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:
parent
d9c60aff6d
commit
e9ffa6df4e
3 changed files with 1031 additions and 90 deletions
|
|
@ -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>
|
||||
|
|
|
|||
322
src/components/quotes/process-item-card.test.tsx
Normal file
322
src/components/quotes/process-item-card.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
482
src/components/quotes/process-item-card.tsx
Normal file
482
src/components/quotes/process-item-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue