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
260 changes: 245 additions & 15 deletions components/transactions/TransactionsSpreadsheet.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,26 @@
<h2>{{ t('Transactions spreadsheet') }}</h2>
<span class="count">
{{ t('{count} rows', { count: filteredRows.length }) }}
<span v-if="isStreaming" class="loading-more">· {{ t('loading…') }}</span>
</span>
</div>
<div class="spreadsheet-actions">
<input v-model="search" type="search" class="search" :placeholder="t('Search rows...')" />
<input
v-model="searchInput"
type="search"
class="search"
:placeholder="t('Search rows...')"
/>
<button
type="button"
class="action-btn"
:disabled="!filteredRows.length"
:aria-label="t('Export CSV')"
:title="t('Export CSV')"
@click="exportCsv"
>
<ArrowDownTrayIcon class="icon" />
</button>
<button type="button" class="close-btn" :aria-label="t('Close')" @click="$emit('close')">
<XMarkIcon class="icon" />
</button>
Expand All @@ -31,18 +47,31 @@
<thead>
<tr>
<th class="col-idx">#</th>
<th class="col-date">{{ t('Date') }}</th>
<th class="col-type">{{ t('Type') }}</th>
<th class="col-amount">{{ t('Amount') }}</th>
<th class="col-currency">{{ t('Currency') }}</th>
<th class="col-desc">{{ t('Description') }}</th>
<th class="col-cat">{{ t('Categories') }}</th>
<th class="col-wallet">{{ t('Wallet') }}</th>
<th class="col-party">{{ t('Party') }}</th>
<th
v-for="col in columns"
:key="col.key"
:class="[col.cls, 'sortable']"
:aria-sort="
sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'
"
@click="toggleSort(col.key)"
>
<span class="th-inner">
{{ col.label }}
<ChevronUpIcon
v-if="sortKey === col.key && sortDir === 'asc'"
class="sort-icon"
/>
<ChevronDownIcon
v-else-if="sortKey === col.key && sortDir === 'desc'"
class="sort-icon"
/>
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in filteredRows" :key="row.id" :class="`row--${row.type}`">
<tr v-for="(row, i) in sortedRows" :key="row.id" :class="`row--${row.type}`">
<td class="col-idx">{{ i + 1 }}</td>
<td class="col-date">{{ formatDate(row.datetime) }}</td>
<td class="col-type">
Expand Down Expand Up @@ -85,28 +114,67 @@
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { TableCellsIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
TableCellsIcon,
XMarkIcon,
ArrowDownTrayIcon,
ChevronUpIcon,
ChevronDownIcon
} from '@heroicons/vue/24/outline';
import { api } from '@/services/api';

const { t } = useI18n();
const emit = defineEmits<{ (e: 'close'): void }>();

const rows = ref<any[]>([]);
const isLoading = ref(true);
const isStreaming = ref(false);
const error = ref('');
const searchInput = ref('');
const search = ref('');

let searchTimer: ReturnType<typeof setTimeout> | null = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debouncing logic uses a local variable searchTimer. This could lead to memory leaks or multiple timers if the component re-renders frequently or if the watch is triggered rapidly. It is safer to use a ref for the timer or a utility like useDebounce from VueUse.

Suggested change
let searchTimer: ReturnType<typeof setTimeout> | null = null;
const searchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
watch(searchInput, (value) => {
if (searchTimer.value) clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
search.value = value;
}, 200);
});

watch(searchInput, (value) => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
search.value = value;
}, 200);
});

const PAGE_SIZE = 100;
const MAX_PAGES = 50;
// Guards against an out-of-order load overwriting a newer one.
let loadToken = 0;

async function loadAll() {
const token = ++loadToken;
isLoading.value = true;
isStreaming.value = true;
error.value = '';
rows.value = [];
try {
const response = await api.transactions.fetchAll({ limit: 500, page: 1 });
rows.value = Array.isArray(response?.data) ? response.data : [];
let page = 1;
while (page <= MAX_PAGES) {
const response = await api.transactions.fetchAll({ limit: PAGE_SIZE, page });
if (token !== loadToken) return;
const chunk = Array.isArray(response?.data) ? response.data : [];
// Append per page so the first chunk paints immediately and the totals
// climb as later pages stream in, rather than blocking on the full set.
rows.value = rows.value.concat(chunk);
isLoading.value = false;
const lastPage = Number(response?.last_page) || 1;
if (chunk.length < PAGE_SIZE || page >= lastPage) break;
page += 1;
}
} catch (e) {
if (token !== loadToken) return;
error.value = e instanceof Error ? e.message : t('Failed to load');
} finally {
isLoading.value = false;
if (token === loadToken) {
isLoading.value = false;
isStreaming.value = false;
}
}
}

Expand Down Expand Up @@ -138,6 +206,117 @@ const totalExpense = computed(() =>
filteredRows.value.filter((r) => r.type === 'expense').reduce((s, r) => s + Number(r.amount), 0)
);

type SortKey =
| 'datetime'
| 'type'
| 'amount'
| 'currency'
| 'description'
| 'categories'
| 'wallet'
| 'party';

const sortKey = ref<SortKey | null>(null);
const sortDir = ref<'asc' | 'desc'>('asc');

const sortValue = (row: any, key: SortKey): string | number => {
switch (key) {
case 'datetime':
return row.datetime || '';
case 'type':
return row.type || '';
case 'amount':
return Number(row.amount) || 0;
case 'currency':
return row.wallet?.currency || '';
case 'description':
return (row.description || '').toLowerCase();
case 'categories':
return categoriesText(row).toLowerCase();
case 'wallet':
return (row.wallet?.name || '').toLowerCase();
case 'party':
return (row.party?.name || '').toLowerCase();
default:
return '';
}
};

const sortedRows = computed(() => {
const key = sortKey.value;
if (!key) return filteredRows.value;
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...filteredRows.value].sort((a, b) => {
const av = sortValue(a, key);
const bv = sortValue(b, key);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
});

const columns = computed<{ key: SortKey; label: string; cls: string }[]>(() => [
{ key: 'datetime', label: t('Date'), cls: 'col-date' },
{ key: 'type', label: t('Type'), cls: 'col-type' },
{ key: 'amount', label: t('Amount'), cls: 'col-amount' },
{ key: 'currency', label: t('Currency'), cls: 'col-currency' },
{ key: 'description', label: t('Description'), cls: 'col-desc' },
{ key: 'categories', label: t('Categories'), cls: 'col-cat' },
{ key: 'wallet', label: t('Wallet'), cls: 'col-wallet' },
{ key: 'party', label: t('Party'), cls: 'col-party' }
]);

const toggleSort = (key: SortKey) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
} else {
sortKey.value = key;
sortDir.value = 'asc';
}
};

const csvEscape = (value: unknown): string => {
const s = String(value ?? '');
return /["\n,]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};

const exportCsv = () => {
const headers = [
t('Date'),
t('Type'),
t('Amount'),
t('Currency'),
t('Description'),
t('Categories'),
t('Wallet'),
t('Party')
];
const lines = [headers.map(csvEscape).join(',')];
for (const r of sortedRows.value) {
lines.push(
[
formatDate(r.datetime),
r.type,
Number(r.amount) || 0,
r.wallet?.currency ?? '',
r.description ?? '',
categoriesText(r),
r.wallet?.name ?? '',
r.party?.name ?? ''
]
.map(csvEscape)
.join(',')
);
}
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'transactions.csv';
link.click();
URL.revokeObjectURL(url);
};

const formatDate = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
Expand Down Expand Up @@ -265,6 +444,36 @@ onUnmounted(() => {
}
}

.action-btn {
background: transparent;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 6px;
cursor: pointer;
color: $text-muted;
display: inline-flex;

.icon {
width: 16px;
height: 16px;
}

&:hover:not(:disabled) {
color: $primary;
border-color: $primary-muted;
}

&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}

.loading-more {
color: $primary;
font-style: italic;
}

.state {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -319,6 +528,27 @@ onUnmounted(() => {
letter-spacing: 0.05em;
border-bottom: 1px solid $border-color;
z-index: 2;

&.sortable {
cursor: pointer;
user-select: none;

&:hover {
color: $primary;
}
}

.th-inner {
display: inline-flex;
align-items: center;
gap: 4px;
}

.sort-icon {
width: 12px;
height: 12px;
color: $primary;
}
}

tbody td {
Expand Down
Loading
Loading