feat(crm): Finder-style sortable column headers, default sort by issue date

Replace the sort <select> dropdowns on the invoice, quote and contract
list pages with clickable column headers that toggle asc/desc and show a
chevron indicator. Adds a shared SortableHeader component + useColumnSort
hook that maps clickable columns onto the server-side sort enum.
Make issue date (newest first) the standard sort on all three lists,
set at the frontend, route and service layers. Adds issue_asc/issue_desc
to invoices and an "Issued" column to the bills table so the default is
visible and toggleable. Extends sort coverage so every clickable column
has both directions (+customer_desc on all; +issue_asc/desc on
quotes/contracts). Storno rows remain listed.
This commit is contained in:
Luca
2026-06-02 10:59:36 +02:00
parent 095edfe06d
commit d9251c0850
16 changed files with 197 additions and 61 deletions
@@ -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<InvoiceStatus[]>([]);
const [unpaidOnly, setUnpaidOnly] = useState(false);
const [sort, setSort] = useState<InvoiceSort>('newest');
const { sort, activeKey, activeDir, toggle } = useColumnSort<InvoiceSort>(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); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as InvoiceSort)}
>
<option value="newest">{t('bills.sort.newest', 'Newest first')}</option>
<option value="due_asc">{t('bills.sort.dueAsc', 'Due soon first')}</option>
<option value="due_desc">{t('bills.sort.dueDesc', 'Due latest first')}</option>
<option value="customer_asc">{t('bills.sort.customerAsc', 'Customer A→Z')}</option>
<option value="value_asc">{t('bills.sort.valueAsc', 'Value low→high')}</option>
<option value="value_desc">{t('bills.sort.valueDesc', 'Value high→low')}</option>
</select>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={unpaidOnly} onChange={(e) => setUnpaidOnly(e.target.checked)} />
{t('bills.filter.unpaidOnly', 'Unpaid only')}
@@ -127,12 +129,13 @@ export const BillsListPage: React.FC = () => {
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">#</th>
<th className="px-3 py-2 text-left">{t('bills.table.customer', 'Customer')}</th>
<SortableHeader label="#" columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('bills.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('bills.table.event', 'Event')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.installment', 'Installment')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.dueDate', 'Due')}</th>
<th className="px-3 py-2 text-right">{t('bills.table.total', 'Total')}</th>
<SortableHeader label={t('bills.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('bills.table.dueDate', 'Due')} columnKey="due" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('bills.table.total', 'Total')} columnKey="value" activeKey={activeKey} activeDir={activeDir} onSort={onSort} align="right" />
<th className="px-3 py-2 text-left">{t('bills.table.status', 'Status')}</th>
</tr>
</thead>
@@ -177,6 +180,7 @@ export const BillsListPage: React.FC = () => {
<td className="px-3 py-2 text-xs text-muted-theme">
{inv.installmentTotal > 1 ? `${inv.installmentIndex + 1}/${inv.installmentTotal} · ${inv.installmentLabel || ''}` : '—'}
</td>
<td className="px-3 py-2 whitespace-nowrap">{inv.issueDate ? fmtDate(inv.issueDate) : '—'}</td>
<td className="px-3 py-2">{fmtDate(inv.dueDate)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search, BookOpen } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { Button, Card, Loading, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
import {
contractsService,
type ContractStatus,
@@ -27,15 +27,25 @@ const STATUSES: ContractStatus[] = [
'draft', 'sent', 'signed_by_customer', 'signed_by_admin', 'fully_signed', 'cancelled',
];
// "Number" sorts by creation order (newest/oldest); "Issued" sorts by
// the admin-controlled issue_date, which can drift from chronology.
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' },
};
export const ContractsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<ContractStatus[]>([]);
const [sort, setSort] = useState<ContractSort>('newest');
const { sort, activeKey, activeDir, toggle } = useColumnSort<ContractSort>(SORT_COLUMNS, 'issue_desc');
const [page, setPage] = useState(1);
const onSort = (key: string) => { toggle(key); setPage(1); };
const { data, isLoading } = useQuery({
queryKey: ['contracts', { search, statusFilter, sort, page }],
queryFn: () => contractsService.list({
@@ -98,15 +108,6 @@ export const ContractsListPage: React.FC = () => {
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as ContractSort)}
>
<option value="newest">{t('contracts.list.sort.newest', 'Newest first')}</option>
<option value="oldest">{t('contracts.list.sort.oldest', 'Oldest first')}</option>
<option value="customer_asc">{t('contracts.list.sort.customer', 'Customer A→Z')}</option>
</select>
</div>
<div className="mt-3 flex flex-wrap gap-1">
@@ -135,10 +136,10 @@ export const ContractsListPage: React.FC = () => {
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('contracts.list.table.number', 'Number')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.customer', 'Customer')}</th>
<SortableHeader label={t('contracts.list.table.number', 'Number')} columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('contracts.list.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('contracts.list.table.title', 'Title')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.issueDate', 'Issued')}</th>
<SortableHeader label={t('contracts.list.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('contracts.list.table.status', 'Status')}</th>
</tr>
</thead>
@@ -8,21 +8,32 @@ import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search } from 'lucide-react';
import { quotesService, type QuoteStatus, type QuoteSort } from '../../../services/quotes.service';
import { Button, Card, Loading } from '../../../components/common';
import { Button, Card, Loading, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const STATUSES: QuoteStatus[] = ['draft', 'sent', 'accepted', 'declined', 'expired', 'converted'];
// "#" sorts by creation order (newest/oldest); "Issued" sorts by the
// admin-controlled issue_date, which can drift from chronology.
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' },
value: { asc: 'value_asc', desc: 'value_desc', defaultDir: 'desc' },
};
export const QuotesListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format: fmtDate } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<QuoteStatus[]>([]);
const [sort, setSort] = useState<QuoteSort>('newest');
const { sort, activeKey, activeDir, toggle } = useColumnSort<QuoteSort>(SORT_COLUMNS, 'issue_desc');
const [page, setPage] = useState(1);
const onSort = (key: string) => { toggle(key); setPage(1); };
const { data, isLoading } = useQuery({
queryKey: ['quotes', { search, statusFilter, sort, page }],
queryFn: () => quotesService.list({
@@ -73,17 +84,6 @@ export const QuotesListPage: React.FC = () => {
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as QuoteSort)}
>
<option value="newest">{t('quotes.sort.newest', 'Newest first')}</option>
<option value="oldest">{t('quotes.sort.oldest', 'Oldest first')}</option>
<option value="customer_asc">{t('quotes.sort.customerAsc', 'Customer A→Z')}</option>
<option value="value_asc">{t('quotes.sort.valueAsc', 'Value low→high')}</option>
<option value="value_desc">{t('quotes.sort.valueDesc', 'Value high→low')}</option>
</select>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
@@ -111,11 +111,11 @@ export const QuotesListPage: React.FC = () => {
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">#</th>
<th className="px-3 py-2 text-left">{t('quotes.table.customer', 'Customer')}</th>
<SortableHeader label="#" columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('quotes.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('quotes.table.event', 'Event')}</th>
<th className="px-3 py-2 text-left">{t('quotes.table.issueDate', 'Issued')}</th>
<th className="px-3 py-2 text-right">{t('quotes.table.total', 'Total')}</th>
<SortableHeader label={t('quotes.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('quotes.table.total', 'Total')} columnKey="value" activeKey={activeKey} activeDir={activeDir} onSort={onSort} align="right" />
<th className="px-3 py-2 text-left">{t('quotes.table.status', 'Status')}</th>
</tr>
</thead>