feat(quotes/process): add mobile card layout for quote items

Replace the cramped horizontal table layout on mobile viewports with responsive vertical cards for each quote item. The new `ProcessItemCard` component provides full-width form fields, properly sized 44px tap targets for all actions, and maintains full parity with the desktop table implementation by reusing the same state handlers and callbacks. Add comprehensive test coverage for the new component, and fix existing mobile overflow issues in the quote process page header and table layout.
This commit is contained in:
Dave 2026-08-10 10:47:36 +02:00
parent d9c60aff6d
commit e9ffa6df4e
3 changed files with 1031 additions and 90 deletions

View file

@ -48,6 +48,7 @@ import { ProcessOriginalEmailCard } from "@/components/quotes/process-original-e
import { ProcessAddItemForm } from "@/components/quotes/process-add-item-form";
import { ProcessOutboundAttachmentsCard } from "@/components/quotes/process-outbound-attachments-card";
import { ProcessItemAttachments } from "@/components/quotes/process-item-attachments";
import { ProcessItemCard } from "@/components/quotes/process-item-card";
import type { Quote, QuoteItem, QuoteAttachment, Supplier, Customer } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { LetterheadCode } from "@/lib/letterhead";
@ -937,12 +938,18 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
{/* Items Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm flex items-center gap-2">
{/* Outer flex wraps to multiple lines on narrow viewports.
Title + total stay on row 1, progress badges wrap to row 2
on mobile, action buttons drop to row 3. On `md+` everything
fits inline and `md:flex-nowrap` keeps it on one line.
Without the wrap, all 4 action buttons push off-screen on
a 428-px viewport (the original bug). */}
<div className="flex flex-wrap md:flex-nowrap items-center justify-between gap-3">
<CardTitle className="text-sm flex items-center gap-2 shrink-0">
<FileText className="h-4 w-4" />
Quote Items ({items.length})
</CardTitle>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2 md:gap-3">
{/* Per-state counts as a small inline progress strip.
Lets the operator spot gaps at a glance without
scanning each row. The numbers are click-free —
@ -950,7 +957,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
("Hide completed", "Check pricing", "Rewrite with AI")
are the actionable ones. */}
{items.length > 0 && (
<div className="flex items-center gap-2 text-[11px]">
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<Badge
variant="outline"
className="h-5 px-1.5 border-success/30 bg-success/10 text-success dark:bg-success/15"
@ -1050,8 +1057,12 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
/>
</div>
) : (
<div className="overflow-x-auto">
<Table className="table-fixed">
<>
{/* Desktop table — unchanged from the original
layout. `hidden md:block` keeps it out of the way on
small screens; the cards below take over. */}
<div className="overflow-x-auto hidden md:block">
<Table className="table-fixed">
<TableHeader>
<TableRow className="bg-muted/50">
{/* Column widths here lock the layout so the
@ -1071,7 +1082,13 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
<TableHead className="w-[64px] text-center">Qty</TableHead>
<TableHead className="w-[56px] text-right">Markup</TableHead>
<TableHead className="w-[56px] text-center">Attach</TableHead>
<TableHead className="w-[96px]">Actions</TableHead>
{/* Hidden on mobile (`md:table-cell` restores on
`md+`): the cramped 96-px icon column overflows
the table on a 428-px viewport. The 3 actions
move into the expanded row at the bottom of
each item on mobile, where they get full-width
buttons with proper 44-px tap targets. */}
<TableHead className="w-[96px] hidden md:table-cell">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -1168,7 +1185,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
className="h-6 w-6 text-muted-foreground hover:text-foreground shrink-0"
disabled={lookingUpId === item.id}
onClick={() =>
void lookupItem(item.id, item.sku || "")
@ -1182,91 +1199,157 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
<Search className="h-3 w-3" />
)}
</Button>
{/* Inline state indicators — render ONLY
when something's wrong with the row.
Drags too much attention when every
item shows a chip; we want the
abnormal rows to pop. Red border is
reserved for the worst (no
description). Amber for missing
supplier / pricing. */}
{!item.description?.trim() && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-destructive/30 bg-destructive/10 text-destructive dark:bg-destructive/15"
title="No description"
>
no description
</Badge>
)}
{!item.supplier && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15"
title="No supplier"
>
✕ supplier
</Badge>
)}
{!(item.cost_price && item.selling_price) && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-destructive/30 bg-destructive/10 text-destructive dark:bg-destructive/15"
title="Missing cost / selling"
>
✕ priced
</Badge>
)}
{item.pricing_source === "catalogue" && (
<Badge
variant="outline"
// Green matched — flips in dark mode
// to a darker border + lighter text
// so it stays readable.
className="h-5 px-1.5 text-[10px] border-success/30 bg-success/10 text-success dark:bg-success/15 gap-0.5"
title={
item.pricing_notes ||
"Auto-matched from price list"
}
>
<CheckCircle className="h-2.5 w-2.5" />
{item.supplier
? "matched"
: "matched (no supplier)"}
</Badge>
)}
{item.pricing_source === "manual" && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15"
title={item.pricing_notes || "Manually priced"}
>
manual
</Badge>
)}
{item.pricing_notes &&
</div>
{/* State indicators — own row so the
badges don't overflow into the
neighbouring Qty column on a narrow
Description cell. Same visual style as
before (red = worst, amber = missing
supplier / pricing, green = matched,
info = tagged) — just on their own line. */}
{(item.pricing_source === "catalogue" ||
item.pricing_source === "manual" ||
!item.description?.trim() ||
!item.supplier ||
!(item.cost_price && item.selling_price) ||
(item.pricing_notes &&
item.pricing_source !== "catalogue" &&
item.pricing_source !== "manual" &&
(item.pricing_notes
.toLowerCase()
.includes("no match") ? (
item.pricing_source !== "manual")) && (
<div
className="flex flex-wrap items-center gap-1 mt-1"
data-testid={`row-badges-${item.id}`}
>
{!item.description?.trim() && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15 gap-0.5"
title={item.pricing_notes}
className="h-5 px-1.5 text-[10px] border-destructive/30 bg-destructive/10 text-destructive dark:bg-destructive/15"
title="No description"
>
<XCircle className="h-2.5 w-2.5" />
no match
no description
</Badge>
)}
{!item.supplier && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15"
title="No supplier"
>
✕ supplier
</Badge>
)}
{!(item.cost_price && item.selling_price) && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-destructive/30 bg-destructive/10 text-destructive dark:bg-destructive/15"
title="Missing cost / selling"
>
✕ priced
</Badge>
)}
{item.pricing_source === "catalogue" && (
<Badge
variant="outline"
// Green matched — flips in dark mode
// to a darker border + lighter text
// so it stays readable.
className="h-5 px-1.5 text-[10px] border-success/30 bg-success/10 text-success dark:bg-success/15 gap-0.5"
title={
item.pricing_notes ||
"Auto-matched from price list"
}
>
<CheckCircle className="h-2.5 w-2.5" />
{item.supplier
? "matched"
: "matched (no supplier)"}
</Badge>
)}
{item.pricing_source === "manual" && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15"
title={item.pricing_notes || "Manually priced"}
>
manual
</Badge>
)}
{item.pricing_notes &&
item.pricing_source !== "catalogue" &&
item.pricing_source !== "manual" &&
(item.pricing_notes
.toLowerCase()
.includes("no match") ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15 gap-0.5"
title={item.pricing_notes}
>
<XCircle className="h-2.5 w-2.5" />
no match
</Badge>
) : (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-info/30 bg-info/10 text-info dark:bg-info/15"
title={item.pricing_notes}
>
tagged
</Badge>
))}
</div>
)}
{/* Mobile-only action row — always visible
on small screens, never gated behind
the RFQ-details expansion. On `md+` the
same 3 buttons live in the cramped icon
Actions column (`hidden md:table-cell`
— 96 px wide). On mobile this row gets
44-px tall, full-width buttons so the
operator can save / price / delete any
row without scrolling horizontally or
expanding the RFQ details. */}
<div
className="grid grid-cols-3 gap-1.5 mt-1.5 md:hidden"
data-testid={`row-actions-mobile-${item.id}`}
>
<Button
type="button"
variant="outline"
size="sm"
className="h-11 text-xs"
onClick={() => openPricingModal(item)}
data-testid={`row-price-mobile-${item.id}`}
>
<DollarSign className="mr-1 h-3.5 w-3.5" />
Price
</Button>
<Button
type="button"
variant="default"
size="sm"
className="h-11 text-xs font-semibold"
onClick={() => saveItem(item.id)}
disabled={savingId === item.id}
data-testid={`row-save-mobile-${item.id}`}
>
{savingId === item.id ? (
<RefreshCw className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-info/30 bg-info/10 text-info dark:bg-info/15"
title={item.pricing_notes}
>
tagged
</Badge>
))}
<Save className="mr-1 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-11 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => deleteItem(item.id)}
data-testid={`row-delete-mobile-${item.id}`}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Delete
</Button>
</div>
<div className="flex gap-1 mt-1">
<Button
@ -1305,7 +1388,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
<TableCell className="text-center">
<ProcessItemAttachments itemId={item.id} />
</TableCell>
<TableCell>
{/* Hidden on mobile — the same 3 buttons
appear in the expanded row below at full
width with 44-px tap targets. Restored on
`md+` where the cramped icon row fits. */}
<TableCell className="hidden md:table-cell">
<div className="flex gap-1">
<Button
variant="outline"
@ -1368,8 +1455,58 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
})
)}
</TableBody>
</Table>
</div>
</Table>
</div>
{/* Mobile card-per-item layout — vertical cards, one per
item, with full-width fields and labelled action
buttons at 44 px tap targets. Hidden on `md+` so the
desktop table isn't duplicated. The card shares the
same callbacks as the desktop table (single source of
truth for state mutations). */}
<div className="md:hidden">
{allProcessed && !showCompleted ? (
<div className="p-6 text-center bg-success/10 dark:bg-success/15">
<div className="flex flex-col items-center gap-2">
<CheckCircle className="h-8 w-8 text-success" />
<p className="text-sm font-medium text-success">
All {items.length} items processed
</p>
<Button
variant="outline"
size="sm"
onClick={() => setShowCompleted(true)}
data-testid="mobile-all-done-show"
>
Show completed
</Button>
</div>
</div>
) : (
<div className="p-3 space-y-3" data-testid="process-items-cards">
{displayItems.map(({ item, originalIdx }) => {
const isExpanded = expandedRows.has(item.id);
return (
<ProcessItemCard
key={item.id}
item={item}
index={originalIdx + 1}
isExpanded={isExpanded}
savingId={savingId}
lookingUpId={lookingUpId}
onUpdateField={updateItemField}
onToggleRow={toggleRow}
onOpenPricing={openPricingModal}
onSave={saveItem}
onDelete={deleteItem}
onLookupSku={lookupItem}
/>
);
})}
</div>
)}
</div>
</>
)}
</CardContent>
</Card>

