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>
135 lines
5.5 KiB
TypeScript
135 lines
5.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|