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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, SortPair>;
|
||||
|
||||
/**
|
||||
* 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<T extends string>(columns: SortColumnMap, initialSort: T) {
|
||||
const [sort, setSort] = useState<T>(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<SortableHeaderProps> = ({
|
||||
label, columnKey, activeKey, activeDir, onSort, align = 'left',
|
||||
}) => {
|
||||
const active = activeKey === columnKey;
|
||||
return (
|
||||
<th className={`px-3 py-2 ${align === 'right' ? 'text-right' : 'text-left'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(columnKey)}
|
||||
className={`group inline-flex items-center gap-1 font-medium transition-colors hover:text-theme ${
|
||||
align === 'right' ? 'flex-row-reverse' : ''
|
||||
} ${active ? 'text-theme' : ''}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{active ? (
|
||||
activeDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3" />
|
||||
: <ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronsUpDown className="w-3 h-3 opacity-30 group-hover:opacity-60" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -3582,6 +3582,7 @@
|
||||
"customer": "Kunde",
|
||||
"event": "Anlass",
|
||||
"installment": "Rate",
|
||||
"issueDate": "Ausgestellt",
|
||||
"dueDate": "Fällig",
|
||||
"total": "Gesamt",
|
||||
"status": "Status"
|
||||
|
||||
@@ -3522,6 +3522,7 @@
|
||||
"customer": "Customer",
|
||||
"event": "Event",
|
||||
"installment": "Installment",
|
||||
"issueDate": "Issued",
|
||||
"dueDate": "Due",
|
||||
"total": "Total",
|
||||
"status": "Status"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,9 +19,10 @@ export type InvoiceStatus = 'scheduled' | 'sent' | 'paid' | 'overdue' | 'cancell
|
||||
export type InvoiceKind = 'invoice' | 'storno';
|
||||
export type InvoiceSort =
|
||||
| 'newest' | 'oldest'
|
||||
| 'issue_asc' | 'issue_desc'
|
||||
| 'due_asc' | 'due_desc'
|
||||
| 'value_asc' | 'value_desc'
|
||||
| 'customer_asc';
|
||||
| 'customer_asc' | 'customer_desc';
|
||||
|
||||
export type InvoiceQrFormat = 'swiss' | 'epc' | 'none';
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ export type ContractStatus =
|
||||
| 'fully_signed'
|
||||
| 'cancelled';
|
||||
|
||||
export type ContractSort = 'newest' | 'oldest' | 'customer_asc';
|
||||
export type ContractSort =
|
||||
| 'newest' | 'oldest'
|
||||
| 'issue_asc' | 'issue_desc'
|
||||
| 'customer_asc' | 'customer_desc';
|
||||
|
||||
/** Canonical section enum kept in sync with backend SECTIONS_ORDER
|
||||
* and contractBlocksService.ALLOWED_SECTIONS. Renaming any value
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type QuoteStatus = 'draft' | 'sent' | 'accepted' | 'declined' | 'expired' | 'converted';
|
||||
export type QuoteSort = 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc';
|
||||
export type QuoteSort =
|
||||
| 'newest' | 'oldest'
|
||||
| 'issue_asc' | 'issue_desc'
|
||||
| 'customer_asc' | 'customer_desc'
|
||||
| 'value_asc' | 'value_desc';
|
||||
|
||||
export interface QuoteLineItem {
|
||||
id?: number;
|
||||
|
||||
Reference in New Issue
Block a user