feat(deliveries): click-to-expand DN items panel
Adds a chevron button at the left edge of every delivery-note row on /deliveries. Clicking reveals an inline items panel (description + qty) so the operator can match against the printed DN paper in their hand before loading. Items come from a client-side filter of the order group's items array (items.filter(i => i.delivery_note === dn.id)). No new server endpoint. DNs whose items aren't in the order group's slice show a 'See full DN →' link to /delivery-notes/[id]. Expand state is held at the OrderGroupView level as a Set<string> of expanded DN ids — independent per DN, resets on refresh. (State lifted to OrderGroupView rather than DeliveriesPage because order.items is already in scope there; avoids prop-drilling through CustomerGroupView.) Service change: deliveries-service.listDeliveryItems now fetches the delivery_note (relation id) + unit fields on each item, so the page can filter and render units without an extra round-trip. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
0984b7513e
commit
2014814c84
2 changed files with 223 additions and 81 deletions
|
|
@ -72,6 +72,7 @@
|
|||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- the
|
||||
* "fetch on mount / refetch on tab change" + "clear selection on
|
||||
* tab change" effects below match the pattern used across this
|
||||
|
|
@ -84,6 +85,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import {
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
|
|
@ -127,6 +130,11 @@ type DeliveryItem = {
|
|||
invoice_number: string | null;
|
||||
loaded_at: string | null;
|
||||
checked_in_at: string | null;
|
||||
// DN this item was assembled into (denormalised from the page's
|
||||
// /api/v1/deliveries response). Used by the per-row expand panel
|
||||
// to filter items belonging to a specific DN.
|
||||
delivery_note: string | null;
|
||||
unit: string | null;
|
||||
order: {
|
||||
id: string;
|
||||
order_number: string;
|
||||
|
|
@ -1252,6 +1260,23 @@ function OrderGroupView({
|
|||
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
// Set of DN ids whose items panel is expanded. Independent state
|
||||
// per DN; resets on refresh. Spec § Feature 1 — Interaction details.
|
||||
const [expandedDnIds, setExpandedDnIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const toggleDnExpanded = useCallback((dnId: string) => {
|
||||
setExpandedDnIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dnId)) {
|
||||
next.delete(dnId);
|
||||
} else {
|
||||
next.add(dnId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allChecked =
|
||||
order.items.length > 0 &&
|
||||
order.items.every((i) => selectedIds.has(i.id));
|
||||
|
|
@ -1357,6 +1382,9 @@ function OrderGroupView({
|
|||
onSignedCopy={onSignedCopy}
|
||||
onMarkLoaded={onMarkLoaded}
|
||||
busy={busy}
|
||||
expanded={expandedDnIds.has(dn.id)}
|
||||
onToggle={() => toggleDnExpanded(dn.id)}
|
||||
items={order.items}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
|
|
@ -1478,11 +1506,17 @@ function DeliveryNoteRowComponent({
|
|||
onSignedCopy,
|
||||
onMarkLoaded,
|
||||
busy,
|
||||
expanded,
|
||||
onToggle,
|
||||
items,
|
||||
}: {
|
||||
dn: DeliveryNoteRow;
|
||||
onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
|
||||
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
|
||||
busy: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
items: DeliveryItem[];
|
||||
}) {
|
||||
const statusLabel = (() => {
|
||||
switch (dn.status) {
|
||||
|
|
@ -1519,95 +1553,199 @@ function DeliveryNoteRowComponent({
|
|||
// abandoned DN would be misleading.
|
||||
const canUploadSignedCopy = dn.status !== "cancelled";
|
||||
|
||||
// Items on this DN — drawn from this order group's items
|
||||
// array. Stale-by-design: a refresh picks up changes. See spec
|
||||
// § Feature 1 — Data source.
|
||||
const dnItems = useMemo(
|
||||
() => items.filter((i) => i.delivery_note === dn.id),
|
||||
[items, dn.id],
|
||||
);
|
||||
|
||||
// Header-line suffix logic per spec: suffix omitted when all
|
||||
// items have empty/null units (operator default is "EA"); no
|
||||
// suffix when units are mixed; the shared unit when all items
|
||||
// share a non-EA unit (e.g. BOX).
|
||||
const totalSuffix = (() => {
|
||||
const units = new Set(
|
||||
dnItems.map((i) => (i.unit ?? "").trim()).filter((u) => u.length > 0),
|
||||
);
|
||||
if (units.size === 0) return "";
|
||||
if (units.size === 1) {
|
||||
const only = [...units][0];
|
||||
return only === "EA" ? "" : ` ${only}`;
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
const totalQty = dnItems.reduce((sum, i) => sum + (i.quantity ?? 0), 0);
|
||||
|
||||
const itemsPanelId = `dn-items-${dn.id}`;
|
||||
const dnOrderId = dn.order?.id ?? null;
|
||||
const hasDifferentOrderItem = (itemOrderId: string | null) =>
|
||||
dnOrderId !== null && itemOrderId !== null && itemOrderId !== dnOrderId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 bg-amber-50/40 px-4 py-2.5 dark:bg-amber-950/10"
|
||||
className="bg-amber-50/40 dark:bg-amber-950/10"
|
||||
data-testid={`deliveries-dn-row-${dn.id}`}
|
||||
>
|
||||
<FileText className="mt-0.5 h-5 w-5 shrink-0 text-amber-700 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm" title={dn.delivery_note_number}>
|
||||
<span className="font-mono">DN {dn.delivery_note_number}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
|
||||
{dn.order ? (
|
||||
<>
|
||||
<span className="truncate max-w-[40ch]" title={dn.order.customer_name ?? ""}>
|
||||
{dn.order.customer_name}
|
||||
</span>
|
||||
{dn.order.company_name && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{dn.order.company_name}</span>
|
||||
</>
|
||||
)}
|
||||
{dn.order.order_number && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono">{dn.order.order_number}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<div className="flex items-start gap-3 px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={itemsPanelId}
|
||||
aria-label={
|
||||
expanded
|
||||
? "Hide items on this delivery note"
|
||||
: "Show items on this delivery note"
|
||||
}
|
||||
className="mt-0.5 shrink-0 rounded p-0.5 text-amber-700 hover:bg-amber-100/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/40 dark:text-amber-400 dark:hover:bg-amber-900/30"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<span className="italic">Counter pickup</span>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
{dn.signed_copy && dn.signed_copy.length > 0 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
title={dn.signed_copy.join(", ")}
|
||||
</button>
|
||||
<FileText className="mt-0.5 h-5 w-5 shrink-0 text-amber-700 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm" title={dn.delivery_note_number}>
|
||||
<span className="font-mono">DN {dn.delivery_note_number}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
|
||||
{dn.order ? (
|
||||
<>
|
||||
<span className="truncate max-w-[40ch]" title={dn.order.customer_name ?? ""}>
|
||||
{dn.order.customer_name}
|
||||
</span>
|
||||
{dn.order.company_name && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{dn.order.company_name}</span>
|
||||
</>
|
||||
)}
|
||||
{dn.order.order_number && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono">{dn.order.order_number}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="italic">Counter pickup</span>
|
||||
)}
|
||||
{dn.signed_copy && dn.signed_copy.length > 0 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
title={dn.signed_copy.join(", ")}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" aria-hidden />
|
||||
signed copy
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{dn.delivery_date && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>Delivery {dn.delivery_date}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{canMarkLoaded && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
onMarkLoaded(dn.id, dn.delivery_note_number)
|
||||
}
|
||||
disabled={busy}
|
||||
data-testid={`deliveries-dn-mark-loaded-${dn.id}`}
|
||||
title="Mark the paper as loaded on the truck"
|
||||
>
|
||||
<Truck className="mr-1 h-3 w-3" />
|
||||
{dn.status === "loaded" ? "Re-mark loaded" : "Mark loaded"}
|
||||
</Button>
|
||||
)}
|
||||
{canUploadSignedCopy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
onSignedCopy(dn.id, dn.delivery_note_number)
|
||||
}
|
||||
disabled={busy}
|
||||
data-testid={`deliveries-dn-signed-copy-${dn.id}`}
|
||||
title="Upload the signed copy of this delivery note and mark it complete"
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
Signed copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div
|
||||
id={itemsPanelId}
|
||||
data-testid={`deliveries-dn-items-${dn.id}`}
|
||||
className="ml-9 mr-4 mb-2 rounded-md border border-amber-200/60 bg-muted/30 px-3 py-2 text-xs dark:border-amber-900/40"
|
||||
>
|
||||
{dnItems.length === 0 ? (
|
||||
<div className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span>No items loaded on this delivery note.</span>
|
||||
<Link
|
||||
href={`/delivery-notes/${dn.id}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
<Paperclip className="h-3 w-3" aria-hidden />
|
||||
signed copy
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{dn.delivery_date && (
|
||||
See full DN →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>Delivery {dn.delivery_date}</span>
|
||||
<div className="mb-1 flex items-center justify-between text-[11px] text-muted-foreground tabular-nums">
|
||||
<span>
|
||||
{dnItems.length} item{dnItems.length === 1 ? "" : "s"}
|
||||
{totalQty > 0 && ` · Total ${totalQty}${totalSuffix}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto divide-y divide-amber-200/40 dark:divide-amber-900/40">
|
||||
{dnItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-baseline gap-3 py-1.5"
|
||||
data-testid={`deliveries-dn-item-${item.id}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="truncate"
|
||||
title={item.description ?? ""}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
{hasDifferentOrderItem(item.order?.id ?? null) && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
from order {item.order?.order_number ?? item.order?.id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
Qty {item.quantity ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{canMarkLoaded && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
onMarkLoaded(dn.id, dn.delivery_note_number)
|
||||
}
|
||||
disabled={busy}
|
||||
data-testid={`deliveries-dn-mark-loaded-${dn.id}`}
|
||||
title="Mark the paper as loaded on the truck"
|
||||
>
|
||||
<Truck className="mr-1 h-3 w-3" />
|
||||
{dn.status === "loaded" ? "Re-mark loaded" : "Mark loaded"}
|
||||
</Button>
|
||||
)}
|
||||
{canUploadSignedCopy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
onSignedCopy(dn.id, dn.delivery_note_number)
|
||||
}
|
||||
disabled={busy}
|
||||
data-testid={`deliveries-dn-signed-copy-${dn.id}`}
|
||||
title="Upload the signed copy of this delivery note and mark it complete"
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
Signed copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,8 +301,8 @@ export async function listDeliveryItems(params: {
|
|||
// all items. Verified live against PB 2026-08-12.
|
||||
expand: "order.customer,order.company,checked_in_by",
|
||||
fields:
|
||||
"id,description,quantity,selling_price,cost_price,item_status,delivery_reference," +
|
||||
"loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," +
|
||||
"id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," +
|
||||
"delivery_note,loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," +
|
||||
"expand.order,expand.order.customer,expand.order.company,expand.checked_in_by",
|
||||
}),
|
||||
pb.collection(DELIVERY_NOTES).getList(page, perPage, {
|
||||
|
|
@ -323,6 +323,8 @@ export async function listDeliveryItems(params: {
|
|||
cost_price: number | null;
|
||||
item_status: string;
|
||||
delivery_reference: string | null;
|
||||
delivery_note: string | null;
|
||||
unit: string | null;
|
||||
loaded_at: string | null;
|
||||
checked_in_at: string | null;
|
||||
checked_in_by: string | null;
|
||||
|
|
@ -371,6 +373,8 @@ export async function listDeliveryItems(params: {
|
|||
cost_price: r.cost_price ?? null,
|
||||
item_status: r.item_status,
|
||||
delivery_reference: r.delivery_reference ?? null,
|
||||
delivery_note: r.delivery_note ?? null,
|
||||
unit: r.unit ?? null,
|
||||
invoice_number: readInvoiceNumber(r.order_item_details),
|
||||
loaded_at: r.loaded_at ?? null,
|
||||
checked_in_at: r.checked_in_at ?? null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue