refactor(quotes detail): move Quote Settings into kebab menu as dialog

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).
This commit is contained in:
SJCoetz 2026-06-29 23:04:03 +02:00
parent 4e085a3f69
commit 9b2b0897a1

View file

@ -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 (
<DropdownMenu>
@ -603,7 +612,16 @@ function MoreActionsMenu({
{cloning ? "Cloning…" : "Clone"}
</DropdownMenuItem>
)}
{(showOrderAction || showSaveAsTemplate || !isTemplate) && <DropdownMenuSeparator />}
{(showOrderAction || showSaveAsTemplate || !isTemplate) && showSettings && (
<DropdownMenuSeparator />
)}
{showSettings && (
<DropdownMenuItem onClick={onOpenSettings}>
<Settings className="h-4 w-4" />
Quote Settings…
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete} disabled={busy}>
<Trash2 className="h-4 w-4" />
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<QuoteItem | null>(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
<FollowUpChip
iso={quote.follow_up_due}
status={quote.status}
onOpenSettings={() => 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)}
/>
</div>
</div>
@ -1892,120 +1914,10 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
</CardContent>
</Card>
{/* 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). */}
<Card>
<button
type="button"
onClick={() => setSettingsOpen((s) => !s)}
className="w-full text-left px-6 py-4 flex items-center justify-between gap-3 cursor-pointer hover:bg-accent/30 transition-colors rounded-xl"
aria-expanded={settingsOpen}
aria-controls="quote-settings-body"
>
<span className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
Quote Settings
{!settingsOpen && (quote.follow_up_due || quote.validity_days != null) && (
<span className="text-xs font-normal text-muted-foreground inline-flex items-center gap-1">
{quote.follow_up_due && (
<>
<Calendar className="h-3 w-3" />
Follow up {formatDate(quote.follow_up_due)}
</>
)}
{quote.follow_up_due && quote.validity_days != null && <span> · </span>}
{quote.validity_days != null && (
<span>{quote.validity_days}d validity</span>
)}
</span>
)}
</span>
{settingsOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</button>
{settingsOpen && (
<CardContent id="quote-settings-body" className="space-y-4 pt-0">
{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.
<p className="text-sm text-muted-foreground">
Templates don&apos;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.
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-3">
{!quote.is_template && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Quote date
</label>
<Input
type="date"
value={quoteDate}
onChange={(e) => setQuoteDate(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
Date printed on the PDF. Defaults to today.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Valid for (days)
</label>
<Input
type="number"
min={1}
step={1}
placeholder="30"
value={validityDays}
onChange={(e) => setValidityDays(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
Empty = 30 day default. Set 60 for 2 months, etc.
</p>
</div>
</>
)}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Follow up by
</label>
<Input
type="date"
value={followUpDue}
onChange={(e) => setFollowUpDue(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
Dashboard flags this quote when overdue.
</p>
</div>
</div>
<div className="flex justify-end pt-1">
<Button
size="sm"
onClick={handleSaveSettings}
disabled={savingSettings}
>
{savingSettings ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : null}
Save settings
</Button>
</div>
</CardContent>
)}
</Card>
{/* 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. */}
<Card>
<button
@ -2187,6 +2099,20 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
onConfirm={handleDelete}
/>
<QuoteSettingsDialog
open={settingsDialogOpen}
saving={savingSettings}
isTemplate={!!quote.is_template}
quoteDate={quoteDate}
validityDays={validityDays}
followUpDue={followUpDue}
onClose={() => setSettingsDialogOpen(false)}
onSave={handleSaveSettings}
onQuoteDateChange={setQuoteDate}
onValidityDaysChange={setValidityDays}
onFollowUpDueChange={setFollowUpDue}
/>
<RewriteQuoteModal
open={rewriteOpen}
onOpenChange={setRewriteOpen}
@ -2256,6 +2182,138 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
);
}
/**
* QuoteSettingsDialog — edit the quote's `quote_date`, `validity_days`,
* and `follow_up_due` from one focused modal. Lives in the kebab menu
* (the ⋮ → "Quote Settings…" item) so it doesn't crowd the right column.
*
* The form fields are owned by the page (so they survive the dialog
* closing + reopening without losing in-progress edits); the dialog just
* renders them and calls `onSave` when the operator confirms. `onSave`
* is the page's `handleSaveSettings` — same patch logic as before, just
* moved out of an inline accordion into a dialog.
*
* The FollowUpChip in the page header also opens this dialog via the
* `onOpenSettings` callback wired through MoreActionsMenu, so the
* follow-up quick-edit path still works.
*/
function QuoteSettingsDialog({
open,
saving,
isTemplate,
quoteDate,
validityDays,
followUpDue,
onClose,
onSave,
onQuoteDateChange,
onValidityDaysChange,
onFollowUpDueChange,
}: {
open: boolean;
saving: boolean;
isTemplate: boolean;
quoteDate: string;
validityDays: string;
followUpDue: string;
onClose: () => void;
onSave: () => void | Promise<void>;
onQuoteDateChange: (v: string) => void;
onValidityDaysChange: (v: string) => void;
onFollowUpDueChange: (v: string) => void;
}) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Quote Settings
</DialogTitle>
<DialogDescription>
Date, validity, and follow-up reminder for this quote.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{isTemplate ? (
// Templates don't have a customer-facing date or validity —
// those only matter once the template becomes a real quote.
// Only the follow-up reminder is meaningful here.
<p className="text-sm text-muted-foreground">
Templates don&apos;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.
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-3">
{!isTemplate && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Quote date
</label>
<Input
type="date"
value={quoteDate}
onChange={(e) => onQuoteDateChange(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
Date printed on the PDF. Defaults to today.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Valid for (days)
</label>
<Input
type="number"
min={1}
step={1}
placeholder="30"
value={validityDays}
onChange={(e) => onValidityDaysChange(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
Empty = 30 day default. Set 60 for 2 months, etc.
</p>
</div>
</>
)}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Follow up by
</label>
<Input
type="date"
value={followUpDue}
onChange={(e) => onFollowUpDueChange(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-xs text-muted-foreground">
Dashboard flags this quote when overdue.
</p>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : null}
Save settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* PriceUpdateDialog — inline editor for a single quote item's markup
* and selling price. Cost is fixed (read-only — the supplier gave us