feat: enhance toasts and add quote item toggle

- completely rewrite the Sonner toaster component with theme-aligned styling, default top-center position, brand-colored toast states, and improved spacing/sizing
- update root layout to remove hardcoded Toaster position prop since the component now handles its own positioning
- add toggle to show/hide completed quote items on the quote process page
- add empty state message when all quote items are fully processed
- memoize displayed quote items for stable sorting and filtering
This commit is contained in:
SJCoetz 2026-06-22 23:50:33 +02:00
parent 86c942a466
commit 4c0b6b0163
3 changed files with 156 additions and 59 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, Fragment } from "react";
import { useState, useEffect, useCallback, useMemo, Fragment } from "react";
import { use } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -67,6 +67,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
* auto-switches the lock to it, so the user is never fighting the UI.
*/
const [pricingLock, setPricingLock] = useState<"cost" | "selling">("cost");
/**
* Focus mode for the items table: completed items naturally sink to
* the bottom; a one-click toggle drops them from view entirely.
*/
const [showCompleted, setShowCompleted] = useState(true);
const [rewriteOpen, setRewriteOpen] = useState(false);
const fetchData = useCallback(async () => {
@ -91,6 +96,21 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
useEffect(() => { fetchData(); }, [fetchData]);
/**
* Items to render in the table. Auto-sort: incomplete first, done
* last. Original row number preserved via `originalIdx` so the
* "1, 2, 3…" reference stays stable as items move around.
*/
const displayItems = useMemo(() => {
const annotated = items.map((item, originalIdx) => ({ item, originalIdx }));
const filtered = showCompleted
? annotated
: annotated.filter(({ item }) => !item.is_processed);
return [...filtered].sort(
(a, b) => Number(a.item.is_processed) - Number(b.item.is_processed)
);
}, [items, showCompleted]);
const toggleRow = (itemId: string) => {
setExpandedRows((prev) => {
const next = new Set(prev);
@ -247,7 +267,8 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
const { subtotal, vat_amount, grand_total } = calculateTotals(
items.map((i) => ({ selling_price: i.selling_price || 0, quantity: i.quantity || 1 }))
);
const processedCount = items.filter((i) => i.is_processed).length;
const processedCount = items.filter((i) => i.is_processed).length;
const allProcessed = items.length > 0 && items.every((i) => i.is_processed);
return (
<div className="space-y-4 max-w-7xl mx-auto">
@ -258,11 +279,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
</Button>
<div className="flex-1">
<h1 className="text-xl font-bold tracking-tight">Process Quote #{quote.quote_number}</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className={quoteStatusClassName(quote.status)}>
{formatQuoteStatus(quote.status)}
</Badge>
<span className="text-xs text-muted-foreground">{processedCount}/{items.length} items processed</span>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className={quoteStatusClassName(quote.status)}>
{formatQuoteStatus(quote.status)}
</Badge>
<span className="text-xs text-muted-foreground">{processedCount}/{items.length} items processed</span>
</div>
</div>
<div className="flex gap-2">
@ -336,6 +357,25 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
Quote Items ({items.length})
</CardTitle>
<div className="flex items-center gap-3">
{processedCount > 0 && (
<Button
variant={showCompleted ? "outline" : "default"}
size="sm"
onClick={() => setShowCompleted(!showCompleted)}
data-testid="toggle-show-completed"
className={cn(
"h-7 text-xs font-medium",
!showCompleted &&
"bg-indigo-500 hover:bg-indigo-600 text-white border-indigo-600 shadow-sm"
)}
>
{showCompleted ? (
<>Hide {processedCount} completed</>
) : (
<>Show all ({items.length})</>
)}
</Button>
)}
{subtotal > 0 && (
<div className="text-right text-sm">
<span className="text-muted-foreground">Total: </span>
@ -376,7 +416,26 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => {
{allProcessed && !showCompleted ? (
<TableRow data-testid="all-done-row">
<TableCell colSpan={6} className="text-center py-10 bg-green-50/40 dark:bg-green-950/20">
<div className="flex flex-col items-center gap-2">
<CheckCircle className="h-8 w-8 text-green-500" />
<p className="text-sm font-medium text-green-900 dark:text-green-200">
All {items.length} items processed
</p>
<Button
variant="outline"
size="sm"
onClick={() => setShowCompleted(true)}
>
Show completed
</Button>
</div>
</TableCell>
</TableRow>
) : (
displayItems.map(({ item, originalIdx: idx }) => {
const isExpanded = expandedRows.has(item.id);
return (
<Fragment key={item.id}>
@ -489,7 +548,8 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
)}
</Fragment>
);
})}
})
)}
</TableBody>
</Table>
</div>

View file

@ -55,7 +55,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
</AuthProvider>
</TooltipProvider>
<Toaster richColors position="top-right" />
<Toaster richColors />
</ThemeProvider>
</body>
</html>

View file

@ -1,49 +1,86 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* WFDASH toaster — built on sonner, styled to match the app:
* - Positioned top-centre, below the typical page header.
* - Slightly translucent cards with backdrop blur, soft shadow,
* rounded-xl. Wider than the default so titles + descriptions
* have room to breathe.
* - Icons bumped to size-5 with semantic tints per type via the
* success/error/warning/info `classNames` keys.
* - Auto-stacks up to 5 visible toasts.
*
* Theme vars (--success-bg, --error-bg, etc.) override sonner's defaults
* so we get brand-aligned emerald / red / amber / blue surfaces in both
* light and dark mode.
*/
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
position="top-center"
offset={24}
visibleToasts={5}
duration={4000}
icons={{
success: <CircleCheckIcon className="size-5 text-emerald-600 dark:text-emerald-400" />,
info: <InfoIcon className="size-5 text-blue-600 dark:text-blue-400" />,
warning: <TriangleAlertIcon className="size-5 text-amber-600 dark:text-amber-400" />,
error: <OctagonXIcon className="size-5 text-red-600 dark:text-red-400" />,
loading: <Loader2Icon className="size-5 animate-spin text-blue-600 dark:text-blue-400" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
// Override rich-colour surfaces with brand-aligned tints.
"--success-bg": "color-mix(in oklab, var(--color-emerald-50) 92%, transparent)",
"--success-text": "var(--color-emerald-950)",
"--success-border": "var(--color-emerald-200)",
"--error-bg": "color-mix(in oklab, var(--color-red-50) 92%, transparent)",
"--error-text": "var(--color-red-950)",
"--error-border": "var(--color-red-200)",
"--warning-bg": "color-mix(in oklab, var(--color-amber-50) 92%, transparent)",
"--warning-text": "var(--color-amber-950)",
"--warning-border": "var(--color-amber-200)",
"--info-bg": "color-mix(in oklab, var(--color-blue-50) 92%, transparent)",
"--info-text": "var(--color-blue-950)",
"--info-border": "var(--color-blue-200)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: cn(
"group toast",
"min-w-[360px] max-w-[420px]",
"rounded-xl border shadow-xl",
"backdrop-blur-md",
"px-4 py-3 gap-3"
),
title: "text-sm font-semibold leading-tight",
description: "text-xs text-muted-foreground mt-1 leading-snug",
icon: "mt-0.5 shrink-0",
actionButton:
"bg-foreground text-background text-xs font-medium px-3 py-1.5 rounded-md hover:bg-foreground/90",
cancelButton:
"bg-muted text-muted-foreground text-xs font-medium px-3 py-1.5 rounded-md hover:bg-muted/80",
closeButton:
"bg-background/80 border border-border text-foreground hover:bg-accent absolute right-2 top-2 rounded-md p-1 opacity-70 hover:opacity-100 transition-opacity",
},
}}
{...props}
/>
)
}
export { Toaster }