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:
parent
234ba6d3cd
commit
50e75e6955
2 changed files with 368 additions and 38 deletions
|
|
@ -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'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'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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue