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 // ─────────────────────────────────────────────────────────────────