View file

@ -0,0 +1,322 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { QuoteItem } from "@/lib/types";
/**
* Mock the attachments sub-component so the card tests don't need a
* live network — `ProcessItemAttachments` opens a popover that hits
* `/api/v1/quotes/items/[id]/attachments` on mount.
*/
vi.mock("@/components/quotes/process-item-attachments", () => ({
ProcessItemAttachments: ({ itemId }: { itemId: string }) => (
<div data-testid={`attach-stub-${itemId}`} data-paperclip="true">
📎 0
</div>
),
}));
import { ProcessItemCard } from "./process-item-card";
const baseItem: QuoteItem = {
id: "qi-1",
description: "M20 x 30 set screw black",
sku: "",
quantity: 10,
cost_price: 8.16,
selling_price: 10.61,
markup: 30.02,
supplier: "sup-1",
pricing_source: "",
pricing_notes: "",
is_processed: false,
};
const noop = (): void => {};
const asyncNoop = async (): Promise<void> => {};
const handlers = () => ({
onUpdateField: vi.fn(noop),
onToggleRow: vi.fn(noop),
onOpenPricing: vi.fn(noop),
onSave: vi.fn(asyncNoop),
onDelete: vi.fn(asyncNoop),
onLookupSku: vi.fn(asyncNoop),
});
const renderCard = (overrides: Partial<Parameters<typeof ProcessItemCard>[0]> = {}) => {
const merged = {
item: baseItem,
index: 1,
isExpanded: false,
savingId: null,
lookingUpId: null,
...handlers(),
...overrides,
};
return render(<ProcessItemCard {...merged} />);
};
afterEach(() => {
vi.restoreAllMocks();
});
/**
* React's controlled `<input value={number}>` + `user.clear()` is
* fiddly in jsdom: clear fires input events but the controlled
* value is still the original number, so subsequent type calls
* append. We bypass userEvent for numeric inputs and drive
* fireEvent.change directly, using the React-aware value setter
* that testing-library ships.
*/
function setControlledValue(
input: HTMLInputElement | HTMLTextAreaElement,
value: string,
) {
const proto =
input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
describe("ProcessItemCard", () => {
it("renders the item number and description as the card header", () => {
renderCard();
const card = screen.getByTestId("process-item-card-qi-1");
// The header h3 carries the title.
const header = card.querySelector("h3")!;
expect(header).toHaveTextContent("M20 x 30 set screw black");
// The index renders as "1" inside its own span with aria-label.
expect(within(card).getByLabelText("Item 1")).toHaveTextContent("1");
});
it("renders a check-circle when the item is already processed", () => {
renderCard({ item: { ...baseItem, is_processed: true } });
expect(screen.getByLabelText("Processed")).toBeInTheDocument();
});
it("shows the 'No description' italic placeholder in the header when description is empty", () => {
renderCard({ item: { ...baseItem, description: "" } });
// The placeholder lives in the header h3 (capital N).
const header = screen.getByTestId("process-item-card-qi-1").querySelector("h3")!;
expect(within(header).getByText(/no description/i)).toBeInTheDocument();
// The 'no description' BADGE also appears (lowercase n).
const badges = screen.getByTestId("card-row-badges-qi-1");
expect(within(badges).getByText("no description")).toBeInTheDocument();
});
it("shows the × supplier and × priced badges when supplier + pricing are missing", () => {
renderCard({
item: {
...baseItem,
supplier: "",
cost_price: 0,
selling_price: 0,
},
});
const badges = screen.getByTestId("card-row-badges-qi-1");
expect(within(badges).getByText("✕ supplier")).toBeInTheDocument();
expect(within(badges).getByText("✕ priced")).toBeInTheDocument();
});
it("renders the green matched badge when pricing_source is catalogue", () => {
renderCard({
item: {
...baseItem,
pricing_source: "catalogue",
supplier: "sup-1",
},
});
expect(within(screen.getByTestId("card-row-badges-qi-1")).getByText("matched")).toBeInTheDocument();
});
it("renders the amber 'no match' badge when pricing_notes contains the phrase", () => {
renderCard({
item: {
...baseItem,
pricing_source: "ai",
pricing_notes: "Catalogue lookup: no match — fell back",
},
});
expect(within(screen.getByTestId("card-row-badges-qi-1")).getByText("no match")).toBeInTheDocument();
});
it("hides the badges container when no badge condition is met", () => {
// baseItem has: description, supplier, cost_price, selling_price all
// set, pricing_notes empty, pricing_source "" (not catalogue/manual).
// → none of the 6 badge conditions match → no container.
renderCard();
expect(screen.queryByTestId("card-row-badges-qi-1")).not.toBeInTheDocument();
});
it("calls onUpdateField with the new value when the description textarea changes", async () => {
const onUpdateField = vi.fn(noop);
renderCard({ onUpdateField });
const ta = screen.getByTestId("card-description-qi-1") as HTMLTextAreaElement;
setControlledValue(ta, "M24 hex bolt");
expect(onUpdateField).toHaveBeenCalledWith("qi-1", "description", "M24 hex bolt");
});
it("calls onUpdateField with the new value when the SKU input changes", async () => {
const onUpdateField = vi.fn(noop);
renderCard({ onUpdateField });
const input = screen.getByTestId("card-sku-qi-1") as HTMLInputElement;
setControlledValue(input, "X");
expect(onUpdateField).toHaveBeenCalledWith("qi-1", "sku", "X");
});
it("calls onLookupSku when the 🔍 button is clicked", async () => {
const user = userEvent.setup();
const onLookupSku = vi.fn(asyncNoop);
renderCard({ onLookupSku });
await user.click(screen.getByTestId("card-sku-lookup-qi-1"));
expect(onLookupSku).toHaveBeenCalledWith("qi-1", "");
});
it("calls onLookupSku when Enter is pressed in the SKU input", async () => {
const user = userEvent.setup();
const onLookupSku = vi.fn(asyncNoop);
renderCard({
item: { ...baseItem, sku: "JEITD-06" },
onLookupSku,
});
const input = screen.getByTestId("card-sku-qi-1") as HTMLInputElement;
input.focus();
await user.keyboard("{Enter}");
expect(onLookupSku).toHaveBeenCalledWith("qi-1", "JEITD-06");
});
it("disables the lookup button while this row is looking up", () => {
renderCard({ lookingUpId: "qi-1" });
expect(screen.getByTestId("card-sku-lookup-qi-1")).toBeDisabled();
});
it("calls onUpdateField('quantity', N) when the qty input changes", async () => {
const onUpdateField = vi.fn(noop);
renderCard({ onUpdateField });
const qty = screen.getByTestId("card-qty-qi-1") as HTMLInputElement;
setControlledValue(qty, "5");
expect(onUpdateField).toHaveBeenCalledWith("qi-1", "quantity", 5);
});
it("shows the markup percentage when markup > 0", () => {
renderCard({ item: { ...baseItem, markup: 25 } });
expect(screen.getByTestId("card-markup-qi-1")).toHaveTextContent("25%");
});
it("shows a dash for markup when markup is 0", () => {
renderCard({ item: { ...baseItem, markup: 0 } });
expect(screen.getByTestId("card-markup-qi-1")).toHaveTextContent("—");
});
it("renders the attachments stub from the mocked component", () => {
renderCard();
expect(screen.getByTestId("attach-stub-qi-1")).toBeInTheDocument();
});
it("hides the RFQ block by default and shows it when isExpanded", () => {
const { rerender } = renderCard({ isExpanded: false });
expect(screen.queryByTestId("card-rfq-block-qi-1")).not.toBeInTheDocument();
rerender(
<ProcessItemCard
item={baseItem}
index={1}
isExpanded={true}
savingId={null}
lookingUpId={null}
{...handlers()}
/>,
);
expect(screen.getByTestId("card-rfq-block-qi-1")).toBeInTheDocument();
expect(screen.getByTestId("card-rfq-desc-qi-1")).toBeInTheDocument();
expect(screen.getByTestId("card-rfq-notes-qi-1")).toBeInTheDocument();
});
it("calls onToggleRow when the RFQ details toggle is pressed", async () => {
const user = userEvent.setup();
const onToggleRow = vi.fn(noop);
renderCard({ onToggleRow });
await user.click(screen.getByTestId("card-toggle-rfq-qi-1"));
expect(onToggleRow).toHaveBeenCalledWith("qi-1");
});
it("fires onOpenPricing when the Price button is clicked", async () => {
const user = userEvent.setup();
const onOpenPricing = vi.fn(noop);
renderCard({ onOpenPricing });
await user.click(screen.getByTestId("card-price-qi-1"));
expect(onOpenPricing).toHaveBeenCalledTimes(1);
expect(onOpenPricing.mock.calls[0][0]).toMatchObject({ id: "qi-1" });
});
it("fires onSave when the Save button is clicked (and disables it while saving)", async () => {
const user = userEvent.setup();
const onSave = vi.fn(asyncNoop);
const { rerender } = renderCard({ onSave });
const saveBtn = screen.getByTestId("card-save-qi-1");
expect(saveBtn).not.toBeDisabled();
await user.click(saveBtn);
expect(onSave).toHaveBeenCalledWith("qi-1");
// While this row is saving, the button disables
rerender(
<ProcessItemCard
item={baseItem}
index={1}
isExpanded={false}
savingId="qi-1"
lookingUpId={null}
{...handlers()}
onSave={onSave}
/>,
);
expect(screen.getByTestId("card-save-qi-1")).toBeDisabled();
});
it("shows an inline confirm block when Delete is pressed, and confirms/cancels", async () => {
const user = userEvent.setup();
const onDelete = vi.fn(asyncNoop);
renderCard({ onDelete });
expect(screen.queryByTestId("card-delete-confirm-qi-1")).not.toBeInTheDocument();
// Click Delete — confirm block appears
await user.click(screen.getByTestId("card-delete-qi-1"));
const confirmBlock = screen.getByTestId("card-delete-confirm-qi-1");
expect(confirmBlock).toBeInTheDocument();
expect(within(confirmBlock).getByText("Confirm delete")).toBeInTheDocument();
// Click Cancel — confirm block disappears, action row returns
await user.click(within(confirmBlock).getByText("Cancel"));
expect(screen.queryByTestId("card-delete-confirm-qi-1")).not.toBeInTheDocument();
expect(screen.getByTestId("card-row-actions-qi-1")).toBeInTheDocument();
expect(onDelete).not.toHaveBeenCalled();
// Click Delete again, then Confirm delete — fires onDelete
await user.click(screen.getByTestId("card-delete-qi-1"));
await user.click(screen.getByTestId("card-delete-confirm-button-qi-1"));
expect(onDelete).toHaveBeenCalledWith("qi-1");
});
});

View file

@ -0,0 +1,482 @@
"use client";
import { useState } from "react";
import { Check, CheckCircle, ChevronDown, ChevronUp, DollarSign, FileText, RefreshCw, Save, Search, Trash2, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ProcessItemAttachments } from "@/components/quotes/process-item-attachments";
import type { QuoteItem } from "@/lib/types";
/**
* Card-per-item layout for the quote-process page on small screens.
*
* Why this exists
* ───────────────
* The desktop layout is a 6-column table (`<Table className="table-fixed">`)
* — operators with a real keyboard scan rows side-by-side. On a phone,
* that table is ~434 px wide in a ~396 px viewport, so the operator has
* to horizontally scroll to reach the Save / Price / Delete buttons.
* Description text wraps mid-word in a 130 px column. Error badges
* overflow visually into the Qty column. None of it is usable.
*
* This component renders a vertical card per item that:
*
* 1. index + title ← one row, truncates cleanly
* 2. status badges ← own row, no overflow into other cells
* 3. description textarea ← full width
* 4. SKU + lookup button ← own row
* 5. qty / markup / attach ← 3-column grid with stacked labels
* 6. RFQ details toggle ← own row, full width
* 7. price / save / delete ← own row, 3 labelled buttons at 44 px
*
* The page branches on viewport: this card renders below the `md`
* breakpoint, the table renders at `md+`. Both render the same data
* from the same callbacks — single source of truth, two presentations.
*
* ## Props
*
* The component is intentionally callback-only. The parent owns the
* state (expandedRows, savingId, lookingUpId) and passes down
* `isExpanded` + the relevant pending-id. Keeps the card pure and
* easy to test in isolation.
*
* The callbacks mirror the page-level handlers exactly:
*
* - `onUpdateField` → `updateItemField` on the page
* - `onToggleRow` → `toggleRow` on the page
* - `onOpenPricing` → `openPricingModal` on the page
* - `onSave` → `saveItem` on the page
* - `onDelete` → `deleteItem` on the page
* - `onLookupSku` → `lookupItem` on the page
*/
export type ProcessItemCardProps = {
item: QuoteItem;
/** 1-based display index (not the database id). */
index: number;
/** Whether this row's RFQ details row is expanded. */
isExpanded: boolean;
/** PB id of the row currently being saved (drives Save button's spinner). */
savingId: string | null;
/** PB id of the row currently doing a catalogue lookup. */
lookingUpId: string | null;
onUpdateField: (
itemId: string,
field: keyof QuoteItem,
value: string | number,
) => void;
onToggleRow: (itemId: string) => void;
onOpenPricing: (item: QuoteItem) => void;
onSave: (itemId: string) => Promise<void> | void;
onDelete: (itemId: string) => Promise<void> | void;
onLookupSku: (itemId: string, query: string) => Promise<void> | void;
};
const baseInputClass = "bg-input-highlight";
export function ProcessItemCard({
item,
index,
isExpanded,
savingId,
lookingUpId,
onUpdateField,
onToggleRow,
onOpenPricing,
onSave,
onDelete,
onLookupSku,
}: ProcessItemCardProps) {
/**
* Local toggle for the "delete this row?" confirm step. We don't
* bounce a confirm modal for every delete on mobile — the row
* itself grows a tiny "Are you sure?" + cancel block so the
* operator can undo without breaking thumb flow.
*/
const [confirmingDelete, setConfirmingDelete] = useState(false);
const hasBadges =
item.pricing_source === "catalogue" ||
item.pricing_source === "manual" ||
!item.description?.trim() ||
!item.supplier ||
!(item.cost_price && item.selling_price) ||
!!(item.pricing_notes &&
item.pricing_source !== "catalogue" &&
item.pricing_source !== "manual");
const isSaving = savingId === item.id;
const isLookingUp = lookingUpId === item.id;
return (
<article
className={
// Each card is a single rounded block; bg-success tint flags
// saved rows on light + dark.
item.is_processed
? "rounded-lg border bg-success/10 dark:bg-success/15 p-3 space-y-2.5"
: "rounded-lg border p-3 space-y-2.5"
}
data-testid={`process-item-card-${item.id}`}
>
{/* Row 1: index + title */}
<header className="flex items-baseline gap-2">
<span
className="text-sm font-semibold text-muted-foreground w-5 shrink-0"
aria-label={`Item ${index}`}
>
{index}
</span>
{item.is_processed ? (
<CheckCircle
className="h-3.5 w-3.5 text-success shrink-0"
aria-label="Processed"
/>
) : null}
<h3 className="flex-1 min-w-0 truncate text-sm font-medium">
{item.description || (
<span className="italic text-muted-foreground">No description</span>
)}
</h3>
</header>
{/* Row 2: status badges — own line so they never overflow into
neighbouring cells. Each badge is gated on its own condition;
the whole row only renders if at least one badge is relevant. */}
{hasBadges ? (
<div
className="flex flex-wrap items-center gap-1"
data-testid={`card-row-badges-${item.id}`}
>
{!item.description?.trim() ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-destructive/30 bg-destructive/10 text-destructive dark:bg-destructive/15"
title="No description"
>
no description
</Badge>
) : null}
{!item.supplier ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15"
title="No supplier"
>
✕ supplier
</Badge>
) : null}
{!(item.cost_price && item.selling_price) ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-destructive/30 bg-destructive/10 text-destructive dark:bg-destructive/15"
title="Missing cost / selling"
>
✕ priced
</Badge>
) : null}
{item.pricing_source === "catalogue" ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-success/30 bg-success/10 text-success dark:bg-success/15 gap-0.5"
title={item.pricing_notes || "Auto-matched from price list"}
>
<Check className="h-2.5 w-2.5" />
{item.supplier ? "matched" : "matched (no supplier)"}
</Badge>
) : null}
{item.pricing_source === "manual" ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15"
title={item.pricing_notes || "Manually priced"}
>
manual
</Badge>
) : null}
{item.pricing_notes &&
item.pricing_source !== "catalogue" &&
item.pricing_source !== "manual" ? (
item.pricing_notes.toLowerCase().includes("no match") ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-warn/30 bg-warn/10 text-warn dark:bg-warn/15 gap-0.5"
title={item.pricing_notes}
>
<XCircle className="h-2.5 w-2.5" />
no match
</Badge>
) : (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-info/30 bg-info/10 text-info dark:bg-info/15"
title={item.pricing_notes}
>
tagged
</Badge>
)
) : null}
</div>
) : null}
{/* Row 3: description textarea (full width) */}
<div>
<Label
htmlFor={`card-description-${item.id}`}
className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium"
>
Description
</Label>
<Textarea
id={`card-description-${item.id}`}
value={item.description}
onChange={(e) => onUpdateField(item.id, "description", e.target.value)}
rows={2}
className={`mt-0.5 text-xs resize-none ${baseInputClass}`}
placeholder="Item description..."
data-testid={`card-description-${item.id}`}
/>
</div>
{/* Row 4: SKU + lookup */}
<div>
<Label
htmlFor={`card-sku-${item.id}`}
className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium"
>
SKU
</Label>
<div className="flex items-center gap-1 mt-0.5">
<Input
id={`card-sku-${item.id}`}
value={item.sku || ""}
onChange={(e) => onUpdateField(item.id, "sku", e.target.value)}
placeholder="e.g. JEITD-06"
className={`h-9 text-xs font-mono px-2 flex-1 ${baseInputClass}`}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void onLookupSku(item.id, item.sku || "");
}
}}
data-testid={`card-sku-${item.id}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
disabled={isLookingUp}
onClick={() => void onLookupSku(item.id, item.sku || "")}
aria-label="Lookup SKU in catalogue"
data-testid={`card-sku-lookup-${item.id}`}
>
{isLookingUp ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Search className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
{/* Row 5: 3-column metadata strip — Qty (input) / Markup (text) /
Attach (icon + count). Labels above values for readability
without a header row. */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label
htmlFor={`card-qty-${item.id}`}
className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium"
>
Qty
</Label>
<Input
id={`card-qty-${item.id}`}
type="number"
value={item.quantity}
onChange={(e) =>
onUpdateField(item.id, "quantity", Number(e.target.value))
}
// The native spinner arrows eat ~20 px on each side of a
// 60-72 px input on Chrome/Safari and clip the value on a
// 360-px phone. Hide them; operator can still type or
// scroll-wheel.
className={`mt-0.5 h-9 text-xs text-center font-bold ${baseInputClass} qty-no-spinner`}
min={1}
data-testid={`card-qty-${item.id}`}
/>
</div>
<div>
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium block">
Markup
</Label>
<div
className="mt-0.5 h-9 grid place-items-center text-sm tabular-nums text-muted-foreground"
data-testid={`card-markup-${item.id}`}
>
{item.markup > 0 ? `${item.markup}%` : "—"}
</div>
</div>
<div>
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium block">
Attach
</Label>
<div
className="mt-0.5 h-9 grid place-items-center"
data-testid={`card-attach-${item.id}`}
>
<ProcessItemAttachments itemId={item.id} />
</div>
</div>
</div>
{/* Row 6: RFQ details toggle */}
<Button
type="button"
variant="ghost"
size="sm"
className="w-full h-9 text-xs text-muted-foreground justify-center"
onClick={() => onToggleRow(item.id)}
data-testid={`card-toggle-rfq-${item.id}`}
>
<FileText className="mr-1 h-3.5 w-3.5" />
RFQ details
{isExpanded ? (
<ChevronUp className="ml-1 h-3.5 w-3.5" />
) : (
<ChevronDown className="ml-1 h-3.5 w-3.5" />
)}
</Button>
{/* Row 7: action buttons (always visible — never gated behind
the RFQ expansion). Three labelled buttons at 44 px tall so
the operator can save / price / delete any row without an
extra tap. Delete shows a confirm sub-block inline instead
of popping a modal. */}
{confirmingDelete ? (
<div
className="grid grid-cols-2 gap-1.5 pt-1"
data-testid={`card-delete-confirm-${item.id}`}
>
<Button
type="button"
variant="ghost"
size="sm"
className="h-11 text-xs"
onClick={() => setConfirmingDelete(false)}
data-testid={`card-delete-cancel-${item.id}`}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="h-11 text-xs font-semibold"
onClick={() => {
setConfirmingDelete(false);
void onDelete(item.id);
}}
data-testid={`card-delete-confirm-button-${item.id}`}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Confirm delete
</Button>
</div>
) : (
<div
className="grid grid-cols-3 gap-1.5 pt-1"
data-testid={`card-row-actions-${item.id}`}
>
<Button
type="button"
variant="outline"
size="sm"
className="h-11 text-xs"
onClick={() => onOpenPricing(item)}
data-testid={`card-price-${item.id}`}
>
<DollarSign className="mr-1 h-3.5 w-3.5" />
Price
</Button>
<Button
type="button"
size="sm"
className="h-11 text-xs font-semibold"
onClick={() => void onSave(item.id)}
disabled={isSaving}
data-testid={`card-save-${item.id}`}
>
{isSaving ? (
<RefreshCw className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-11 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setConfirmingDelete(true)}
data-testid={`card-delete-${item.id}`}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Delete
</Button>
</div>
)}
{/* Expanded RFQ block — when the operator toggles RFQ details,
show two full-width textareas for the original RFQ
description and any per-item notes. Hidden behind the
toggle so the default card stays scannable. */}
{isExpanded ? (
<div className="space-y-2 pt-1" data-testid={`card-rfq-block-${item.id}`}>
<div>
<Label
htmlFor={`card-rfq-desc-${item.id}`}
className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium"
>
RFQ Description
</Label>
<Textarea
id={`card-rfq-desc-${item.id}`}
value={item.description}
onChange={(e) =>
onUpdateField(item.id, "description", e.target.value)
}
rows={2}
className="mt-0.5 text-xs resize-none"
data-testid={`card-rfq-desc-${item.id}`}
/>
</div>
<div>
<Label
htmlFor={`card-rfq-notes-${item.id}`}
className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium"
>
Notes
</Label>
<Textarea
id={`card-rfq-notes-${item.id}`}
value={(item as { notes?: string }).notes || ""}
onChange={(e) =>
onUpdateField(item.id, "notes" as keyof QuoteItem, e.target.value)
}
rows={2}
className={`mt-0.5 text-xs resize-none ${baseInputClass}`}
placeholder="Item notes..."
data-testid={`card-rfq-notes-${item.id}`}
/>
</div>
</div>
) : null}
</article>
);
}