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:
parent
86c942a466
commit
4c0b6b0163
3 changed files with 156 additions and 59 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
Loading…
Reference in a new issue