feat(orders): add edit-mode confirmation for items table

Items in the orders review modal are read-only by default. A new
'Edit items' button (Pencil icon, top right of the items list)
opens a confirmation dialog before the inputs become editable.

- State: editingItems (default false), confirmingEdit (default false).
- Both reset to false whenever the modal re-opens (useEffect on
  [open, order.id]) so re-opening always starts read-only.
- All 4 per-row inputs (qty, cost, markup, selling) carry
  disabled={!editingItems || saving}.
- Visual hint: when editing, the items list gets a subtle warn
  ring + tint (ring-1 ring-warn/40 bg-warn/5).
- data-testid hooks: review-edit-items-btn on the button,
  review-edit-confirm-dialog on the dialog, review-items-list on
  the ul with data-editing attribute.
- 4 new tests cover: read-only default, button opens dialog,
  confirm enables editing, cancel keeps read-only.

Also adds order_attachements?: string[] to the Order type — the
PB file field on the orders collection used by the modal's source-
of-truth panel (separate commit wires the data).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 17:41:21 +02:00
parent 22df511293
commit 7801b602f5
3 changed files with 188 additions and 4 deletions

View file

@ -426,4 +426,74 @@ describe("OrdersReviewModal", () => {
);
expect(screen.getByText(/unpreviewable attachment/i)).toBeInTheDocument();
});
describe("edit-mode confirmation", () => {
// All 4 per-row inputs (qty, cost, markup, selling) should be
// disabled until the operator clicks "Edit items" → confirms.
// This is the safety gate — items are read-only by default.
it("items are read-only by default", () => {
render(<OrdersReviewModal {...baseProps()} />);
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
const rowInputs = itemRow.querySelectorAll("input");
expect(rowInputs).toHaveLength(4);
for (const input of Array.from(rowInputs)) {
expect(input).toBeDisabled();
}
// The list itself should NOT carry the editing visual hint yet.
const list = screen.getByTestId("review-items-list");
expect(list.getAttribute("data-editing")).toBe("false");
});
it("clicking Edit items opens a confirmation dialog", () => {
render(<OrdersReviewModal {...baseProps()} />);
fireEvent.click(screen.getByTestId("review-edit-items-btn"));
// Dialog surfaces the confirmation copy.
expect(
screen.getByText(/Enable item editing\?/i),
).toBeInTheDocument();
// Inputs are STILL disabled — confirming is required to flip the gate.
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
for (const input of Array.from(itemRow.querySelectorAll("input"))) {
expect(input).toBeDisabled();
}
});
it("confirming the dialog enables editing", () => {
render(<OrdersReviewModal {...baseProps()} />);
fireEvent.click(screen.getByTestId("review-edit-items-btn"));
fireEvent.click(screen.getByTestId("review-edit-confirm-enable"));
// Dialog should be closed and items enabled.
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
for (const input of Array.from(itemRow.querySelectorAll("input"))) {
expect(input).not.toBeDisabled();
}
// The visual edit-mode hint should be applied.
const list = screen.getByTestId("review-items-list");
expect(list.getAttribute("data-editing")).toBe("true");
// Button label flips to "Stop editing" so the operator has an
// obvious exit without re-confirming.
expect(
screen.getByRole("button", { name: /Stop editing/i }),
).toBeInTheDocument();
});
it("canceling the dialog keeps items read-only", () => {
render(<OrdersReviewModal {...baseProps()} />);
fireEvent.click(screen.getByTestId("review-edit-items-btn"));
// The Cancel button is the close in the dialog footer.
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/i }));
// Dialog gone, items still disabled, no edit hint.
expect(
screen.queryByText(/Enable item editing\?/i),
).not.toBeInTheDocument();
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
for (const input of Array.from(itemRow.querySelectorAll("input"))) {
expect(input).toBeDisabled();
}
const list = screen.getByTestId("review-items-list");
expect(list.getAttribute("data-editing")).toBe("false");
});
});
});

View file

