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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user