feat(dashboard): local-time greeting + timezone-drift banner
Greeting - The "Good morning / afternoon / evening, Matt" line now derives from the browser's local time, computed inside a useEffect so the rendered HTML can't lock to the server's clock during hydration. Until the effect fires, the header reads "Welcome" — a neutral phrase that's correct at every hour and never produces a hydration warning. The phrase re-evaluates hourly so a rep leaving the dashboard open across a boundary (5am, noon, 6pm) doesn't keep stale text on screen. Timezone-drift banner - New <TimezoneDriftBanner> on the dashboard surfaces when the browser's resolved timezone (Intl.DateTimeFormat().resolvedOptions().timeZone, which follows the OS — and the OS usually follows physical location) doesn't match the user's stored CRM preference. The rep gets a one-tap "Update to Tokyo" button and a dismiss × that's sticky per browser via localStorage. - Why a banner rather than auto-update: the stored timezone drives reminder firing time, daily-digest delivery, and due-date rendering. Silently pinning it to a transient travel location would shift their reminder schedule underfoot. The banner gives them control. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,195 +193,195 @@ export function InlineStagePicker({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (mutation.isPending) return;
|
||||
setOpen(o);
|
||||
if (!o) cancelOverride();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
|
||||
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
STAGE_BADGE[stage],
|
||||
className,
|
||||
)}
|
||||
aria-label={`Pipeline stage: ${STAGE_LABELS[stage]}. Click to change.`}
|
||||
>
|
||||
<span>{STAGE_LABELS[stage]}</span>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : showChevron ? (
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-72 p-0"
|
||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (mutation.isPending) return;
|
||||
setOpen(o);
|
||||
if (!o) cancelOverride();
|
||||
}}
|
||||
>
|
||||
{overrideTarget ? (
|
||||
// Confirm-override view: only reached when the user picked a
|
||||
// stage that isn't a legal next step. Reason is optional but
|
||||
// strongly nudged for the audit log.
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-600 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-foreground">Override transition</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{STAGE_LABELS[stage]} → {STAGE_LABELS[overrideTarget]} isn't a standard next
|
||||
step. The change will be flagged in the audit log.
|
||||
</p>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
|
||||
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
STAGE_BADGE[stage],
|
||||
className,
|
||||
)}
|
||||
aria-label={`Pipeline stage: ${STAGE_LABELS[stage]}. Click to change.`}
|
||||
>
|
||||
<span>{STAGE_LABELS[stage]}</span>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : showChevron ? (
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-72 p-0"
|
||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||
>
|
||||
{overrideTarget ? (
|
||||
// Confirm-override view: only reached when the user picked a
|
||||
// stage that isn't a legal next step. Reason is optional but
|
||||
// strongly nudged for the audit log.
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-600 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-foreground">Override transition</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{STAGE_LABELS[stage]} → {STAGE_LABELS[overrideTarget]} isn't a standard
|
||||
next step. The change will be flagged in the audit log.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="stage-override-reason"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Reason (optional but recommended)
|
||||
</label>
|
||||
<Textarea
|
||||
id="stage-override-reason"
|
||||
value={overrideReason}
|
||||
onChange={(e) => setOverrideReason(e.target.value)}
|
||||
placeholder="e.g. Skipping EOI, client signed contract directly"
|
||||
rows={2}
|
||||
className="mt-1 text-sm"
|
||||
disabled={mutation.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelOverride}
|
||||
disabled={mutation.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={commitOverride}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="size-3.5 animate-spin mr-1" />}
|
||||
Confirm override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="stage-override-reason"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Reason (optional but recommended)
|
||||
</label>
|
||||
<Textarea
|
||||
id="stage-override-reason"
|
||||
value={overrideReason}
|
||||
onChange={(e) => setOverrideReason(e.target.value)}
|
||||
placeholder="e.g. Skipping EOI, client signed contract directly"
|
||||
rows={2}
|
||||
className="mt-1 text-sm"
|
||||
disabled={mutation.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelOverride}
|
||||
disabled={mutation.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={commitOverride}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="size-3.5 animate-spin mr-1" />}
|
||||
Confirm override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Default view: just the stage list. No upfront textarea —
|
||||
// earlier UX put a "Reason (optional)…" field at the top
|
||||
// which read as visually noisy for the >90% of changes that
|
||||
// are normal transitions and never get a reason attached.
|
||||
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const isCurrent = s === stage;
|
||||
const isPending = pendingStage === s && mutation.isPending;
|
||||
const isOverride = s !== stage && !canTransitionStage(stage, s);
|
||||
const blockedByPermission = isOverride && !canOverride;
|
||||
return (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
disabled={mutation.isPending || blockedByPermission}
|
||||
onClick={() => pick(s)}
|
||||
title={
|
||||
blockedByPermission
|
||||
? `Override required (you don't have permission)`
|
||||
: isOverride
|
||||
? 'Non-standard transition — confirm step required'
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||
'transition-colors hover:bg-muted/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
isCurrent && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||
) : (
|
||||
// Default view: just the stage list. No upfront textarea —
|
||||
// earlier UX put a "Reason (optional)…" field at the top
|
||||
// which read as visually noisy for the >90% of changes that
|
||||
// are normal transitions and never get a reason attached.
|
||||
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const isCurrent = s === stage;
|
||||
const isPending = pendingStage === s && mutation.isPending;
|
||||
const isOverride = s !== stage && !canTransitionStage(stage, s);
|
||||
const blockedByPermission = isOverride && !canOverride;
|
||||
return (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
disabled={mutation.isPending || blockedByPermission}
|
||||
onClick={() => pick(s)}
|
||||
title={
|
||||
blockedByPermission
|
||||
? `Override required (you don't have permission)`
|
||||
: isOverride
|
||||
? 'Non-standard transition — confirm step required'
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||
'transition-colors hover:bg-muted/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
isCurrent && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||
the picker into a visual scan rather than just a list. */}
|
||||
<span
|
||||
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : isOverride && canOverride ? (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wide text-amber-600"
|
||||
title="Override required"
|
||||
>
|
||||
⚑
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<AlertDialog
|
||||
open={!!openConfirmTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o && !unlinking) setOpenConfirmTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This interest has {linkedBerthCount} linked{' '}
|
||||
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
|
||||
usually means restarting the lead — keeping the berth links would leave them showing as
|
||||
under offer on the public map for a deal that's no longer in progress.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<AlertDialogCancel disabled={unlinking}>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={unlinking}
|
||||
onClick={() => openConfirmTarget && keepBerthsAndOpen(openConfirmTarget)}
|
||||
>
|
||||
Keep berth links
|
||||
</Button>
|
||||
<AlertDialogAction
|
||||
disabled={unlinking}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (openConfirmTarget) void unlinkAllAndOpen(openConfirmTarget);
|
||||
}}
|
||||
>
|
||||
{unlinking && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
||||
Unlink {linkedBerthCount} {linkedBerthCount === 1 ? 'berth' : 'berths'} & reset
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : isOverride && canOverride ? (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wide text-amber-600"
|
||||
title="Override required"
|
||||
>
|
||||
⚑
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<AlertDialog
|
||||
open={!!openConfirmTarget}
|
||||
onOpenChange={(o) => {
|
||||
if (!o && !unlinking) setOpenConfirmTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This interest has {linkedBerthCount} linked{' '}
|
||||
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
|
||||
usually means restarting the lead — keeping the berth links would leave them showing
|
||||
as under offer on the public map for a deal that's no longer in progress.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<AlertDialogCancel disabled={unlinking}>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={unlinking}
|
||||
onClick={() => openConfirmTarget && keepBerthsAndOpen(openConfirmTarget)}
|
||||
>
|
||||
Keep berth links
|
||||
</Button>
|
||||
<AlertDialogAction
|
||||
disabled={unlinking}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (openConfirmTarget) void unlinkAllAndOpen(openConfirmTarget);
|
||||
}}
|
||||
>
|
||||
{unlinking && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
||||
Unlink {linkedBerthCount} {linkedBerthCount === 1 ? 'berth' : 'berths'} & reset
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user