feat(delivery-notes, migrations): add tabs, grouping, markup, and fix DN relations
- Add shared env loader for migration scripts to load .env and .env.local files at module init - Add migration script to fix broken delivery note relation fields and backfill missing company data - Update delivery notes list page: add status filter tabs, company-based collapsible grouping, increased fetch limit, and improved global search - Add per-item cost price, markup calculations, and total summary rows for cost, selling price, markup, and margin to delivery note detail pages
This commit is contained in:
parent
43de97501c
commit
3b8bc0fc35
4 changed files with 535 additions and 72 deletions
130
scripts/migrations/2026-08-13-fix-delivery-notes-relations.ts
Normal file
130
scripts/migrations/2026-08-13-fix-delivery-notes-relations.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import "./_env-loader";
|
||||
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
/**
|
||||
* Migration — 2026-08-13-fix-delivery-notes-relations.ts
|
||||
*
|
||||
* `delivery_notes` was created with relation fields (`customer`,
|
||||
* `company`, `order`, `created_by`) but the live schema has
|
||||
* `collectionId: null` on those fields, so any `expand=...` query
|
||||
* silently returns nothing. The list page relies on
|
||||
* `expand=company.company` for per-company grouping, so all DNs
|
||||
* collapse into a single "No company" bucket — and crucially, the
|
||||
* records remain in the table, but grouped wrong (and reported
|
||||
* missing when the operator searches by number).
|
||||
*
|
||||
* This migration patches the relation targets so expansion resolves,
|
||||
* then backfills `delivery_notes.company` from the parent order's
|
||||
* company where it's currently empty (defensive — most records
|
||||
* already have the right value from the original create path).
|
||||
*
|
||||
* Run:
|
||||
* npx tsx scripts/migrations/2026-08-13-fix-delivery-notes-relations.ts
|
||||
*/
|
||||
|
||||
const CUSTOMERS = "customers";
|
||||
const COMPANIES = "companies";
|
||||
const ORDERS = "orders";
|
||||
const USERS = "users";
|
||||
const DELIVERY_NOTES = "delivery_notes";
|
||||
|
||||
async function patchRelation(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
collectionId: string,
|
||||
fieldName: string,
|
||||
targetCollectionId: string,
|
||||
log: string[],
|
||||
): Promise<void> {
|
||||
const collection = await pb.collections.getOne(collectionId);
|
||||
const field = (collection.fields ?? []).find((f) => f.name === fieldName);
|
||||
if (!field) {
|
||||
log.push(` ✗ ${fieldName} not found on ${collection.name}`);
|
||||
return;
|
||||
}
|
||||
if (field.type !== "relation") {
|
||||
log.push(` ✗ ${fieldName} is not a relation (type=${field.type})`);
|
||||
return;
|
||||
}
|
||||
const opts = (field.options ?? {}) as { collectionId?: string | null };
|
||||
if (opts.collectionId === targetCollectionId) {
|
||||
log.push(` · ${fieldName}.collectionId already set — skip`);
|
||||
return;
|
||||
}
|
||||
log.push(` → patching ${fieldName}.collectionId to ${targetCollectionId}`);
|
||||
await pb.collections.update(collection.id, {
|
||||
fields: [
|
||||
{ ...field, options: { ...opts, collectionId: targetCollectionId } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function backfillCompany(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
log: string[],
|
||||
): Promise<{ updated: number; skipped: number; failed: number }> {
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
const records = await pb.collection(DELIVERY_NOTES).getFullList<{
|
||||
id: string;
|
||||
company: string;
|
||||
order: string;
|
||||
}>({ filter: "company = '' || company = null" });
|
||||
log.push(` · ${records.length} delivery note(s) need a company backfill`);
|
||||
for (const dn of records) {
|
||||
if (!dn.order) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const order = await pb.collection(ORDERS).getOne(dn.order, {
|
||||
fields: "company",
|
||||
});
|
||||
const company = (order as unknown as { company?: string }).company;
|
||||
if (!company) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await pb.collection(DELIVERY_NOTES).update(dn.id, { company });
|
||||
updated += 1;
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
log.push(
|
||||
` ✗ ${dn.id} (${dn.delivery_note_number || dn.id}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { updated, skipped, failed };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const log: string[] = [];
|
||||
log.push("[migration:fix-delivery-notes-relations] starting");
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
const customersCol = await pb.collections.getOne(CUSTOMERS);
|
||||
const companiesCol = await pb.collections.getOne(COMPANIES);
|
||||
const ordersCol = await pb.collections.getOne(ORDERS);
|
||||
const usersCol = await pb.collections.getOne(USERS);
|
||||
|
||||
log.push("[migration:fix-delivery-notes-relations] step 1 — patch relation targets");
|
||||
await patchRelation(pb, DELIVERY_NOTES, "customer", customersCol.id, log);
|
||||
await patchRelation(pb, DELIVERY_NOTES, "company", companiesCol.id, log);
|
||||
await patchRelation(pb, DELIVERY_NOTES, "order", ordersCol.id, log);
|
||||
await patchRelation(pb, DELIVERY_NOTES, "created_by", usersCol.id, log);
|
||||
|
||||
log.push("[migration:fix-delivery-notes-relations] step 2 — backfill empty company from order");
|
||||
const { updated, skipped, failed } = await backfillCompany(pb, log);
|
||||
log.push(
|
||||
`[migration:fix-delivery-notes-relations] updated=${updated} skipped=${skipped} failed=${failed}`,
|
||||
);
|
||||
|
||||
log.push("[migration:fix-delivery-notes-relations] done");
|
||||
for (const line of log) console.log(line);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[migration:fix-delivery-notes-relations] fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
31
scripts/migrations/_env-loader.ts
Normal file
31
scripts/migrations/_env-loader.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Inline .env/.env.local loader — must be the FIRST import in any
|
||||
* migration script that reads process.env at module-init time.
|
||||
* tsx's ESM hoisting means all top-level imports run before the
|
||||
* body, so we load env inside the imported module's TOP-LEVEL
|
||||
* SCOPE (here) instead of in the consumer's body.
|
||||
*/
|
||||
function loadEnv(): void {
|
||||
for (const name of [".env", ".env.local"]) {
|
||||
try {
|
||||
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
||||
if (m && !process.env[m[1]]) {
|
||||
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// missing file — rely on shell env
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
export { loadEnv };
|
||||
|
|
@ -671,6 +671,15 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
|
|||
(sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.quantity) || 1),
|
||||
0,
|
||||
);
|
||||
const itemCostTotal = items.reduce(
|
||||
(sum, it) => sum + (Number(it.cost_price) || 0) * (Number(it.quantity) || 1),
|
||||
0,
|
||||
);
|
||||
const itemMarkupTotal = itemSubtotal - itemCostTotal;
|
||||
const itemMarkupPercent =
|
||||
itemCostTotal > 0 && itemSubtotal > 0
|
||||
? ((itemSubtotal - itemCostTotal) / itemCostTotal) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -1080,26 +1089,34 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
|
|||
) : (
|
||||
<Table className="text-xs">
|
||||
<colgroup>
|
||||
<col className="w-[58%] sm:w-[48%]" />
|
||||
<col className="hidden sm:table-cell sm:w-[18%]" />
|
||||
<col className="w-[10%] sm:w-[8%]" />
|
||||
<col className="w-[16%] sm:w-[13%]" />
|
||||
<col className="w-[16%] sm:w-[13%]" />
|
||||
<col className="w-[44%] sm:w-[40%]" />
|
||||
<col className="w-[10%] sm:w-[10%]" />
|
||||
<col className="w-[14%] sm:w-[14%]" />
|
||||
<col className="w-[16%] sm:w-[18%]" />
|
||||
<col className="w-[16%] sm:w-[18%]" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-2 py-1 text-[11px]">Description</TableHead>
|
||||
<TableHead className="hidden px-2 py-1 text-[11px] sm:table-cell">Supplier</TableHead>
|
||||
<TableHead className="px-1 py-1 text-right text-[11px]">Qty</TableHead>
|
||||
<TableHead className="px-1 py-1 text-right text-[11px]">Unit</TableHead>
|
||||
<TableHead className="px-1 py-1 text-right text-[11px]">Subtotal</TableHead>
|
||||
<TableHead className="px-1 py-1 text-right text-[11px]">Cost</TableHead>
|
||||
<TableHead className="px-1 py-1 text-right text-[11px]">Selling</TableHead>
|
||||
<TableHead className="px-1 py-1 text-right text-[11px]">Markup</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((it) => {
|
||||
const qty = Number(it.quantity) || 1;
|
||||
const costPrice = Number(it.cost_price) || 0;
|
||||
const price = Number(it.selling_price) || 0;
|
||||
const subtotal = qty * price;
|
||||
const costSubtotal = qty * costPrice;
|
||||
const markupValue = costPrice > 0 ? price - costPrice : 0;
|
||||
const markupPercent =
|
||||
costPrice > 0 && price > 0
|
||||
? ((price - costPrice) / costPrice) * 100
|
||||
: 0;
|
||||
const hasMarkup = costPrice > 0 && price > 0;
|
||||
return (
|
||||
<TableRow key={it.id} className="text-xs">
|
||||
<TableCell className="px-2 py-1 align-top whitespace-normal">
|
||||
|
|
@ -1115,23 +1132,7 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
|
|||
Status: {it.item_status}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden px-2 py-1 align-top text-[11px] text-muted-foreground break-words sm:table-cell">
|
||||
{it.expand?.supplier?.suppliername || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
|
||||
{/*
|
||||
Inline qty editor. Click the static
|
||||
number to open an input; Enter or blur
|
||||
saves, Escape cancels. Only available
|
||||
on draft / sent DNs — once the DN is
|
||||
po_received / invoiced / completed,
|
||||
the cell goes read-only and the
|
||||
"Qty edited" affordance goes away.
|
||||
`qtySavingId` adds a brief spinner
|
||||
while the PATCH is in flight; the
|
||||
cell stays mounted (and disabled) so
|
||||
the layout doesn't jump.
|
||||
*/}
|
||||
{qtyEditingId === it.id ? (
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -1192,12 +1193,41 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
|
||||
{price ? formatCurrency(price) : (
|
||||
{costPrice ? (
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span>{formatCurrency(costPrice)}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Σ {formatCurrency(costSubtotal)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
|
||||
{price ? formatCurrency(subtotal) : (
|
||||
{price ? (
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span>{formatCurrency(price)}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Σ {formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
|
||||
{hasMarkup ? (
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span className={markupPercent < 0 ? "text-destructive" : ""}>
|
||||
{markupPercent.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{markupValue >= 0 ? "+" : ""}
|
||||
{formatCurrency(markupValue)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
|
@ -1207,6 +1237,56 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
|
|||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{(itemSubtotal > 0 || itemCostTotal > 0) && (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 border-t px-4 py-3 text-xs sm:grid-cols-4">
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Total cost
|
||||
</div>
|
||||
<div className="font-semibold tabular-nums">
|
||||
{itemCostTotal > 0 ? formatCurrency(itemCostTotal) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Total selling
|
||||
</div>
|
||||
<div className="font-semibold tabular-nums">
|
||||
{itemSubtotal > 0 ? formatCurrency(itemSubtotal) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Markup
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
itemMarkupTotal < 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{itemCostTotal > 0 && itemSubtotal > 0
|
||||
? `${itemMarkupPercent.toFixed(1)}%`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Margin
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
itemMarkupTotal < 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{itemCostTotal > 0 && itemSubtotal > 0
|
||||
? `${itemMarkupTotal >= 0 ? "+" : ""}${formatCurrency(itemMarkupTotal)}`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import {
|
|||
import { toast } from "sonner";
|
||||
import {
|
||||
Banknote,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Combine,
|
||||
FileSignature,
|
||||
FileText,
|
||||
|
|
@ -38,6 +41,7 @@ import {
|
|||
MailCheck,
|
||||
Plus,
|
||||
Receipt,
|
||||
Search as SearchIcon,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
|
|
@ -46,6 +50,32 @@ import type {
|
|||
Quote,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Tab key for the list-page filter strip. The four buckets cover
|
||||
* the full lifecycle without forcing the operator to scroll:
|
||||
* - "active" → draft + sent + po_received + invoiced (anything in flight)
|
||||
* - "cancelled" → cancelled only (kept separate so old cancelled DNs
|
||||
* don't pollute the working list)
|
||||
* - "completed" → invoiced/completed (terminal, visible but inert)
|
||||
* - "all" → no filter (escape hatch when the operator
|
||||
* doesn't know the status)
|
||||
*/
|
||||
type DnTab = "active" | "cancelled" | "completed" | "all";
|
||||
|
||||
const TAB_LABELS: Record<DnTab, string> = {
|
||||
active: "Active",
|
||||
cancelled: "Cancelled",
|
||||
completed: "Completed",
|
||||
all: "All",
|
||||
};
|
||||
|
||||
const TAB_STATUSES: Record<DnTab, DeliveryNoteStatus[]> = {
|
||||
active: ["draft", "sent", "po_received", "invoiced"],
|
||||
cancelled: ["cancelled"],
|
||||
completed: ["completed"],
|
||||
all: ["draft", "sent", "po_received", "invoiced", "completed", "cancelled"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Status → badge color. The lifecycle is:
|
||||
* draft — created, items assembled, not yet sent
|
||||
|
|
@ -92,6 +122,18 @@ export default function DeliveryNotesPage() {
|
|||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
// List-page filter strip. Defaults to "active" so the operator
|
||||
// opens the page to a working list; cancelled / completed /
|
||||
// all are opt-in. Status filter is applied client-side over
|
||||
// the current page of notes (the server still returns the full
|
||||
// recent set so search/tab switching doesn't have to re-fetch).
|
||||
const [tab, setTab] = useState<DnTab>("active");
|
||||
// Per-company open state. All groups start expanded so the
|
||||
// operator sees the full picture on load; the chevron on each
|
||||
// card header collapses a company they don't care about. State
|
||||
// lives here (not in URL) because grouping is a viewing
|
||||
// preference, not a navigable page.
|
||||
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// Per-row busy state. The "Create Quote" / "Capture PO" / "Mark
|
||||
// Invoiced" buttons can each be in-flight for different rows at
|
||||
|
|
@ -216,11 +258,72 @@ export default function DeliveryNotesPage() {
|
|||
.filter((n): n is DeliveryNote => !!n);
|
||||
}, [notes, selectedIds]);
|
||||
|
||||
// Per-tab counts. Drives the badge in each tab label so the
|
||||
// operator can see at a glance how many DNs sit in each bucket
|
||||
// before clicking. Counts are computed against the full `notes`
|
||||
// array (the page of DNs the server returned) — not the
|
||||
// filtered view — so the badge stays accurate when the filter
|
||||
// changes.
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts: Record<DnTab, number> = {
|
||||
active: 0,
|
||||
cancelled: 0,
|
||||
completed: 0,
|
||||
all: notes.length,
|
||||
};
|
||||
for (const note of notes) {
|
||||
if (note.status === "cancelled") counts.cancelled += 1;
|
||||
else if (note.status === "completed") counts.completed += 1;
|
||||
else counts.active += 1;
|
||||
}
|
||||
return counts;
|
||||
}, [notes]);
|
||||
|
||||
// Apply the tab filter. Selection / bulk actions keep working
|
||||
// because we filter the visible array but keep the underlying
|
||||
// `notes` untouched.
|
||||
const visibleNotes = useMemo(
|
||||
() => notes.filter((note) => TAB_STATUSES[tab].includes(note.status)),
|
||||
[notes, tab],
|
||||
);
|
||||
|
||||
// Group visible notes by company. The list used to be one flat
|
||||
// table, but operators deal with DNs at the "what's outstanding
|
||||
// for X" level — a flat list mixed 6 companies together and
|
||||
// made scanning painful. Grouping brings company context into
|
||||
// the table header so the operator never has to look up the
|
||||
// company for a row.
|
||||
//
|
||||
// Empty / missing company collapses into a single "No company"
|
||||
// bucket so it doesn't show up at the top of the list. Within
|
||||
// each group rows are sorted by created desc — same as the
|
||||
// server sort — so the most recent DN per company leads.
|
||||
const groupedNotes = useMemo(() => {
|
||||
const groups = new Map<string, DeliveryNote[]>();
|
||||
for (const note of visibleNotes) {
|
||||
const key = note.expand?.company?.company?.trim() || "No company";
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(note);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.entries()).sort(([a], [b]) => {
|
||||
if (a === "No company") return 1;
|
||||
if (b === "No company") return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [visibleNotes]);
|
||||
|
||||
const fetchNotes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await getDeliveryNotes(page, 20, search);
|
||||
// Fetch a wide page so the client-side tab filter sees the
|
||||
// full set — the previous `perPage=20` made the active
|
||||
// tab silently drop records past rank 20 (the operator
|
||||
// saw "no DN" when the row existed but was buried). 500
|
||||
// is well over the production DN count (≈30) and still
|
||||
// cheap for PB.
|
||||
const result = await getDeliveryNotes(page, 500, search);
|
||||
setNotes(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
// Batch-fetch the related quotes in one query so the Q
|
||||
|
|
@ -883,52 +986,171 @@ export default function DeliveryNotesPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={notes}
|
||||
loading={loading}
|
||||
error={error}
|
||||
searchPlaceholder="Search delivery notes..."
|
||||
onSearch={(q) => {
|
||||
setSearch(q);
|
||||
setPage(1);
|
||||
}}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
onRowClick={(item) => router.push(`/delivery-notes/${item.id}`)}
|
||||
selection={{
|
||||
isSelected: (item) => selectedIds.has(item.id),
|
||||
onToggle: (item) => toggleSelected(item.id),
|
||||
isDisabled: isSelectionDisabled,
|
||||
}}
|
||||
mobileCardRender={(item) => (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium">{item.delivery_note_number}</p>
|
||||
<Badge
|
||||
variant={statusColors[item.status] || "secondary"}
|
||||
data-testid={`dn-status-mobile-${item.id}`}
|
||||
>
|
||||
{statusLabel[item.status] || item.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.expand?.order?.order_number || "No order"} · {item.expand?.customer?.customer || "No customer"}
|
||||
</p>
|
||||
{item.customer_po && (
|
||||
<p className="text-xs font-mono">PO: {item.customer_po}</p>
|
||||
{/* Status filter strip. Active is the default landing tab so
|
||||
the operator opens the page to the working list;
|
||||
cancelled / completed live one click away and never
|
||||
collide with the active workflow. Each tab carries a
|
||||
count badge so the operator can tell at a glance how
|
||||
many DNs sit in each bucket. */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Filter delivery notes by status"
|
||||
className="inline-flex flex-wrap items-center gap-1 rounded-lg border bg-card p-1 shadow-sm"
|
||||
>
|
||||
{(Object.keys(TAB_LABELS) as DnTab[]).map((key) => {
|
||||
const isActive = key === tab;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => setTab(key)}
|
||||
data-testid={`dn-tab-${key}`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{formatDate(item.created)}</p>
|
||||
{/* Mobile action lives inline at the bottom of the
|
||||
card so the row is fully self-contained — no need
|
||||
to scroll horizontally to find the button. */}
|
||||
<div className="pt-1">{ActionCell(item)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{TAB_LABELS[key]}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] tabular-nums",
|
||||
isActive
|
||||
? "bg-primary-foreground/15 text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tabCounts[key]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Global search. One input filters across every company
|
||||
below — operators usually know the DN number / customer
|
||||
/ order, not the company. We use a plain Input here
|
||||
instead of a full DataTable so the search input doesn't
|
||||
render its own empty-state banner ("No records found")
|
||||
that hides the actual grouped list. */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search delivery notes..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="pl-9 border-warn/40 bg-warn/5 focus-visible:border-warn focus-visible:ring-warn/30"
|
||||
data-testid="dn-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-company grouping. Each company is its own collapsible
|
||||
card; rows stay local to that company. The card is open
|
||||
by default so the operator sees everything without an
|
||||
extra click, and the chevron lets them collapse a company
|
||||
they don't care about right now. */}
|
||||
{groupedNotes.length === 0 && !loading ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-16 text-center text-muted-foreground">
|
||||
<p className="text-sm font-medium">No delivery notes on this tab.</p>
|
||||
<p className="text-xs">Switch tabs above or create a new delivery note.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupedNotes.map(([companyName, groupNotes]) => {
|
||||
const isOpen = openGroups.has(companyName);
|
||||
const toggle = () =>
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(companyName)) next.delete(companyName);
|
||||
else next.add(companyName);
|
||||
return next;
|
||||
});
|
||||
return (
|
||||
<Card key={companyName} data-testid={`dn-group-${companyName}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`dn-group-rows-${companyName}`}
|
||||
data-testid={`dn-group-toggle-${companyName}`}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{companyName}
|
||||
</h2>
|
||||
<span className="text-xs font-normal normal-case tracking-normal text-muted-foreground/70">
|
||||
({groupNotes.length})
|
||||
</span>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<CardContent className="p-0" id={`dn-group-rows-${companyName}`}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={groupNotes}
|
||||
loading={loading}
|
||||
error={error}
|
||||
page={1}
|
||||
totalPages={1}
|
||||
onPageChange={() => {
|
||||
// no-op: grouping bypasses pagination;
|
||||
// pagination only affects the fetch below
|
||||
}}
|
||||
onRowClick={(item) => router.push(`/delivery-notes/${item.id}`)}
|
||||
selection={{
|
||||
isSelected: (item) => selectedIds.has(item.id),
|
||||
onToggle: (item) => toggleSelected(item.id),
|
||||
isDisabled: isSelectionDisabled,
|
||||
}}
|
||||
mobileCardRender={(item) => (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium">{item.delivery_note_number}</p>
|
||||
<Badge
|
||||
variant={statusColors[item.status] || "secondary"}
|
||||
data-testid={`dn-status-mobile-${item.id}`}
|
||||
>
|
||||
{statusLabel[item.status] || item.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.expand?.order?.order_number || "No order"} · {item.expand?.customer?.customer || "No customer"}
|
||||
</p>
|
||||
{item.customer_po && (
|
||||
<p className="text-xs font-mono">PO: {item.customer_po}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{formatDate(item.created)}</p>
|
||||
{/* Mobile action lives inline at the bottom of the
|
||||
card so the row is fully self-contained — no need
|
||||
to scroll horizontally to find the button. */}
|
||||
<div className="pt-1">{ActionCell(item)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Combine-DNs bulk action. Mirrors the sticky bottom-bar
|
||||
|
|
|
|||
Loading…
Reference in a new issue