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:
2026-05-12 15:48:51 +02:00
parent 04a594963f
commit 0ab7055cf1
9 changed files with 395 additions and 217 deletions

View File

@@ -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&apos;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&apos;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&apos;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&apos;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>
</>
);
}