@ -8,10 +8,20 @@ import {
Loader2,
Mail,
Paperclip,
Pencil,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CustomerSearch } from "@/components/ui/customer-search";
@ -110,6 +120,16 @@ export function OrdersReviewModal({
const [partialFailures, setPartialFailures] = useState<
Array<{ id: string; error: string }>
>([]);
// Edit-mode gate for the items table. Items are read-only by default;
// clicking the "Edit items" button pops a confirmation dialog, and on
// confirm we flip this on. Reset to false on modal close so re-opening
// the modal always starts in read-only mode (defence in depth — parent
// already remounts via `key={order.id}`, but we don't want stale
// editing state to leak between orders if the key is ever forgotten).
const [editingItems, setEditingItems] = useState(false);
// Whether the confirmation dialog is currently shown. Distinct from
// `editingItems` because the dialog is its own modal surface.
const [confirmingEdit, setConfirmingEdit] = useState(false);
// Re-seed local state when the modal opens for a different order.
// Parent already remounts via `key={order.id}`, but defensively
@ -126,6 +146,8 @@ export function OrdersReviewModal({
setNumberError(null);
setCollidingOrderId(null);
setPartialFailures([]);
setEditingItems(false);
setConfirmingEdit(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, order.id]);
@ -446,15 +468,48 @@ export function OrdersReviewModal({
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>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Order items
</h3>
{/* Edit gate — items are read-only until the operator
confirms via the dialog. The button label flips
between "Edit items" and "Stop editing" so the
operator has a clear exit. Clicking while in edit
mode skips the confirmation (no data loss to
confirm against; save still has its own gate). */}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
if (editingItems) {
setEditingItems(false);
} else {
setConfirmingEdit(true);
}
}}
data-testid="review-edit-items-btn"
aria-label={editingItems ? "Stop editing items" : "Edit items"}
>
<Pencil className="h-3 w-3 mr-1" />
{editingItems ? "Stop editing" : "Edit items"}
</Button>
</div>
{persistedItems.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">
<ul
className={cn(
"divide-y rounded-md border bg-card",
editingItems && "ring-1 ring-warn/40 bg-warn/5",
)}
data-testid="review-items-list"
data-editing={editingItems ? "true" : "false"}
>
{persistedItems.map((it) => {
const d = itemDrafts[it.id];
if (!d) return null;
@ -474,6 +529,7 @@ export function OrdersReviewModal({
onChange={(e) =>
onQuantityChange(it.id, e.target.value)
}
disabled={!editingItems || saving}
className="h-8 text-xs tabular-nums"
/>
</div>
@ -485,6 +541,7 @@ export function OrdersReviewModal({
type="number"
value={d.cost_price ?? ""}
onChange={(e) => onCostChange(it.id, e.target.value)}
disabled={!editingItems || saving}
className={cn(
"h-8 text-xs tabular-nums",
d.lock === "cost" && "border-warn/40 bg-warn/10",
@ -501,6 +558,7 @@ export function OrdersReviewModal({
onChange={(e) =>
onMarkupChange(it.id, e.target.value)
}
disabled={!editingItems || saving}
className="h-8 text-xs tabular-nums"
/>
</div>
@ -514,6 +572,7 @@ export function OrdersReviewModal({
onChange={(e) =>
onSellingChange(it.id, e.target.value)
}
disabled={!editingItems || saving}
className={cn(
"h-8 text-xs tabular-nums",
d.lock === "selling" && "border-warn/40 bg-warn/10",
@ -571,6 +630,49 @@ export function OrdersReviewModal({
</div>
</CardContent>
{/* Edit-mode confirmation dialog. Pops when the operator clicks
"Edit items" so the input fields aren't immediately
editable — the operator has to confirm intent. Cancel just
closes the dialog; Enable editing flips `editingItems` and
also closes the dialog. */}
<Dialog
open={confirmingEdit}
onOpenChange={(open) => {
// Only allow the dialog to be dismissed via Cancel / X —
// don't auto-flip editingItems from the open-change handler
// (the only writers are the two buttons below).
if (!open) setConfirmingEdit(false);
}}
>
<DialogContent
className="sm:max-w-sm"
data-testid="review-edit-confirm-dialog"
>
<DialogHeader>
<DialogTitle>Enable item editing?</DialogTitle>
<DialogDescription>
Are you sure you want to edit the fields? Changes will be
saved when you click Save.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="outline" size="sm" />}>
Cancel
</DialogClose>
<Button
size="sm"
onClick={() => {
setEditingItems(true);
setConfirmingEdit(false);
}}
data-testid="review-edit-confirm-enable"
>
Enable editing
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex items-center justify-end gap-2 border-t p-4">
<Button
variant="outline"

View file

@ -650,6 +650,18 @@ export interface Order extends BaseRecord {
* `Quote.ai_insights`. See `components/ai-insights-renderer.tsx`.
*/
ai_insights?: unknown;
/**
* Source attachments for the order. Multi-file field on the orders
* collection (`maxSelect: 10`, accepts any MIME). Replaces the
* previous `quote_attachments` collection rows for orders that were
* migrated on 2026-07-29. The PB SDK returns this as a bare
* `string[]` of filenames (no token); callers compose the file URL
* with `recordId` + filename.
*
* Note: the field name is the PB-side misspelling ("attachements")
* — keep it in sync with the live schema.
*/
order_attachements?: string[];
expand?: {
company?: Company;
customer?: Customer;