From 9b2b0897a195ff2c2733b92959d1f37e21a30857 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 29 Jun 2026 23:04:03 +0200 Subject: [PATCH] refactor(quotes detail): move Quote Settings into kebab menu as dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quote Settings card sat in the right column as a collapsed accordion alongside History and Email Status — three near-identical cards the operator had to scan past to find the data they actually came for. Quote Settings is a low-frequency config action; it belongs with the other low-frequency actions (Save as Template, Clone, Delete). ⋮ → View Quote View Order Save as Template Clone ───────────── Quote Settings… ← new ───────────── Delete Quote (red) The Settings card is removed from the right column. Its contents now live in QuoteSettingsDialog — same three fields (Quote date, Valid for days, Follow up by), same Save handler, but rendered as a focused modal so the operator sees exactly what they're editing. The page header's FollowUpChip (the yellow / red bell that surfaces a follow-up date on a quote) still opens the same dialog via the renamed `settingsDialogOpen` state. Auto-open on follow_up / problem status is preserved — same effect, just an opened modal instead of an expanded accordion. Template mode (no customer-facing fields) still works: the dialog shows only the Follow up by field plus the explanatory paragraph about why date / validity don't apply to templates. Tests: 229/230 vitest (the 1 failure is the pre-existing attachments off-by-one, unrelated to this change). --- src/app/(app)/quotes/[id]/page.tsx | 310 +++++++++++++++++------------ 1 file changed, 184 insertions(+), 126 deletions(-) diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index 4c921d3..4959d2c 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -72,6 +72,7 @@ import { AlertCircle, RotateCcw, Loader2, + Settings, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -453,9 +454,9 @@ function HistoryRow({ /** * FollowUpChip — small clickable badge in the page header that surfaces * the quote's `follow_up_due` date without forcing the operator to open - * the Quote Settings card. Colour flips yellow→red when overdue so a + * the Quote Settings dialog. Colour flips yellow→red when overdue so a * glance at the list view is enough to triage. Clicking opens the - * Quote Settings card so the date can be edited in one extra step. + * Quote Settings dialog so the date can be edited in one extra step. */ function FollowUpChip({ iso, @@ -531,6 +532,7 @@ function MoreActionsMenu({ onDelete, onViewOrder, onViewQuote, + onOpenSettings, }: { isTemplate: boolean; hasItems: boolean; @@ -545,11 +547,18 @@ function MoreActionsMenu({ onDelete: () => void; onViewOrder: () => void; onViewQuote: () => void; + onOpenSettings: () => void; }) { const busy = creatingOrder || savingAsTemplate || cloning || actionLoading !== ""; const showSaveAsTemplate = !isTemplate && hasItems; const showOrderAction = !isTemplate; const showClone = !isTemplate; + // Settings sits in its own separator-bounded group so the operator + // reads the kebab as: workflow actions / config / destructive action. + // It's available on every quote (real or template) — templates only + // need the follow-up date, but the rest of the form is harmless on + // them (the dialog handles the "is_template" branch internally). + const showSettings = true; return ( @@ -603,7 +612,16 @@ function MoreActionsMenu({ {cloning ? "Cloning…" : "Clone"} )} - {(showOrderAction || showSaveAsTemplate || !isTemplate) && } + {(showOrderAction || showSaveAsTemplate || !isTemplate) && showSettings && ( + + )} + {showSettings && ( + + + Quote Settings… + + )} + Delete Quote @@ -707,12 +725,13 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri const [savingSettings, setSavingSettings] = useState(false); const [savingAsTemplate, setSavingAsTemplate] = useState(false); const [creatingFromTemplate, setCreatingFromTemplate] = useState(false); - // Quote Settings card is collapsed by default — for the vast majority of - // quotes the page shouldn't show a date/validity editor until the user - // needs it. Auto-expands when the quote enters an attention-needing + // Quote Settings lives in the kebab menu (⋮ → "Quote Settings…"). + // Closed by default — for the vast majority of quotes the page + // shouldn't show the dialog until the operator asks. Auto-opens when + // the quote enters an attention-needing // state (follow_up / problem) so the operator can manage the follow-up // date right then and there. - const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); // Inline price-update dialog — null when closed, item when open. Each // item row has its own "Update price" button that sets this state. const [priceUpdateItem, setPriceUpdateItem] = useState(null); @@ -851,13 +870,15 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri // eslint-disable-next-line react-hooks/exhaustive-deps }, [quote?.id, quote?.updated]); - // Auto-expand the Quote Settings card when the quote enters an - // attention-needing state. We don't auto-close — if the operator - // collapsed it on purpose, respect that until the next status flip. + // Auto-open the Quote Settings dialog when the quote enters an + // attention-needing state (follow_up / problem). Same idea as the + // old auto-expand — the operator probably wants to update the + // follow-up date right then and there. With the dialog, "open" is + // the same shape as before: setting `settingsDialogOpen` to true. useEffect(() => { if (quote?.status === "follow_up" || quote?.status === "problem") { // eslint-disable-next-line react-hooks/set-state-in-effect - setSettingsOpen(true); + setSettingsDialogOpen(true); } }, [quote?.status]); @@ -1494,7 +1515,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri setSettingsOpen(true)} + onOpenSettings={() => setSettingsDialogOpen(true)} /> )} {quote.status === "mail_failed" && ( @@ -1552,6 +1573,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri onDelete={() => setDeleteOpen(true)} onViewOrder={() => linkedOrderId && router.push(`/orders/${linkedOrderId}`)} onViewQuote={() => setViewQuoteOpen(true)} + onOpenSettings={() => setSettingsDialogOpen(true)} /> @@ -1892,120 +1914,10 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri - {/* Settings / History / Email Status sit at the bottom of the - right column. They're reference / config surfaces so they - stay below the working content (items + outbound + linked). */} - - - {settingsOpen && ( - - {quote.is_template ? ( - // Templates don't have a specific date or validity — those - // only matter when a quote actually goes to a customer. - // Only the follow-up reminder is meaningful. -

- Templates don't carry a quote date or validity — those - are set on the real quote generated from this template. - Use the follow-up date below to remind yourself to issue - this template on a particular day. -

- ) : null} -
- {!quote.is_template && ( - <> -
- - setQuoteDate(e.target.value)} - className="h-9 text-sm" - /> -

- Date printed on the PDF. Defaults to today. -

-
-
- - setValidityDays(e.target.value)} - className="h-9 text-sm" - /> -

- Empty = 30 day default. Set 60 for 2 months, etc. -

-
- - )} -
- - setFollowUpDue(e.target.value)} - className="h-9 text-sm" - /> -

- Dashboard flags this quote when overdue. -

-
-
-
- -
-
- )} -
+ {/* History sits below Linked Orders — it's a reference panel, + the same accordion pattern as before. Quote Settings has + moved to the kebab menu ("Quote Settings…") so this card + doesn't need to exist inline anymore. */} + + + + + ); +} + /** * PriceUpdateDialog — inline editor for a single quote item's markup * and selling price. Cost is fixed (read-only — the supplier gave us