From 9eea138662389741ce76b226ac3af43417c186ae Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 12 Aug 2026 08:55:56 +0200 Subject: [PATCH] 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 --- src/app/(app)/delivery-notes/[id]/page.tsx | 256 +++++++++++++++--- src/app/(app)/delivery-notes/page.tsx | 4 +- src/app/(app)/invoicing/page.tsx | 12 +- .../v1/delivery-notes/[id]/cancel/route.ts | 28 ++ .../[id]/mark-complete/route.ts | 19 ++ src/lib/server/delivery-notes-service.ts | 82 ++++++ 6 files changed, 359 insertions(+), 42 deletions(-) create mode 100644 src/app/api/v1/delivery-notes/[id]/cancel/route.ts create mode 100644 src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts diff --git a/src/app/(app)/delivery-notes/[id]/page.tsx b/src/app/(app)/delivery-notes/[id]/page.tsx index 3f8f583..04a1ddc 100644 --- a/src/app/(app)/delivery-notes/[id]/page.tsx +++ b/src/app/(app)/delivery-notes/[id]/page.tsx @@ -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 )} + {status === "invoiced" && ( + + )} + {(status === "draft" || + status === "sent" || + status === "po_received" || + status === "invoiced") && ( + + )} + + + + + {/* 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 diff --git a/src/app/(app)/delivery-notes/page.tsx b/src/app/(app)/delivery-notes/page.tsx index 507c80a..a8c6910 100644 --- a/src/app/(app)/delivery-notes/page.tsx +++ b/src/app/(app)/delivery-notes/page.tsx @@ -621,13 +621,13 @@ export default function DeliveryNotesPage() { key: "created", label: "Created", render: (item: DeliveryNote) => ( - {formatDate(item.created)} + {formatDate(item.created)} ), }, { key: "actions", label: "", - className: "min-w-[280px] text-right whitespace-nowrap", + className: "text-right whitespace-nowrap", render: (item: DeliveryNote) => ActionCell(item), }, ], diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index 51af049..fd81d1f 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -2327,16 +2327,16 @@ export default function InvoicingPage() { : null; return (
-
-
-
{headline}
-
{first?.orderNumber || "No order number"}
-
{first?.companyName || "No company"}
+
+
+
{headline}
+
{first?.orderNumber || "No order number"}
+
{first?.companyName || "No company"}
{dateLine ? (
{dateLine}
) : null}
-
+
} +) { + 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 }); + } +} diff --git a/src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts b/src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts new file mode 100644 index 0000000..ebd3876 --- /dev/null +++ b/src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts @@ -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 }); + } +} diff --git a/src/lib/server/delivery-notes-service.ts b/src/lib/server/delivery-notes-service.ts index bc2271a..8a46553 100644 --- a/src/lib/server/delivery-notes-service.ts +++ b/src/lib/server/delivery-notes-service.ts @@ -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 { + const pb = await getAdminPocketBase(); + + const dn = await pb.collection(DELIVERY_NOTES).getOne(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(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 { + const pb = await getAdminPocketBase(); + + const dn = await pb.collection(DELIVERY_NOTES).getOne(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({ + 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(dn.id, { + status: "cancelled", + }); +} + // ───────────────────────────────────────────────────────────────── // Ad-hoc (Path B) creation // ─────────────────────────────────────────────────────────────────