diff --git a/src/app/(app)/orders/[id]/process/page.tsx b/src/app/(app)/orders/[id]/process/page.tsx index 28f86d1..ad51c53 100644 --- a/src/app/(app)/orders/[id]/process/page.tsx +++ b/src/app/(app)/orders/[id]/process/page.tsx @@ -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(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 )} + {/* ─── 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). */} + + + { + 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 && ( @@ -2127,6 +2179,23 @@ function ItemCard({ )} + {/* 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 && ( + + + Not invoiced + + )} + {/* ─── 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; +}) { + const [draft, setDraft] = useState(emptyAddItemDraft); + const [saving, setSaving] = useState(false); + const set = (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 ( + + + + + + Add item + + + Append a line to this order. Independent of any in-flight PO. + + + +
+
+ + set("description", e.target.value)} + placeholder="M20 nut, washer, …" + autoFocus + /> +
+ +
+
+ + set("quantity", Number(e.target.value) || 1)} + /> +
+
+ + set("cost_price", Number(e.target.value) || 0)} + /> +
+
+ +
+ +
+ + set("selling_price", Number(e.target.value) || 0) + } + /> + {draft.exclude_from_invoice ? ( + + + Not invoiced + + ) : liveMargin !== null ? ( + + {liveMargin.toFixed(1)}% + + ) : null} +
+
+ +
+ + +
+ +
+ + set("notes", e.target.value)} + placeholder="Optional" + /> +
+ +
+ +
+
+ + + + + +
+
+ ); +} + function PriceEditSheet({ edit, matchedQuoteItem, diff --git a/src/app/api/v1/orders/[id]/items/route.ts b/src/app/api/v1/orders/[id]/items/route.ts index 9e4842e..fcc8ed8 100644 --- a/src/app/api/v1/orders/[id]/items/route.ts +++ b/src/app/api/v1/orders/[id]/items/route.ts @@ -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 | null; - if (!body || typeof body !== "object" || Array.isArray(body)) { + + const body = (await req.json().catch(() => ({}))) as Partial; + 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 }); } }