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:
parent
4e085a3f69
commit
9b2b0897a1
1 changed files with 184 additions and 126 deletions
|
|
@ -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'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'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
|
||||
|
|
|
|||
Loading…
Reference in a new issue