feat(orders): review modal scaffold (header + items + dirty state)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
cd33e425fc
commit
0e6c3d8f0c
2 changed files with 741 additions and 0 deletions
191
src/components/orders-review-modal.test.tsx
Normal file
191
src/components/orders-review-modal.test.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("@/components/ui/customer-search", () => ({
|
||||
CustomerSearch: ({
|
||||
onValueChange,
|
||||
value,
|
||||
}: {
|
||||
onValueChange: (v: string) => void;
|
||||
value: string;
|
||||
customers: unknown[];
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="customer-search-mock"
|
||||
onClick={() => onValueChange("cust-new")}
|
||||
>
|
||||
{value || "(unset)"}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { OrdersReviewModal } from "./orders-review-modal";
|
||||
import type { Order, OrderItem, Customer, Company } from "@/lib/types";
|
||||
|
||||
const baseOrder: Order = {
|
||||
id: "order-1",
|
||||
created: "2026-07-28T00:00:00Z",
|
||||
updated: "2026-07-28T00:00:00Z",
|
||||
collectionId: "c1",
|
||||
collectionName: "orders",
|
||||
order_number: "ORD-2026-07-001",
|
||||
company: "comp-1",
|
||||
customer: "cust-1",
|
||||
quote: "quote-1",
|
||||
notes: "",
|
||||
quote_matching_attempted: true,
|
||||
quote_match_confidence: 1,
|
||||
potential_quote: "",
|
||||
potential_quote_confidence: 0,
|
||||
is_repair_order: false,
|
||||
rep: "Maria",
|
||||
invoiced: false,
|
||||
quote_reference_text: "",
|
||||
quote_match_method: "",
|
||||
};
|
||||
|
||||
const baseCustomer: Customer = {
|
||||
id: "cust-1",
|
||||
created: "",
|
||||
updated: "",
|
||||
collectionId: "",
|
||||
collectionName: "",
|
||||
customer: "J. Smith",
|
||||
email: "",
|
||||
alternative_email: "",
|
||||
buyer_email: "",
|
||||
number: "",
|
||||
company: "comp-1",
|
||||
dateadded: "",
|
||||
lastvisiteddate: "",
|
||||
rep: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
const baseCompany: Company = {
|
||||
id: "comp-1",
|
||||
created: "",
|
||||
updated: "",
|
||||
collectionId: "",
|
||||
collectionName: "",
|
||||
company: "Acme (Pty) Ltd",
|
||||
address: "",
|
||||
vendor: "",
|
||||
company_email: "",
|
||||
company_tel: "",
|
||||
};
|
||||
|
||||
const baseItems: OrderItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
created: "",
|
||||
updated: "",
|
||||
collectionId: "",
|
||||
collectionName: "",
|
||||
description: "HSS drill 6mm",
|
||||
po_description: "",
|
||||
quantity: 2,
|
||||
order_qty: 2,
|
||||
selling_price: 50,
|
||||
supplier: "",
|
||||
cost_price: 25,
|
||||
markup: 100,
|
||||
purchase_order: "",
|
||||
item_status: "pending",
|
||||
in_stock: false,
|
||||
notes: "",
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps = () => ({
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
order: baseOrder,
|
||||
items: baseItems,
|
||||
customers: [baseCustomer],
|
||||
customer: baseCustomer,
|
||||
company: baseCompany,
|
||||
quoteEmail: { subject: "Re: RFQ", body: "Hi team..." },
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
describe("OrdersReviewModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders order number and customer in the header", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
expect(
|
||||
screen.getByDisplayValue("ORD-2026-07-001"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("J. Smith")).toBeInTheDocument();
|
||||
expect(screen.getByText("Acme (Pty) Ltd")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one row per line item", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
expect(screen.getByText("HSS drill 6mm")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("2")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("50")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Save button is disabled until a field is changed", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
const save = screen.getByRole("button", { name: /save/i });
|
||||
expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
it("Save button becomes enabled after editing the order number", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
const numberInput = screen.getByDisplayValue("ORD-2026-07-001");
|
||||
fireEvent.change(numberInput, {
|
||||
target: { value: "ORD-2026-07-014" },
|
||||
});
|
||||
const save = screen.getByRole("button", { name: /save/i });
|
||||
expect(save).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders inline error from the API on 409", async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "order_number_collision",
|
||||
collidingOrderId: "order-other",
|
||||
attemptedNumber: "ORD-2026-07-014",
|
||||
}),
|
||||
{ status: 409 },
|
||||
),
|
||||
);
|
||||
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
const numberInput = screen.getByDisplayValue("ORD-2026-07-001");
|
||||
fireEvent.change(numberInput, {
|
||||
target: { value: "ORD-2026-07-014" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/ORD-2026-07-014 is already used by another order/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/v1/orders/order-1/review-save",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
550
src/components/orders-review-modal.tsx
Normal file
550
src/components/orders-review-modal.tsx
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Mail,
|
||||
Paperclip,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CustomerSearch } from "@/components/ui/customer-search";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
Company,
|
||||
Customer,
|
||||
Order,
|
||||
OrderItem,
|
||||
QuoteAttachment,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Orders review modal — Task 4 scaffold.
|
||||
*
|
||||
* Renders an order's header (order_number, customer, company, rep) and
|
||||
* editable line items on the left, with the source-of-truth panel on
|
||||
* the right. The preview chain (email body + attachments) lands in
|
||||
* Task 5; this version ships the header + items + dirty-state gate +
|
||||
* save flow (with inline 409 collision error rendering).
|
||||
*
|
||||
* The save flow posts a single patch to `/api/v1/orders/{id}/review-save`
|
||||
* — see the route for the request shape and error envelope.
|
||||
*/
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
type Lock = "cost" | "selling";
|
||||
|
||||
export interface OrdersReviewModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
order: Order;
|
||||
items: OrderItem[];
|
||||
customers: Customer[];
|
||||
customer: Customer;
|
||||
company: Company;
|
||||
quoteEmail: { subject: string; body: string };
|
||||
attachments: QuoteAttachment[];
|
||||
}
|
||||
|
||||
interface ItemDraft {
|
||||
quantity: number | null;
|
||||
selling_price: number | null;
|
||||
cost_price: number | null;
|
||||
markup: number | null;
|
||||
lock: Lock;
|
||||
}
|
||||
|
||||
function initialItemDraft(it: OrderItem): ItemDraft {
|
||||
return {
|
||||
quantity: it.quantity,
|
||||
selling_price: it.selling_price,
|
||||
cost_price: it.cost_price,
|
||||
markup: it.markup ?? null,
|
||||
lock:
|
||||
it.cost_price && it.cost_price > 0 ? "cost" : "selling",
|
||||
};
|
||||
}
|
||||
|
||||
export function OrdersReviewModal({
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
order,
|
||||
items,
|
||||
customers,
|
||||
customer,
|
||||
company,
|
||||
quoteEmail,
|
||||
attachments,
|
||||
}: OrdersReviewModalProps) {
|
||||
const [orderNumber, setOrderNumber] = useState(order.order_number);
|
||||
const [customerId, setCustomerId] = useState(order.customer);
|
||||
const [itemDrafts, setItemDrafts] = useState<Record<string, ItemDraft>>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
items.map((it) => [it.id, initialItemDraft(it)]),
|
||||
),
|
||||
);
|
||||
const [numberError, setNumberError] = useState<string | null>(null);
|
||||
const [collidingOrderId, setCollidingOrderId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Re-seed local state when the modal opens for a different order.
|
||||
// Parent already remounts via `key={order.id}`, but defensively
|
||||
// re-seed here too in case anyone forgets the key.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setOrderNumber(order.order_number);
|
||||
setCustomerId(order.customer);
|
||||
setItemDrafts(
|
||||
Object.fromEntries(items.map((it) => [it.id, initialItemDraft(it)])),
|
||||
);
|
||||
setNumberError(null);
|
||||
setCollidingOrderId(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, order.id]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (orderNumber !== order.order_number) return true;
|
||||
if (customerId !== order.customer) return true;
|
||||
for (const it of items) {
|
||||
const d = itemDrafts[it.id];
|
||||
if (!d) continue;
|
||||
if (d.quantity !== it.quantity) return true;
|
||||
if (d.selling_price !== it.selling_price) return true;
|
||||
if (d.cost_price !== it.cost_price) return true;
|
||||
if ((d.markup ?? null) !== (it.markup ?? null)) return true;
|
||||
}
|
||||
return false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderNumber, customerId, itemDrafts, order.id]);
|
||||
|
||||
const updateItemDraft = useCallback(
|
||||
(id: string, patch: Partial<ItemDraft>) => {
|
||||
setItemDrafts((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onCostChange = (id: string, raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
const d = itemDrafts[id];
|
||||
if (!d) return;
|
||||
const newSelling = round2(value * (1 + (d.markup ?? 0) / 100));
|
||||
updateItemDraft(id, {
|
||||
cost_price: value,
|
||||
selling_price: newSelling,
|
||||
lock: "cost",
|
||||
});
|
||||
};
|
||||
|
||||
const onSellingChange = (id: string, raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
const d = itemDrafts[id];
|
||||
if (!d) return;
|
||||
const newCost =
|
||||
(d.markup ?? 0) > -100 ? round2(value / (1 + (d.markup ?? 0) / 100)) : 0;
|
||||
updateItemDraft(id, {
|
||||
selling_price: value,
|
||||
cost_price: newCost,
|
||||
lock: "selling",
|
||||
});
|
||||
};
|
||||
|
||||
const onMarkupChange = (id: string, raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
const d = itemDrafts[id];
|
||||
if (!d) return;
|
||||
if (d.lock === "cost") {
|
||||
const newSelling = round2((d.cost_price ?? 0) * (1 + value / 100));
|
||||
updateItemDraft(id, { markup: value, selling_price: newSelling });
|
||||
} else {
|
||||
const newCost =
|
||||
value > -100 ? round2((d.selling_price ?? 0) / (1 + value / 100)) : 0;
|
||||
updateItemDraft(id, { markup: value, cost_price: newCost });
|
||||
}
|
||||
};
|
||||
|
||||
const onQuantityChange = (id: string, raw: string) => {
|
||||
const value = raw === "" ? null : Number(raw);
|
||||
updateItemDraft(id, { quantity: value });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setNumberError(null);
|
||||
setCollidingOrderId(null);
|
||||
|
||||
const body: {
|
||||
order_number?: string;
|
||||
customer?: string;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
quantity?: number | null;
|
||||
selling_price?: number | null;
|
||||
cost_price?: number | null;
|
||||
}>;
|
||||
} = {};
|
||||
|
||||
if (orderNumber !== order.order_number) body.order_number = orderNumber;
|
||||
if (customerId !== order.customer) body.customer = customerId;
|
||||
|
||||
const dirtyItems = items
|
||||
.map((it) => {
|
||||
const d = itemDrafts[it.id];
|
||||
if (!d) return null;
|
||||
const changed: {
|
||||
id: string;
|
||||
quantity?: number | null;
|
||||
selling_price?: number | null;
|
||||
cost_price?: number | null;
|
||||
} = { id: it.id };
|
||||
let isDirty = false;
|
||||
if (d.quantity !== it.quantity) {
|
||||
changed.quantity = d.quantity;
|
||||
isDirty = true;
|
||||
}
|
||||
if (d.selling_price !== it.selling_price) {
|
||||
changed.selling_price = d.selling_price;
|
||||
isDirty = true;
|
||||
}
|
||||
if (d.cost_price !== it.cost_price) {
|
||||
changed.cost_price = d.cost_price;
|
||||
isDirty = true;
|
||||
}
|
||||
return isDirty ? changed : null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
if (dirtyItems.length > 0) body.items = dirtyItems;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/orders/${order.id}/review-save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status === 409) {
|
||||
const err = await res.json();
|
||||
setNumberError(
|
||||
`${err.attemptedNumber} is already used by another order.`,
|
||||
);
|
||||
setCollidingOrderId(err.collidingOrderId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Save failed (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
if (result.items?.failed?.length > 0) {
|
||||
toast.error(
|
||||
`Saved ${result.items.updated} items, ${result.items.failed.length} failed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Saved");
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Esc closes without saving (no confirm-discard in v1; dirty work is
|
||||
// small and the operator can re-open to retry).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !saving) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, saving, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orders-review-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !saving) onClose();
|
||||
}}
|
||||
>
|
||||
<Card className="w-full max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle
|
||||
id="orders-review-title"
|
||||
className="text-sm flex items-center gap-2"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-warn-foreground" />
|
||||
Review order
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Verify AI-populated fields against the source email &
|
||||
attachments.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
aria-label="Close"
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto space-y-4 p-4">
|
||||
{/* Header strip */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Order number</Label>
|
||||
<Input
|
||||
value={orderNumber}
|
||||
onChange={(e) => {
|
||||
setOrderNumber(e.target.value);
|
||||
setNumberError(null);
|
||||
setCollidingOrderId(null);
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 font-mono text-sm",
|
||||
numberError && "border-destructive focus-visible:ring-destructive/40",
|
||||
)}
|
||||
data-testid="review-order-number"
|
||||
/>
|
||||
{numberError && (
|
||||
<p
|
||||
className="text-[11px] text-destructive flex items-center gap-1"
|
||||
data-testid="review-order-number-error"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{numberError}
|
||||
{collidingOrderId && (
|
||||
<a
|
||||
href={`/orders/${collidingOrderId}`}
|
||||
className="underline hover:no-underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
open existing <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Customer</Label>
|
||||
<CustomerSearch
|
||||
value={customerId}
|
||||
onValueChange={setCustomerId}
|
||||
customers={customers}
|
||||
placeholder="Pick a customer…"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{customer.customer || "(no customer name)"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Company</Label>
|
||||
<p className="h-9 inline-flex items-center px-3 rounded-md border bg-muted/30 text-sm">
|
||||
{company.company}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Rep</Label>
|
||||
<p className="h-9 inline-flex items-center px-3 rounded-md border bg-muted/30 text-sm">
|
||||
{order.rep || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items table (left side) + preview placeholder (right side).
|
||||
The preview region is filled in by Task 5. */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Order items
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No line items on this order.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
{items.map((it) => {
|
||||
const d = itemDrafts[it.id];
|
||||
if (!d) return null;
|
||||
return (
|
||||
<li key={it.id} className="p-3 space-y-2">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{it.description || "(no description)"}
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
Qty
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={d.quantity ?? ""}
|
||||
onChange={(e) =>
|
||||
onQuantityChange(it.id, e.target.value)
|
||||
}
|
||||
className="h-8 text-xs tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
Cost
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={d.cost_price ?? ""}
|
||||
onChange={(e) => onCostChange(it.id, e.target.value)}
|
||||
className={cn(
|
||||
"h-8 text-xs tabular-nums",
|
||||
d.lock === "cost" && "border-warn/40 bg-warn/10",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
Markup %
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={d.markup ?? ""}
|
||||
onChange={(e) =>
|
||||
onMarkupChange(it.id, e.target.value)
|
||||
}
|
||||
className="h-8 text-xs tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
Selling
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={d.selling_price ?? ""}
|
||||
onChange={(e) =>
|
||||
onSellingChange(it.id, e.target.value)
|
||||
}
|
||||
className={cn(
|
||||
"h-8 text-xs tabular-nums",
|
||||
d.lock === "selling" && "border-warn/40 bg-warn/10",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Source of truth
|
||||
</h3>
|
||||
{/* Task 5 fills this in. */}
|
||||
<SourceOfTruth
|
||||
quoteEmail={quoteEmail}
|
||||
attachments={attachments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
data-testid="review-save-button"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Source-of-truth panel (filled out in Task 5) -------------------------
|
||||
function SourceOfTruth({
|
||||
quoteEmail,
|
||||
attachments,
|
||||
}: {
|
||||
quoteEmail: { subject: string; body: string };
|
||||
attachments: QuoteAttachment[];
|
||||
}) {
|
||||
if (attachments.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs space-y-2">
|
||||
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
Email
|
||||
</div>
|
||||
<p className="text-foreground font-medium">{quoteEmail.subject || "(no subject)"}</p>
|
||||
<pre className="whitespace-pre-wrap font-sans text-muted-foreground max-h-64 overflow-y-auto">
|
||||
{quoteEmail.body || "(no body)"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs space-y-2">
|
||||
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
Attachments ({attachments.length}) — preview added in Task 5
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.id} className="text-muted-foreground truncate">
|
||||
{a.filename}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue