diff --git a/backend/src/routes/adminContracts.js b/backend/src/routes/adminContracts.js index 7a571635..b97137e9 100644 --- a/backend/src/routes/adminContracts.js +++ b/backend/src/routes/adminContracts.js @@ -286,7 +286,7 @@ router.get( query('status').optional().isString(), query('customerAccountId').optional().isInt({ min: 1 }), query('q').optional().isString(), - query('sort').optional().isIn(['newest', 'oldest', 'customer_asc']), + query('sort').optional().isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc']), query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 200 }), ], @@ -302,7 +302,7 @@ router.get( const pageSize = parseInt(req.query.pageSize, 10) || 25; const result = await contractService.listContracts({ filters, - sort: req.query.sort || 'newest', + sort: req.query.sort || 'issue_desc', page, pageSize, }); diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index 42375648..1a656da9 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -319,7 +319,7 @@ router.get( query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }), query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(), query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), - query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc']), + query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']), query('page').optional({ values: 'falsy' }).isInt({ min: 1 }), query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }), ], @@ -336,7 +336,7 @@ router.get( unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true, q: req.query.q, }, - sort: req.query.sort || 'newest', + sort: req.query.sort || 'issue_desc', page: req.query.page ? parseInt(req.query.page, 10) : 1, pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25, }); diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index 417d6dda..b0769ec0 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -257,7 +257,7 @@ router.get( query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), query('from').optional({ values: 'falsy' }).isISO8601(), query('to').optional({ values: 'falsy' }).isISO8601(), - query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'customer_asc', 'value_asc', 'value_desc']), + query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc', 'value_asc', 'value_desc']), query('page').optional({ values: 'falsy' }).isInt({ min: 1 }), query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }), ], @@ -272,7 +272,7 @@ router.get( customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null, from: req.query.from, to: req.query.to, q: req.query.q, }, - sort: req.query.sort || 'newest', + sort: req.query.sort || 'issue_desc', page: req.query.page ? parseInt(req.query.page, 10) : 1, pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25, }); diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index e443b4ef..46cc448d 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -621,7 +621,7 @@ async function buildRenderContext(contract, inclusions) { // Public API // --------------------------------------------------------------------- -async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) { +async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { return await withRetry(async () => { let query = db('contracts') .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') @@ -658,11 +658,22 @@ async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize case 'oldest': query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); break; + case 'issue_asc': + query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc'); + break; + case 'issue_desc': + query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc'); + break; case 'customer_asc': query = query .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') .orderBy('contracts.id', 'desc'); break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('contracts.id', 'desc'); + break; case 'newest': default: query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc'); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index bc863f0c..1e1f09ba 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -410,7 +410,7 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) { // Public API // --------------------------------------------------------------------- -async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) { +async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { return await withRetry(async () => { let query = db('invoices') .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') @@ -477,6 +477,8 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize // reflects when the row landed in the DB. id is the tiebreaker // for rows that share a created_at second. case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break; + case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break; + case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break; case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break; case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break; case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break; @@ -486,6 +488,11 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') .orderBy('invoices.id', 'desc'); break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('invoices.id', 'desc'); + break; case 'newest': default: query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc'); diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 0c993ca0..af277091 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -333,7 +333,7 @@ function ensureCustomerFeatureEnabled(customer, feature) { * Filters: { status[], customerAccountId, from, to, q } * Sort: 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc' */ -async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) { +async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { return await withRetry(async () => { let query = db('quotes') .leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id') @@ -385,11 +385,22 @@ async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize = case 'oldest': query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc'); break; + case 'issue_asc': + query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc'); + break; + case 'issue_desc': + query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc'); + break; case 'customer_asc': query = query .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') .orderBy('quotes.id', 'desc'); break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('quotes.id', 'desc'); + break; case 'value_asc': query = query.orderBy('quotes.total_amount_minor', 'asc'); break; diff --git a/frontend/src/components/common/SortableHeader.tsx b/frontend/src/components/common/SortableHeader.tsx new file mode 100644 index 00000000..91edb1fa --- /dev/null +++ b/frontend/src/components/common/SortableHeader.tsx @@ -0,0 +1,90 @@ +/** + * Finder-style sortable table header. + * + * The admin list pages (invoices / quotes / contracts) drive sorting + * through a single server-side `sort` enum (e.g. 'customer_asc'). This + * component + the `useColumnSort` hook map that flat enum onto clickable + * column headers: clicking a column applies its ascending/descending + * variant, clicking the active column again flips direction. The active + * column shows a filled chevron; inactive sortable columns show a faint + * up/down hint so it's discoverable that the header is clickable. + */ +import React, { useCallback, useMemo, useState } from 'react'; +import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'; + +export type SortDir = 'asc' | 'desc'; + +/** Maps one logical column to its two server-side sort enum values. */ +export interface SortPair { + asc: string; + desc: string; + /** Direction applied when this column is first clicked. Defaults to 'asc'. */ + defaultDir?: SortDir; +} + +export type SortColumnMap = Record; + +/** + * Holds the flat `sort` enum as the single source of truth and exposes + * the active column + a toggle that flips direction on re-click. Returns + * `sort` to feed straight into the list query and `setSort` for any + * legacy callers that still set the enum directly. + */ +export function useColumnSort(columns: SortColumnMap, initialSort: T) { + const [sort, setSort] = useState(initialSort); + + const active = useMemo(() => { + for (const [key, pair] of Object.entries(columns)) { + if (pair.asc === sort) return { key, dir: 'asc' as SortDir }; + if (pair.desc === sort) return { key, dir: 'desc' as SortDir }; + } + return { key: null as string | null, dir: 'asc' as SortDir }; + }, [columns, sort]); + + const toggle = useCallback((key: string) => { + const pair = columns[key]; + if (!pair) return; + setSort((prev) => { + if (prev === pair.asc) return pair.desc as T; + if (prev === pair.desc) return pair.asc as T; + return (pair.defaultDir === 'desc' ? pair.desc : pair.asc) as T; + }); + }, [columns]); + + return { sort, setSort, activeKey: active.key, activeDir: active.dir, toggle }; +} + +interface SortableHeaderProps { + label: React.ReactNode; + columnKey: string; + activeKey: string | null; + activeDir: SortDir; + onSort: (key: string) => void; + align?: 'left' | 'right'; +} + +export const SortableHeader: React.FC = ({ + label, columnKey, activeKey, activeDir, onSort, align = 'left', +}) => { + const active = activeKey === columnKey; + return ( + + + + ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 68130e1a..6a11c004 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -3,6 +3,8 @@ export { CMSContentBlock } from './CMSContentBlock'; export { Input } from './Input'; export { CountrySelect } from './CountrySelect'; export { LocalizedDateInput } from './LocalizedDateInput'; +export { SortableHeader, useColumnSort } from './SortableHeader'; +export type { SortDir, SortPair, SortColumnMap } from './SortableHeader'; export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Loading, LoadingSkeleton } from './Loading'; export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index a8e43491..1a5b0e50 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3582,6 +3582,7 @@ "customer": "Kunde", "event": "Anlass", "installment": "Rate", + "issueDate": "Ausgestellt", "dueDate": "Fällig", "total": "Gesamt", "status": "Status" diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4bd19899..3ac57979 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3522,6 +3522,7 @@ "customer": "Customer", "event": "Event", "installment": "Installment", + "issueDate": "Issued", "dueDate": "Due", "total": "Total", "status": "Status" diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index f84c618e..e25fcbd6 100644 --- a/frontend/src/pages/admin/bills/BillsListPage.tsx +++ b/frontend/src/pages/admin/bills/BillsListPage.tsx @@ -8,7 +8,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Search, Upload, X } from 'lucide-react'; import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service'; -import { Button, Card, Input, Loading, LocalizedDateInput } from '../../../components/common'; +import { Button, Card, Input, Loading, LocalizedDateInput, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common'; import { formatMoney } from '../../../components/admin/LineItemsTable'; import { customerAdminService } from '../../../services/customerAdmin.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; @@ -16,6 +16,18 @@ import { toast } from 'react-toastify'; const STATUSES: InvoiceStatus[] = ['scheduled', 'pending_delivery', 'sent', 'paid', 'overdue', 'cancelled', 'skipped']; +// Maps each clickable column to its server-side sort enum pair. The "#" +// column sorts by creation order (newest/oldest) since that's how the +// invoice sequence is assigned; "Issued" sorts the admin-controlled +// issue_date and is the default (newest issued first). +const SORT_COLUMNS: SortColumnMap = { + number: { asc: 'oldest', desc: 'newest', defaultDir: 'desc' }, + customer: { asc: 'customer_asc', desc: 'customer_desc' }, + issue: { asc: 'issue_asc', desc: 'issue_desc', defaultDir: 'desc' }, + due: { asc: 'due_asc', desc: 'due_desc' }, + value: { asc: 'value_asc', desc: 'value_desc', defaultDir: 'desc' }, +}; + export const BillsListPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -23,10 +35,12 @@ export const BillsListPage: React.FC = () => { const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState([]); const [unpaidOnly, setUnpaidOnly] = useState(false); - const [sort, setSort] = useState('newest'); + const { sort, activeKey, activeDir, toggle } = useColumnSort(SORT_COLUMNS, 'issue_desc'); const [page, setPage] = useState(1); const [importOpen, setImportOpen] = useState(false); + const onSort = (key: string) => { toggle(key); setPage(1); }; + const { data, isLoading } = useQuery({ queryKey: ['invoices', { search, statusFilter, unpaidOnly, sort, page }], queryFn: () => billsService.list({ @@ -87,18 +101,6 @@ export const BillsListPage: React.FC = () => { onChange={(e) => { setSearch(e.target.value); setPage(1); }} /> -