feat(orders): add-item affordance + sheet on the process page

Task 3 of the non-invoiceable-order-extras plan.

- New POST /api/v1/orders/{id}/items route wraps
  createOrderItemForOrder. Same-origin browser auth (PB session
  cookie), mirrors the posture of /quote after the order-quote
  migration.
- AddItemSheet component on the process page: right-side Sheet
  (mirrors PriceEditSheet's layout) with description / quantity /
  cost / selling / supplier / notes + the load-bearing
  'exclude_from_invoice' checkbox. When ticked, the selling-price
  input is forced to 0 and disabled, a 'Not invoiced' badge replaces
  the margin chip so a -100% red chip on every extra is noise.
- Add Item button below the item card stack — always available,
  not gated on item_status. The empty-state copy above ('No line
  items on this order. Add items before processing.') now points at
  a real action.
- Existing excluded rows render with a muted 'Not invoiced' badge
  in the card stack so the operator can spot them at a glance
  without opening the sheet.
- The server (Task 2) forces selling_price=0 from the flag
  regardless of caller input — the client forcing here is for UX
  consistency.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 17:18:30 +02:00
parent 234ba6d3cd
commit 50e75e6955
2 changed files with 368 additions and 38 deletions

View file

@ -41,6 +41,8 @@ import {
MessageSquare,
Split,
Package,
Plus,
Receipt,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
@ -324,6 +326,9 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
// Sheet state — single sheet, content switches by itemId.
const [editingItemId, setEditingItemId] = useState<string | null>(null);
// Add-item Sheet — opened by the "Add item" button below the item
// stack. Task 3 of the non-invoiceable-order-extras plan (2026-08-30).
const [addItemOpen, setAddItemOpen] = useState(false);
// Below-threshold save confirmation. We keep the fully committed draft
// here so the confirmation sends exactly what the operator reviewed,
@ -1792,6 +1797,53 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
</ul>
)}
{/* ─── Add-item affordance ─────────────────────────────────── */}
{/* Task 3 of the non-invoiceable-order-extras plan. The empty-
state copy above now points at a real action. Always
available — not gated on item_status — because adding a new
row is independent of any in-flight PO (see Task 2). */}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAddItemOpen(true)}
className="self-start"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add item
</Button>
<AddItemSheet
open={addItemOpen}
onOpenChange={setAddItemOpen}
suppliers={suppliers}
onSubmit={async (draft) => {
try {
const res = await fetch(`/api/v1/orders/${id}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft),
});
const data = (await res.json().catch(() => ({}))) as {
created?: Array<{ id: string }>;
failed?: Array<{ error: string }>;
error?: string;
};
if (!res.ok) {
throw new Error(data?.error || "Failed to add item");
}
if ((data.failed ?? []).length > 0) {
throw new Error((data.failed ?? [])[0].error);
}
toast.success(`Added "${draft.description}"`);
setAddItemOpen(false);
await fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to add item");
}
}}
/>
{/* ─── Send POs ───────────────────────────────────────────── */}
{Object.keys(bySupplier).length > 0 && (
<Card>
@ -2127,6 +2179,23 @@ function ItemCard({
</button>
)}
{/* Non-invoiceable badge — Task 3 of the
non-invoiceable-order-extras plan. Suppresses the
price-area visual noise on an excluded row: the price
column shows "Not invoiced" instead of "R 0", and the
selling/margin chips at the right are hidden so the
operator doesn't try to recover a margin that should
never have been one. */}
{item.exclude_from_invoice && (
<Badge
variant="outline"
className="mt-1 border-muted-foreground/40 text-muted-foreground text-[10px]"
>
<Receipt className="h-3 w-3 mr-1" />
Not invoiced
</Badge>
)}
{/* ─── PO description — inline edit under order description ───
What the supplier sees on the PO. Defaults to the order
description. Inline editor (textarea popover) so the
@ -2766,6 +2835,255 @@ function TotalsCard({
* Selling-price field is intentionally not copyable from quote —
* only cost can be copied. Quote's selling shown as reference only.
* ========================================================================== */
/* ============================================================================
* AddItemSheet — small form to append a new line item to the order
* mid-flow. Task 3 of the non-invoiceable-order-extras plan
* (2026-08-30). Mirrors PriceEditSheet's Sheet pattern: right-side
* drawer with header + scroll body + footer Save.
*
* The `exclude_from_invoice` checkbox is the load-bearing control.
* When ticked: selling-price input is forced to 0 and disabled, the
* margin chip is replaced with "Not invoiced" so a -100% red chip
* on every extra is noise, and the call passes the flag to the
* server. The server (Task 2) forces selling_price=0 regardless
* of caller input — the client forcing here is for UX consistency.
* ========================================================================== */
interface AddItemDraft {
description: string;
quantity: number;
cost_price: number;
selling_price: number;
supplier: string;
notes: string;
exclude_from_invoice: boolean;
}
function emptyAddItemDraft(): AddItemDraft {
return {
description: "",
quantity: 1,
cost_price: 0,
selling_price: 0,
supplier: "",
notes: "",
exclude_from_invoice: false,
};
}
function AddItemSheet({
open,
onOpenChange,
suppliers,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
suppliers: Supplier[];
onSubmit: (draft: AddItemDraft) => Promise<void>;
}) {
const [draft, setDraft] = useState<AddItemDraft>(emptyAddItemDraft);
const [saving, setSaving] = useState(false);
const set = <K extends keyof AddItemDraft>(k: K, v: AddItemDraft[K]) =>
setDraft((prev) => ({ ...prev, [k]: v }));
const liveMargin = draft.exclude_from_invoice
? null
: draft.cost_price > 0 && draft.selling_price > 0
? ((draft.selling_price - draft.cost_price) / draft.cost_price) * 100
: null;
const submit = async () => {
if (!draft.description.trim()) return;
setSaving(true);
try {
await onSubmit(draft);
setDraft(emptyAddItemDraft());
} finally {
setSaving(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full sm:max-w-md p-0 flex flex-col gap-0 overflow-hidden"
>
<SheetHeader className="px-4 pt-4 pb-3 border-b shrink-0">
<SheetTitle className="text-base flex items-center gap-2">
<Plus className="h-4 w-4" />
Add item
</SheetTitle>
<SheetDescription className="text-xs">
Append a line to this order. Independent of any in-flight PO.
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="add-description" className="text-xs font-medium">
Description
</Label>
<Input
id="add-description"
value={draft.description}
onChange={(e) => set("description", e.target.value)}
placeholder="M20 nut, washer, …"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="add-quantity" className="text-xs font-medium">
Quantity
</Label>
<Input
id="add-quantity"
type="number"
inputMode="numeric"
value={String(draft.quantity)}
onChange={(e) => set("quantity", Number(e.target.value) || 1)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="add-cost" className="text-xs font-medium">
Cost price
</Label>
<Input
id="add-cost"
type="number"
inputMode="decimal"
step="0.01"
value={String(draft.cost_price)}
onChange={(e) => set("cost_price", Number(e.target.value) || 0)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="add-selling" className="text-xs font-medium">
Selling price
</Label>
<div className="flex items-center gap-2">
<Input
id="add-selling"
type="number"
inputMode="decimal"
step="0.01"
value={String(draft.exclude_from_invoice ? 0 : draft.selling_price)}
disabled={draft.exclude_from_invoice}
onChange={(e) =>
set("selling_price", Number(e.target.value) || 0)
}
/>
{draft.exclude_from_invoice ? (
<Badge
variant="outline"
className="shrink-0 border-muted-foreground/30 text-muted-foreground text-[10px]"
>
<Receipt className="h-3 w-3 mr-1" />
Not invoiced
</Badge>
) : liveMargin !== null ? (
<Badge
variant="outline"
className={cn(
"shrink-0 text-[10px]",
liveMargin < 0
? "border-warn/40 text-warn-foreground"
: "border-muted-foreground/30 text-muted-foreground",
)}
>
{liveMargin.toFixed(1)}%
</Badge>
) : null}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="add-supplier" className="text-xs font-medium">
Supplier
</Label>
<select
id="add-supplier"
value={draft.supplier}
onChange={(e) => set("supplier", e.target.value)}
className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
>
<option value="">(none — pick later)</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.suppliername}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="add-notes" className="text-xs font-medium">
Notes
</Label>
<Input
id="add-notes"
value={draft.notes}
onChange={(e) => set("notes", e.target.value)}
placeholder="Optional"
/>
</div>
<div className="rounded-md border bg-muted/30 p-3 space-y-1.5">
<label className="flex items-start gap-2 cursor-pointer">
<Checkbox
checked={draft.exclude_from_invoice}
onCheckedChange={(checked) =>
set("exclude_from_invoice", checked === true)
}
className="mt-0.5"
/>
<div className="flex-1">
<span className="text-sm font-medium">
Don&apos;t invoice this line
</span>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Counts for the order and gets collected, but never appears on
the customer&apos;s invoice or delivery note.
</p>
</div>
</label>
</div>
</div>
<SheetFooter className="px-4 py-3 border-t shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={submit}
disabled={saving || !draft.description.trim()}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5 mr-1" />
)}
Add item
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
function PriceEditSheet({
edit,
matchedQuoteItem,

View file

@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import {
createOrderItemForOrder,
type CreateOrderItemForOrderDraft,
@ -8,31 +7,33 @@ import {
/**
* POST /api/v1/orders/[id]/items
*
* Add a new line item to an existing order. Strict-pending-gated:
* refuses with 409 (`order_locked`) if any line item on the order
* is past `item_status === "pending"`. Use `orders.split` to create
* a continuation order for an order with a PO in flight.
* Add a new line item to an existing order. Mirrors the MCP
* `order_items.create` workflow's "add one line to an order" path.
*
* Body:
* {
* description: string; // required
* quantity?: number; // default 1
* cost_price?: number;
* selling_price?: number; // auto-derived from cost_price × (1 + markup/100) if omitted
* markup?: number; // default 0
* sku?: string;
* unit?: string;
* supplier?: string; // PB supplier id
* notes?: string;
* }
* Body: `CreateOrderItemForOrderDraft` — see orders-service.ts:
* description (required)
* quantity?: number
* cost_price?: number
* markup?: number
* selling_price?: number ← forced to 0 when exclude_from_invoice
* sku?, unit?, supplier?, notes?
* exclude_from_invoice?: boolean ← non-invoiceable extras feature
* (Tasks 0–4 of the non-invoiceable-order-extras plan, 2026-08-30).
* When true the row gets a PO + driver + collection like any other
* line but is dropped from the customer invoice / delivery-note.
*
* Returns:
* 201 — { orderId, created: [{ id, ... }], failed: [], orderLocked: false }
* 400 — body missing or not JSON
* 404 — order not found
* 409 — order_locked; data.offendingItemIds[] lists the items that gate the create
* Returns: `{ created, failed, orderLocked, offendingItemIds }` —
* mirrors `createOrderItemForOrder`'s shape. `orderLocked` is kept
* for API compatibility and is always `false` (the pending gate was
* removed in Task 2).
*
* Auth: bearer AGENT_API_KEY.
* Errors:
* - 400 if `description` is missing.
* - 404 if the order doesn't exist.
*
* Auth: same-origin browser (PB session cookie at the PocketBase
* layer). Mirrors the auth posture of `/quote` after the
* order-quote-auto-linking migration.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -42,36 +43,47 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> },
) {
try {
assertAgentAuth(req);
const { id } = await params;
if (!id || typeof id !== "string") {
const { id: orderId } = await params;
if (!orderId || typeof orderId !== "string") {
return NextResponse.json({ error: "Order id is required" }, { status: 400 });
}
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body || typeof body !== "object" || Array.isArray(body)) {
const body = (await req.json().catch(() => ({}))) as Partial<CreateOrderItemForOrderDraft>;
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Body must be a JSON object" }, { status: 400 });
}
const description = typeof body.description === "string" ? body.description.trim() : "";
if (!description) {
if (typeof body.description !== "string" || !body.description.trim()) {
return NextResponse.json({ error: "description is required" }, { status: 400 });
}
const draft: CreateOrderItemForOrderDraft = {
description,
description: body.description.trim(),
quantity: typeof body.quantity === "number" ? body.quantity : null,
cost_price: typeof body.cost_price === "number" ? body.cost_price : undefined,
markup: typeof body.markup === "number" ? body.markup : undefined,
cost_price: typeof body.cost_price === "number" ? body.cost_price : 0,
markup: typeof body.markup === "number" ? body.markup : 0,
selling_price: typeof body.selling_price === "number" ? body.selling_price : undefined,
sku: typeof body.sku === "string" ? body.sku : undefined,
unit: typeof body.unit === "string" ? body.unit : undefined,
supplier: typeof body.supplier === "string" ? body.supplier : undefined,
notes: typeof body.notes === "string" ? body.notes : undefined,
// Undefined rather than `false` — the server's force-zero selling
// price path keys on a `true` flag, so an explicit `false` from
// a caller (e.g. an older agent) is honoured as "not excluded".
exclude_from_invoice:
typeof body.exclude_from_invoice === "boolean"
? body.exclude_from_invoice
: undefined,
};
const result = await createOrderItemForOrder(id, draft);
return NextResponse.json(result, { status: result.created.length > 0 ? 201 : 200 });
const result = await createOrderItemForOrder(orderId, draft);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create order item";
const status = (error as Error & { status?: number }).status || 500;
const message = error instanceof Error ? error.message : "Failed to add item";
const status = (error as Error & { status?: number }).status ?? 500;
const data = (error as Error & { data?: unknown }).data;
return NextResponse.json({ error: message, ...(data ? { data } : {}) }, { status });
if (data && typeof data === "object") {
return NextResponse.json({ error: message, ...data }, { status });
}
return NextResponse.json({ error: message }, { status });
}
}