From 4c0b6b0163bb7082a111adce21f00b6df345bed4 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 22 Jun 2026 23:50:33 +0200 Subject: [PATCH] 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 --- src/app/(app)/quotes/[id]/process/page.tsx | 78 ++++++++++-- src/app/layout.tsx | 2 +- src/components/ui/sonner.tsx | 135 +++++++++++++-------- 3 files changed, 156 insertions(+), 59 deletions(-) diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx index 25d43fd..826fd77 100644 --- a/src/app/(app)/quotes/[id]/process/page.tsx +++ b/src/app/(app)/quotes/[id]/process/page.tsx @@ -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 (
@@ -258,11 +279,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str

Process Quote #{quote.quote_number}

-
- - {formatQuoteStatus(quote.status)} - - {processedCount}/{items.length} items processed +
+ + {formatQuoteStatus(quote.status)} + + {processedCount}/{items.length} items processed
@@ -336,6 +357,25 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str Quote Items ({items.length})
+ {processedCount > 0 && ( + + )} {subtotal > 0 && (
Total: @@ -376,7 +416,26 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str - {items.map((item, idx) => { + {allProcessed && !showCompleted ? ( + + +
+ +

+ All {items.length} items processed +

+ +
+
+
+ ) : ( + displayItems.map(({ item, originalIdx: idx }) => { const isExpanded = expandedRows.has(item.id); return ( @@ -489,7 +548,8 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str )} ); - })} + }) + )}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 82e438d..d43eaae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -55,7 +55,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} - + diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index bfc3ca5..a75dba5 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -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 ( - - ), - info: ( - - ), - warning: ( - - ), - error: ( - - ), - loading: ( - - ), - }} - 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 ( + , + info: , + warning: , + error: , + loading: , + }} + 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 } \ No newline at end of file