WFDASH-WEBAPP/src/components/quote-items-expanded.tsx
Dave 113735d0ba fix(quotes): speed up archive search and add item preview to archive rows
The archive's search fetched the entire recency-windowed matching set
(thousands of rows once "Include older" is on) and substring-filtered
it in memory, taking several seconds per keystroke; it now delegates
to the same ranked pushdown search service the main /quotes list uses.
Also fixes pagination being hidden while searching (results past page
1 were unreachable) and the match count showing the current page's
size instead of the true total.

Adds the chevron item-preview toggle to archive rows (extracted into
a shared QuoteItemsExpanded component, backed by a new GET
/api/v1/quotes/[id]/items route), removes a duplicate "Customer or
Company" label in the new-quote form, and drops the stray
warning-colored search box styling that leaked from DataTable onto
every list page that uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:14 +02:00

135 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Button } from "@/components/ui/button";
import { Loader2, Inbox, PackageOpen } from "lucide-react";
import { formatCurrency } from "@/lib/pb-helpers";
import type { QuoteItem } from "@/lib/types";
/**
* Inline mini-list of a quote's items, shown when a list row is
* expanded. Purely a renderer — the host page owns the fetch/cache
* (lazy-populated on first expand, kept warm across collapse/re-open).
* Shared by `/quotes` and `/quotes/archive` so both list views get the
* same "peek at items without leaving the list" affordance.
*/
export function QuoteItemsExpanded({
items,
loading,
onOpen,
}: {
items: QuoteItem[] | undefined;
loading: boolean;
onOpen: () => void;
}) {
if (loading) {
return (
<div className="px-4 py-6 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading items…
</div>
);
}
if (!items) {
return (
<div className="px-4 py-4 text-sm text-muted-foreground">
Click again to retry loading items.
</div>
);
}
if (items.length === 0) {
return (
<div className="px-4 py-6 flex flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<Inbox className="h-6 w-6 opacity-60" />
No items on this quote yet.
<Button variant="link" size="sm" onClick={onOpen} className="h-auto p-0">
Open quote to add items
</Button>
</div>
);
}
const subtotal = items.reduce(
(sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.quantity) || 1),
0
);
return (
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<PackageOpen className="h-3.5 w-3.5" />
Items ({items.length})
</div>
<Button variant="link" size="sm" onClick={onOpen} className="h-auto p-0 text-xs">
Open full quote →
</Button>
</div>
<ul className="divide-y divide-border/60 rounded-md border bg-background">
{items.map((it) => (
<li key={it.id} className="px-3 py-2 flex items-start gap-3 text-sm">
{/*
* `items-start` (vs the default `items-center`) so the right
* price column anchors to the top of the row regardless of
* whether the description is one line, two lines, or more.
* Without this, a single-line "no price yet" placeholder
* floats in the vertical middle of mixed rows where other
* items have a 2-line price stack — feels orphaned.
*/}
<div className="flex-1 min-w-0">
{/* Long descriptions wrap inside the row width instead
of being clipped to one line. `whitespace-pre-wrap`
preserves the supplier's line breaks (POs sometimes
ship with multiline notes), `break-words` lets very
long unbroken tokens wrap at any character instead
of forcing horizontal scroll. */}
<div className="font-medium whitespace-pre-wrap break-words">
{it.description || "(no description)"}
</div>
<div className="text-xs text-muted-foreground mt-0.5 flex flex-wrap gap-x-3">
<span>
Qty: <span className="font-medium tabular-nums text-foreground">{it.quantity || 1}</span>
</span>
{it.expand?.supplier?.suppliername && (
<span>Supplier: <span className="text-foreground">{it.expand.supplier.suppliername}</span></span>
)}
{/*
* Pair with the "processed" branch above: green when the
* item is priced, warm amber when it isn't. We use `text-warn`
* (the amber itself), NOT `text-warn-foreground` — the
* foreground token is intentionally dark because it's
* designed to sit on a warn-tinted background (see the
* FollowUpChip). As a standalone span with no background
* it renders near-black on the dark canvas and disappears.
* `text-warn` keeps the same semantic pair (success ↔ warn)
* while reading on both light and dark themes.
*/}
{it.is_processed ? (
<span className="text-success">processed</span>
) : (
<span className="text-warn">not processed</span>
)}
</div>
</div>
<div className="text-right shrink-0 tabular-nums">
{it.selling_price ? (
<>
<div className="font-medium">{formatCurrency(it.selling_price)}</div>
<div className="text-xs text-muted-foreground">
× {it.quantity || 1} = {formatCurrency((it.selling_price || 0) * (it.quantity || 1))}
</div>
</>
) : (
<span className="text-xs text-muted-foreground">no price yet</span>
)}
</div>
</li>
))}
</ul>
{subtotal > 0 && (
<div className="flex justify-end mt-2 text-xs text-muted-foreground">
<span>Subtotal: <span className="font-semibold text-foreground">{formatCurrency(subtotal)}</span></span>
</div>
)}
</div>
);
}