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:
parent
8efb952c76
commit
9eea138662
6 changed files with 359 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
28
src/app/api/v1/delivery-notes/[id]/cancel/route.ts
Normal file
28
src/app/api/v1/delivery-notes/[id]/cancel/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
19
src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts
Normal file
19
src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue