feat(delivery-notes): add mark complete and cancel delivery note functionality

Add backend API routes and service functions for delivery note status updates with strict validation rules
Add UI action buttons and cancellation confirmation modal to the delivery note detail page
Update table layouts and styling across delivery notes and invoicing pages to improve responsiveness
Fix text overflow issues by adding whitespace-nowrap and break-words classes
Refactor order items table to display status inline and reduce redundant layout code
This commit is contained in:
Dave 2026-08-12 08:55:56 +02:00
parent 8efb952c76
commit 9eea138662
6 changed files with 359 additions and 42 deletions

View file

@ -21,6 +21,8 @@ import {
CalendarDays,
ClipboardList,
Hash,
CheckCircle2,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -138,6 +140,15 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
// without leaving the page.
const [previewOpen, setPreviewOpen] = useState(false);
// Cancel-DN confirmation modal — collects an optional reason and
// the operator identity to stamp on the items' notes before the
// service flips them to "completed".
const [cancelModal, setCancelModal] = useState<{
open: boolean;
note: string;
saving: boolean;
}>({ open: false, note: "", saving: false });
// Capture-PO modal state. Same shape as the list page so the
// "operator types the PO, hits Save, page refreshes" flow is
// identical whether the operator came from the list or the
@ -309,7 +320,95 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
}
}, [deliveryNote, loadAll]);
// ── Action: Upload signed copy ──────────────────────────────
// ── Action: Mark Complete ─────────────────────────────────
const handleMarkComplete = useCallback(async () => {
if (!deliveryNote) return;
setBusyAction("mark_complete");
try {
const res = await fetch(`/api/v1/delivery-notes/${deliveryNote.id}/mark-complete`, {
method: "POST",
});
if (!res.ok) {
let message = `Failed to mark as complete (${res.status})`;
try {
const data: unknown = await res.json();
if (
data &&
typeof data === "object" &&
"error" in data &&
typeof (data as { error?: unknown }).error === "string"
) {
message = (data as { error: string }).error;
}
} catch {
// ignore non-json
}
toast.error(message);
return;
}
toast.success(`Delivery note ${deliveryNote.delivery_note_number} marked as complete.`);
await loadAll();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to mark as complete");
} finally {
setBusyAction(null);
}
}, [deliveryNote, loadAll]);
// ── Action: Cancel ─────────────────────────────────────────
// Open the modal so the operator can type a reason before we
// stamp it on the items' notes and flip them to "completed".
const openCancelModal = useCallback(() => {
setCancelModal({ open: true, note: "", saving: false });
}, []);
const submitCancelModal = useCallback(async () => {
if (!deliveryNote) return;
setCancelModal((prev) => ({ ...prev, saving: true }));
setBusyAction("cancel");
try {
const pb = getPocketBase();
const sender = (pb.authStore as unknown as {
model?: { id?: string; name?: string; email?: string } | null;
}).model;
const res = await fetch(`/api/v1/delivery-notes/${deliveryNote.id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
setById: sender?.id ?? "",
setByName: sender?.name?.trim() || sender?.email?.trim() || "",
note: cancelModal.note.trim(),
}),
});
if (!res.ok) {
let message = `Failed to cancel (${res.status})`;
try {
const data: unknown = await res.json();
if (
data &&
typeof data === "object" &&
"error" in data &&
typeof (data as { error?: unknown }).error === "string"
) {
message = (data as { error: string }).error;
}
} catch {
// ignore non-json
}
toast.error(message);
return;
}
toast.success(`Delivery note ${deliveryNote.delivery_note_number} cancelled.`);
setCancelModal({ open: false, note: "", saving: false });
await loadAll();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to cancel");
} finally {
setBusyAction(null);
setCancelModal((prev) => ({ ...prev, saving: false }));
}
}, [deliveryNote, loadAll, cancelModal.note]);
// The signed copy is what the customer signs and sends back
// — when the "Send to customer" flow runs, this file is
// attached to the outbound email (instead of the re-rendered
@ -496,6 +595,41 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
Mark invoiced
</Button>
)}
{status === "invoiced" && (
<Button
size="sm"
variant="outline"
disabled={busyAction !== null}
onClick={() => void handleMarkComplete()}
data-testid={`dn-detail-mark-complete-${deliveryNote.id}`}
>
{busyAction === "mark_complete" ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
)}
Mark complete
</Button>
)}
{(status === "draft" ||
status === "sent" ||
status === "po_received" ||
status === "invoiced") && (
<Button
size="sm"
variant="destructive"
disabled={busyAction !== null}
onClick={openCancelModal}
data-testid={`dn-detail-cancel-${deliveryNote.id}`}
>
{busyAction === "cancel" ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<XCircle className="mr-1 h-3.5 w-3.5" />
)}
Cancel delivery note
</Button>
)}
<Button
size="sm"
variant="outline"
@ -746,70 +880,65 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
No order items are linked to this delivery note yet.
</div>
) : (
<Table>
<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%]" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>Description</TableHead>
<TableHead>Supplier</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
<TableHead>Status</TableHead>
<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>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const qty = Number(it.quantity) || 1;
const price = Number(it.selling_price) || 0;
const subtotal = qty * price;
return (
<TableRow key={it.id}>
<TableCell>
<div className="font-medium whitespace-pre-wrap break-words">
<TableRow key={it.id} className="text-xs">
<TableCell className="px-2 py-1 align-top whitespace-normal">
<div className="font-medium text-xs whitespace-pre-wrap break-words">
{it.description || "(no description)"}
</div>
{it.sku && (
<div className="text-xs text-muted-foreground font-mono">
<div className="text-[10px] text-muted-foreground font-mono">
SKU: {it.sku}
</div>
)}
<div className="mt-1 text-[10px] text-muted-foreground">
Status: {it.item_status}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<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="text-right tabular-nums">
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
{qty}
</TableCell>
<TableCell className="text-right tabular-nums">
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
{price ? formatCurrency(price) : (
<span className="text-xs text-muted-foreground">—</span>
<span className="text-[10px] text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{price ? formatCurrency(price * qty) : (
<span className="text-xs text-muted-foreground">—</span>
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
{price ? formatCurrency(subtotal) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{it.item_status || "—"}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{itemSubtotal > 0 && (
<div className="flex justify-end border-t p-4 text-sm">
<span className="text-muted-foreground">
Subtotal:{" "}
<span className="font-semibold text-foreground">
{formatCurrency(itemSubtotal)}
</span>
</span>
</div>
)}
</CardContent>
</Card>
@ -874,6 +1003,65 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
</DialogContent>
</Dialog>
{/* Cancel-DN confirmation. Optional reason is appended to
the items' notes as a cancellation banner so anyone
reading the row later knows who cancelled it and why. */}
<Dialog
open={cancelModal.open}
onOpenChange={(open) =>
setCancelModal((prev) => ({ ...prev, open, saving: open ? prev.saving : false }))
}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel delivery note {deliveryNote.delivery_note_number}?</DialogTitle>
<DialogDescription>
The items will be marked as completed and stamped with a note that this delivery note was cancelled. The original delivery note record is preserved for audit.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="dn-detail-cancel-note">Reason (optional)</Label>
<Input
id="dn-detail-cancel-note"
data-testid="dn-detail-cancel-note"
placeholder="e.g. customer cancelled the order"
value={cancelModal.note}
onChange={(e) =>
setCancelModal((prev) => ({ ...prev, note: e.target.value }))
}
onKeyDown={(e) => {
if (e.key === "Enter" && !cancelModal.saving) {
e.preventDefault();
void submitCancelModal();
}
}}
/>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() =>
setCancelModal({ open: false, note: "", saving: false })
}
disabled={cancelModal.saving}
>
Keep delivery note
</Button>
<Button
variant="destructive"
onClick={() => void submitCancelModal()}
disabled={cancelModal.saving}
data-testid="dn-detail-cancel-confirm"
>
{cancelModal.saving && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" aria-hidden />
)}
Cancel delivery note
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Inline PDF preview. The "Preview PDF" button in the
action strip toggles this. Operator stays on the
detail page — the PDF renders in an iframe, no

View file

@ -621,13 +621,13 @@ export default function DeliveryNotesPage() {
key: "created",
label: "Created",
render: (item: DeliveryNote) => (
<span className="text-sm text-muted-foreground">{formatDate(item.created)}</span>
<span className="text-sm text-muted-foreground whitespace-nowrap">{formatDate(item.created)}</span>
),
},
{
key: "actions",
label: "",
className: "min-w-[280px] text-right whitespace-nowrap",
className: "text-right whitespace-nowrap",
render: (item: DeliveryNote) => ActionCell(item),
},
],

View file

@ -2327,16 +2327,16 @@ export default function InvoicingPage() {
: null;
return (
<div key={group.orderNumber || "unknown"} className={cn("rounded-xl border bg-card p-4 shadow-sm border-l-4 pl-4 border-info/40", colorForOrder(group.orderNumber).borderClass)}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<div className="font-mono font-semibold text-info">{headline}</div>
<div className="text-sm text-muted-foreground">{first?.orderNumber || "No order number"}</div>
<div className="text-sm text-muted-foreground/80">{first?.companyName || "No company"}</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<div className="min-w-0 space-y-1">
<div className="font-mono font-semibold text-info break-words">{headline}</div>
<div className="text-sm text-muted-foreground break-words">{first?.orderNumber || "No order number"}</div>
<div className="text-sm text-muted-foreground/80 break-words">{first?.companyName || "No company"}</div>
{dateLine ? (
<div className="text-xs text-muted-foreground/80">{dateLine}</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<Badge
variant="outline"
className="border-info/40 bg-info/10 text-info"

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { cancelDeliveryNote } from "@/lib/server/delivery-notes-service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = (await req.json().catch(() => ({}))) as {
setById?: string;
setByName?: string;
note?: string;
};
const updated = await cancelDeliveryNote(id, {
setById: typeof body.setById === "string" ? body.setById : null,
setByName: typeof body.setByName === "string" ? body.setByName : null,
note: typeof body.note === "string" ? body.note : null,
});
return NextResponse.json(updated);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to cancel delivery note";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { markDeliveryNoteComplete } from "@/lib/server/delivery-notes-service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const updated = await markDeliveryNoteComplete(id);
return NextResponse.json(updated);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to mark delivery note as complete";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -498,6 +498,88 @@ export async function markDeliveryNoteInvoiced(
});
}
/**
* Mark the delivery note as "completed". Operator signal that
* the work has wrapped up and the items are no longer in flight.
* Allowed from "invoiced" only — once cancelled you have to
* revert to draft first.
*
* Side effects:
* - delivery_notes.status flips "invoiced" → "completed".
*/
export async function markDeliveryNoteComplete(deliveryNoteId: string): Promise<DeliveryNote> {
const pb = await getAdminPocketBase();
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
if (dn.status !== "invoiced") {
throw new Error(
`Delivery note must be in "invoiced" status to mark complete (current: "${dn.status}").`,
);
}
assertTransition("invoiced", "completed");
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
status: "completed",
});
}
export interface CancelDeliveryNoteInput {
setById?: string | null;
setByName?: string | null;
note?: string | null;
}
/**
* Cancel a delivery note. Items are flipped back to "completed"
* (no longer "delivery_note") so they fall off the Invoicing
* "ready for delivery note" list, with a note stamping who
* cancelled it, when, and why.
*
* Allowed from draft / sent / po_received / invoiced. From
* "completed" you must not cancel — terminal. From "cancelled"
* it's a no-op.
*/
export async function cancelDeliveryNote(
deliveryNoteId: string,
input: CancelDeliveryNoteInput = {},
): Promise<DeliveryNote> {
const pb = await getAdminPocketBase();
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
const from = dn.status;
if (from === "cancelled") {
return dn;
}
if (from === "completed") {
throw new Error(
`Cannot cancel a delivery note in "completed" status.`,
);
}
assertTransition(from, "cancelled");
const trimmedNote = (input.note ?? "").trim();
const setById = input.setById ?? "";
const setByName = input.setByName ?? "";
const setAt = new Date().toISOString();
const banner = `Delivery note CANCELLED by ${setByName || setById || "unknown"} on ${setAt}${trimmedNote ? ` — ${trimmedNote}` : ""}`;
const items = await pb.collection(ORDER_ITEMS).getFullList<OrderItem>({
filter: `delivery_note_number = "${dn.delivery_note_number}"`,
});
await Promise.all(
items.map((it) =>
pb.collection(ORDER_ITEMS).update(it.id, {
item_status: "completed",
notes: it.notes ? `${it.notes}\n${banner}` : banner,
}),
),
);
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
status: "cancelled",
});
}
// ─────────────────────────────────────────────────────────────────
// Ad-hoc (Path B) creation
// ─────────────────────────────────────────────────────────────────