Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
418 changes: 56 additions & 362 deletions components/ContentTable.vue

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions components/TDataTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<template>
<div class="table-scroll">
<table
class="data-table"
:class="{
'data-table--clickable': clickable,
'data-table--expense': headerType === 'expense'
}"
>
<thead>
<tr>
<th
v-for="column in columns"
:key="column.key"
:class="column.align ? `text-${column.align}` : undefined"
:style="column.width ? { width: column.width } : undefined"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
:key="String(row[rowKey])"
:class="rowClass?.(row)"
@click="$emit('row-click', row)"
>
<td
v-for="column in columns"
:key="column.key"
:class="column.align ? `text-${column.align}` : undefined"
>
<slot :name="`cell-${column.key}`" :row="row" :value="valueAt(row, column.key)">
{{ column.format?.(valueAt(row, column.key), row) ?? valueAt(row, column.key) }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>

<script setup lang="ts">
export interface DataTableColumn {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
width?: string;
format?: (value: unknown, row: Record<string, any>) => unknown;
}

withDefaults(
defineProps<{
columns: DataTableColumn[];
rows: Record<string, any>[];
rowKey?: string;
clickable?: boolean;
headerType?: 'default' | 'expense';
rowClass?: (row: Record<string, any>) => string | Record<string, boolean> | undefined;
}>(),
{ rowKey: 'id', clickable: false, headerType: 'default', rowClass: undefined }
);

defineEmits<{
'row-click': [row: Record<string, any>];
}>();

const valueAt = (row: Record<string, any>, key: string): unknown =>
key.split('.').reduce<unknown>((value, part) => {
if (value === null || typeof value !== 'object') return undefined;
return (value as Record<string, unknown>)[part];
}, row);
</script>

<style lang="scss" scoped>
@use '@/assets/scss/_variables.scss' as *;

.table-scroll {
width: 100%;
overflow-x: auto;
}

.data-table {
width: 100%;
min-width: 500px;
border-collapse: collapse;
font-size: $font-size-sm;

thead tr {
background: $primary-light;
}

th {
padding: 8px 16px;
border-bottom: 1px solid $border-light;
color: $primary-dark;
font-size: $font-size-xs;
font-weight: $font-bold;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}

td {
padding: 8px 16px;
border-bottom: 1px solid $border-light;
color: $text-primary;
vertical-align: middle;
}

tbody tr {
background: $bg-white;
transition: background-color $duration-fast $easing-standard;

&:hover {
background: rgba(var(--color-primary-rgb), 0.04);
}

&:last-child td {
border-bottom: none;
}

&.is-default {
background: rgba(var(--color-success-rgb), 0.08);
}
}

.text-right {
text-align: right;
}

.text-center {
text-align: center;
}
}

.data-table--clickable tbody tr {
cursor: pointer;
}

.data-table--expense thead tr {
background: rgba(var(--color-expense-rgb), 0.12);

th {
color: var(--color-expense);
}
}
</style>
122 changes: 122 additions & 0 deletions components/TPagination.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<template>
<div class="pagination">
<div class="pagination__controls">
<button
class="pagination__button"
:disabled="currentPage <= 1 || disabled"
:aria-label="t('Previous page')"
@click="$emit('page-change', currentPage - 1)"
>
<ChevronLeft :size="16" />
</button>
<button
v-for="page in visiblePages"
:key="page"
class="pagination__button"
:class="{ 'pagination__button--active': page === currentPage }"
:disabled="page === '...' || disabled"
@click="typeof page === 'number' && $emit('page-change', page)"
>
{{ page }}
</button>
<button
class="pagination__button"
:disabled="currentPage >= totalPages || disabled"
:aria-label="t('Next page')"
@click="$emit('page-change', currentPage + 1)"
>
<ChevronRight :size="16" />
</button>
</div>
<slot name="info" />
<slot />
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';

const props = withDefaults(
defineProps<{
currentPage: number;
totalPages: number;
disabled?: boolean;
}>(),
{ disabled: false }
);

defineEmits<{ 'page-change': [page: number] }>();

const { t } = useI18n();

const visiblePages = computed<(number | string)[]>(() => {
if (props.totalPages <= 7) {
return Array.from({ length: props.totalPages }, (_, index) => index + 1);
}

const pages: (number | string)[] = [1];
if (props.currentPage > 3) pages.push('...');
const start = Math.max(2, Math.min(props.currentPage - 1, props.totalPages - 3));
const end = Math.min(props.totalPages - 1, start + 2);
for (let page = start; page <= end; page++) pages.push(page);
if (props.currentPage < props.totalPages - 2) pages.push('...');
pages.push(props.totalPages);
return pages;
});
</script>

<style lang="scss" scoped>
@use '@/assets/scss/_variables.scss' as *;

.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-3;
padding: $spacing-3 $spacing-4;
border-top: 1px solid $border-light;
color: $text-muted;
font-size: $font-size-sm;

@media (max-width: $breakpoint-sm) {
flex-wrap: wrap;
justify-content: center;
}
}

.pagination__controls {
display: flex;
align-items: center;
gap: 0.25rem;
}

.pagination__button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 $spacing-2;
border: none;
border-radius: $radius-md;
background: transparent;
color: $text-secondary;
font-weight: $font-semibold;
cursor: pointer;

&:hover:not(:disabled) {
background: $bg-light;
}

&:disabled {
opacity: 0.4;
cursor: default;
}

&--active {
background: $primary-light;
color: $primary;
}
}
</style>
2 changes: 1 addition & 1 deletion components/admin/OutreachComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ const filteredUsers = computed(() => {

onMounted(async () => {
try {
users.value = await adminApi.users();
users.value = (await adminApi.users({ perPage: 100 })).data;
} catch {
users.value = [];
}
Expand Down
5 changes: 5 additions & 0 deletions components/admin/UserDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
<dt>{{ t('Last transaction') }}</dt>
<dd>{{ detail.last_transaction_at ? fmtDate(detail.last_transaction_at) : '-' }}</dd>
</div>
<div class="fact">
<dt>{{ t('AI tokens used') }}</dt>
<dd>{{ fmtNumber(detail.user.tokens_used) }}</dd>
</div>
</dl>
</TCard>

Expand Down Expand Up @@ -91,6 +95,7 @@ const initials = computed(() => {
});

const fmtDate = (iso: string): string => (iso ? new Date(iso).toLocaleDateString() : '');
const fmtNumber = (value: number): string => new Intl.NumberFormat().format(value ?? 0);

const load = async () => {
loading.value = true;
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/de.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"Joined on {date}": "Beigetreten am {date}",
"Last seen": "Zuletzt gesehen",
"Last transaction": "Letzte Transaktion",
"Never": "Nie",
"Page {page} of {pages} · {total} users": "Seite {page} von {pages} · {total} Benutzer",
"Recent imports": "Letzte Importe",
"Needs review": "Zu prüfen",
"Analyzing": "Wird analysiert",
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"Joined on {date}": "Joined on {date}",
"Last seen": "Last seen",
"Last transaction": "Last transaction",
"Never": "Never",
"Page {page} of {pages} · {total} users": "Page {page} of {pages} · {total} users",
"Recent imports": "Recent imports",
"Needs review": "Needs review",
"Analyzing": "Analyzing",
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/es.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"Joined on {date}": "Se unió el {date}",
"Last seen": "Última visita",
"Last transaction": "Última transacción",
"Never": "Nunca",
"Page {page} of {pages} · {total} users": "Página {page} de {pages} · {total} usuarios",
"Recent imports": "Importaciones recientes",
"Needs review": "Por revisar",
"Analyzing": "Analizando",
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/fr.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"Joined on {date}": "Inscrit le {date}",
"Last seen": "Dernière visite",
"Last transaction": "Dernière transaction",
"Never": "Jamais",
"Page {page} of {pages} · {total} users": "Page {page} sur {pages} · {total} utilisateurs",
"Recent imports": "Imports récents",
"Needs review": "À vérifier",
"Analyzing": "Analyse en cours",
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/it.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"Joined on {date}": "Iscritto il {date}",
"Last seen": "Ultimo accesso",
"Last transaction": "Ultima transazione",
"Never": "Mai",
"Page {page} of {pages} · {total} users": "Pagina {page} di {pages} · {total} utenti",
"Recent imports": "Importazioni recenti",
"Needs review": "Da rivedere",
"Analyzing": "Analisi in corso",
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/pt.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"Joined on {date}": "Inscrito em {date}",
"Last seen": "Último acesso",
"Last transaction": "Última transação",
"Never": "Nunca",
"Page {page} of {pages} · {total} users": "Página {page} de {pages} · {total} utilizadores",
"Recent imports": "Importações recentes",
"Needs review": "A rever",
"Analyzing": "A analisar",
Expand Down
Loading
Loading