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:
@@ -95,7 +95,12 @@ export function AdminSectionsBrowser({ portSlug, groups }: AdminSectionsBrowserP
|
|||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredMatches.map((s) => (
|
{filteredMatches.map((s) => (
|
||||||
<SectionCard key={`${s.href}-${s.groupTitle}`} portSlug={portSlug} section={s} groupTitle={s.groupTitle} />
|
<SectionCard
|
||||||
|
key={`${s.href}-${s.groupTitle}`}
|
||||||
|
portSlug={portSlug}
|
||||||
|
section={s}
|
||||||
|
groupTitle={s.groupTitle}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +143,11 @@ function SectionCard({
|
|||||||
href={`/${portSlug}/admin/${section.href}` as any}
|
href={`/${portSlug}/admin/${section.href}` as any}
|
||||||
className="block group"
|
className="block group"
|
||||||
>
|
>
|
||||||
<Card className={cn('h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30')}>
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -368,7 +368,10 @@ function InterestLinkPicker({
|
|||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[320px] p-0" align="start">
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popper-anchor-width)] min-w-[320px] p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search prospects…" />
|
<CommandInput placeholder="Search prospects…" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
@@ -415,7 +418,9 @@ function InterestLinkPicker({
|
|||||||
>
|
>
|
||||||
{stageLabel(opt.pipelineStage)}
|
{stageLabel(opt.pipelineStage)}
|
||||||
</span>
|
</span>
|
||||||
{value === opt.id ? <Check className="h-3.5 w-3.5 text-muted-foreground" /> : null}
|
{value === opt.id ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -400,8 +400,9 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
|||||||
onChange={setAttachedYachtIds}
|
onChange={setAttachedYachtIds}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Adding a yacht transfers its ownership to this company (logged in the yacht's
|
Adding a yacht transfers its ownership to this company (logged in the
|
||||||
audit trail). Skip if you only want to associate without changing ownership.
|
yacht's audit trail). Skip if you only want to associate without changing
|
||||||
|
ownership.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -662,13 +663,19 @@ function EntityMultiPicker({
|
|||||||
size="sm"
|
size="sm"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn('w-full justify-between font-normal', !selectedIds.length && 'text-muted-foreground')}
|
className={cn(
|
||||||
|
'w-full justify-between font-normal',
|
||||||
|
!selectedIds.length && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{placeholder}
|
{placeholder}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0" align="start">
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput placeholder="Search…" onValueChange={setSearch} />
|
<CommandInput placeholder="Search…" onValueChange={setSearch} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
@@ -683,10 +690,7 @@ function EntityMultiPicker({
|
|||||||
onSelect={() => toggle(opt.value)}
|
onSelect={() => toggle(opt.value)}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn('mr-2 h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')}
|
||||||
'mr-2 h-4 w-4',
|
|
||||||
isSelected ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ function buildDiffLine(item: ActivityItem): string | null {
|
|||||||
if (keys.length === 0) return null;
|
if (keys.length === 0) return null;
|
||||||
return keys
|
return keys
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k], k)} → ${shortValue(newObj[k], k)}`)
|
.map(
|
||||||
|
(k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k], k)} → ${shortValue(newObj[k], k)}`,
|
||||||
|
)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
@@ -9,6 +9,7 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||||
import { DateRangePicker } from './date-range-picker';
|
import { DateRangePicker } from './date-range-picker';
|
||||||
|
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import type { DashboardWidget } from './widget-registry';
|
import type { DashboardWidget } from './widget-registry';
|
||||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
@@ -45,6 +46,12 @@ interface MeData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function timeOfDayGreeting(): string {
|
function timeOfDayGreeting(): string {
|
||||||
|
// new Date().getHours() uses the browser's local timezone, which the OS
|
||||||
|
// updates automatically on most modern devices when the user travels (laptop
|
||||||
|
// "set timezone automatically" on macOS, phones via cell-tower / GPS). So
|
||||||
|
// this reflects the user's physical location as long as their machine clock
|
||||||
|
// does. We still defer the compute to a useEffect on mount so the rendered
|
||||||
|
// HTML can never disagree with the server's clock during hydration.
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
if (hour < 5) return 'Up late';
|
if (hour < 5) return 'Up late';
|
||||||
if (hour < 12) return 'Good morning';
|
if (hour < 12) return 'Good morning';
|
||||||
@@ -73,9 +80,27 @@ export function DashboardShell() {
|
|||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
const firstName = me.data?.data?.profile?.firstName?.trim();
|
const firstName = me.data?.data?.profile?.firstName?.trim();
|
||||||
// Time-aware greeting line, falls back to a generic "Welcome back" when
|
|
||||||
// we don't know the user's first name yet (e.g. profile not filled out).
|
// Greeting word is computed in a useEffect so the rendered HTML can't lock
|
||||||
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
|
// 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. `clientGreeting` flips to the
|
||||||
|
// local-time-aware phrasing once the component has mounted.
|
||||||
|
const [clientGreeting, setClientGreeting] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setClientGreeting(timeOfDayGreeting());
|
||||||
|
// Re-evaluate hourly so a rep who leaves the dashboard open through a
|
||||||
|
// boundary (5am, noon, 6pm) doesn't keep stale text on screen.
|
||||||
|
const interval = window.setInterval(
|
||||||
|
() => setClientGreeting(timeOfDayGreeting()),
|
||||||
|
60 * 60_000,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const greeting = firstName
|
||||||
|
? `${clientGreeting ?? 'Welcome'}, ${firstName}`
|
||||||
|
: (clientGreeting ?? 'Welcome back');
|
||||||
|
|
||||||
// Use a partial query-key prefix (no range segment) for invalidations.
|
// Use a partial query-key prefix (no range segment) for invalidations.
|
||||||
// Reading: "any cached analytics result, regardless of range, please
|
// Reading: "any cached analytics result, regardless of range, please
|
||||||
@@ -108,6 +133,8 @@ export function DashboardShell() {
|
|||||||
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
|
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TimezoneDriftBanner />
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={greeting}
|
title={greeting}
|
||||||
eyebrow="Dashboard"
|
eyebrow="Dashboard"
|
||||||
|
|||||||
142
src/components/dashboard/timezone-drift-banner.tsx
Normal file
142
src/components/dashboard/timezone-drift-banner.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Clock, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { formatTimezoneLabel } from '@/lib/i18n/timezones';
|
||||||
|
|
||||||
|
const DISMISS_STORAGE_KEY = 'pn-crm.tz-drift.dismissed';
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
data: {
|
||||||
|
profile?: {
|
||||||
|
timezone?: string | null;
|
||||||
|
preferences?: { timezone?: string | null } | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects when the user's machine timezone (via Intl) doesn't match the
|
||||||
|
* timezone stored on their CRM profile and surfaces a small banner offering
|
||||||
|
* a one-tap update.
|
||||||
|
*
|
||||||
|
* Why a banner (not auto-update): the stored timezone is what the CRM uses
|
||||||
|
* to render due-dates, send daily-digest emails, and schedule reminders.
|
||||||
|
* Silently re-pinning it when a rep crosses a timezone for a single day
|
||||||
|
* would change their reminder firing time underfoot and surprise them. The
|
||||||
|
* banner gives them control: keep the home zone, or commit to the new one.
|
||||||
|
*
|
||||||
|
* Dismissal is sticky per browser via localStorage so the rep isn't nagged
|
||||||
|
* once they've decided. Clearing storage or signing in elsewhere re-asks.
|
||||||
|
*/
|
||||||
|
export function TimezoneDriftBanner() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [detected, setDetected] = useState<string | null>(null);
|
||||||
|
const [stored, setStored] = useState<string | null>(null);
|
||||||
|
const [profileLoaded, setProfileLoaded] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
// Read on mount: browser's resolved timezone (mirrors the OS setting) +
|
||||||
|
// the user's stored preference + any prior dismissal flag. All three
|
||||||
|
// are stable across the lifetime of the dashboard view; the banner
|
||||||
|
// makes a single comparison and either renders or doesn't.
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setDetected(Intl.DateTimeFormat().resolvedOptions().timeZone || null);
|
||||||
|
} catch {
|
||||||
|
setDetected(null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const flag = window.localStorage.getItem(DISMISS_STORAGE_KEY);
|
||||||
|
if (flag === 'true') setDismissed(true);
|
||||||
|
} catch {
|
||||||
|
// Private mode or quota — proceed without dismissal memory.
|
||||||
|
}
|
||||||
|
void apiFetch<MeResponse>('/api/v1/me')
|
||||||
|
.then((res) => {
|
||||||
|
const tz =
|
||||||
|
res.data.profile?.preferences?.timezone ?? res.data.profile?.timezone ?? null;
|
||||||
|
setStored(tz);
|
||||||
|
})
|
||||||
|
.catch(() => setStored(null))
|
||||||
|
.finally(() => setProfileLoaded(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (newTz: string) => {
|
||||||
|
await apiFetch('/api/v1/users/me/preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { timezone: newTz },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Timezone updated to ${formatTimezoneLabel(detected ?? '')}.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
|
setStored(detected);
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
setDismissed(true);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true');
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — we just don't persist the dismissal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render gates: don't show until the profile fetch resolves (avoid
|
||||||
|
// flashing the banner against a stale `stored=null`); don't show when
|
||||||
|
// the detected and stored zones agree or both are missing; don't show
|
||||||
|
// when the rep has dismissed it.
|
||||||
|
if (!profileLoaded) return null;
|
||||||
|
if (dismissed) return null;
|
||||||
|
if (!detected) return null;
|
||||||
|
if (!stored) return null;
|
||||||
|
if (detected === stored) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 sm:flex-nowrap">
|
||||||
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
|
<Clock className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium leading-tight">
|
||||||
|
You appear to be in{' '}
|
||||||
|
<span className="font-semibold">{formatTimezoneLabel(detected)}</span>.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-snug text-amber-900/80">
|
||||||
|
Your CRM is set to <span className="font-medium">{formatTimezoneLabel(stored)}</span>.
|
||||||
|
Reminders and the daily digest follow the CRM setting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-amber-300 bg-white hover:bg-amber-100"
|
||||||
|
onClick={() => mutation.mutate(detected)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Update to {detected.split('/').pop()?.replace(/_/g, ' ')}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label="Dismiss timezone reminder"
|
||||||
|
className="rounded-full p-1 text-amber-700 hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,8 +145,7 @@ export function EoiGenerateDialog({
|
|||||||
value: ctx.client.fullName,
|
value: ctx.client.fullName,
|
||||||
present: !!ctx.client.fullName,
|
present: !!ctx.client.fullName,
|
||||||
edit: {
|
edit: {
|
||||||
onSave: async (next: string | null) =>
|
onSave: async (next: string | null) => await patchClient({ fullName: next ?? '' }),
|
||||||
await patchClient({ fullName: next ?? '' }),
|
|
||||||
placeholder: 'Full legal name',
|
placeholder: 'Full legal name',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -194,8 +193,7 @@ export function EoiGenerateDialog({
|
|||||||
value: ctx.yacht?.name ?? null,
|
value: ctx.yacht?.name ?? null,
|
||||||
edit: ctx.yacht
|
edit: ctx.yacht
|
||||||
? {
|
? {
|
||||||
onSave: async (next: string | null) =>
|
onSave: async (next: string | null) => await patchYacht({ name: next ?? '' }),
|
||||||
await patchYacht({ name: next ?? '' }),
|
|
||||||
placeholder: 'Yacht name',
|
placeholder: 'Yacht name',
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -323,12 +321,7 @@ export function EoiGenerateDialog({
|
|||||||
</p>
|
</p>
|
||||||
<dl className="space-y-1.5">
|
<dl className="space-y-1.5">
|
||||||
{optional.map((row) => (
|
{optional.map((row) => (
|
||||||
<PreviewRow
|
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
|
||||||
key={row.key}
|
|
||||||
label={row.label}
|
|
||||||
value={row.value}
|
|
||||||
edit={row.edit}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -477,9 +470,7 @@ function PreviewRow({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1">
|
<span className="flex-1">{value ?? (missing ? 'Missing — required' : 'Not set')}</span>
|
||||||
{value ?? (missing ? 'Missing — required' : 'Not set')}
|
|
||||||
</span>
|
|
||||||
{edit ? (
|
{edit ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -193,195 +193,195 @@ export function InlineStagePicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(o) => {
|
onOpenChange={(o) => {
|
||||||
if (mutation.isPending) return;
|
if (mutation.isPending) return;
|
||||||
setOpen(o);
|
setOpen(o);
|
||||||
if (!o) cancelOverride();
|
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()}
|
|
||||||
>
|
>
|
||||||
{overrideTarget ? (
|
<PopoverTrigger asChild>
|
||||||
// Confirm-override view: only reached when the user picked a
|
<button
|
||||||
// stage that isn't a legal next step. Reason is optional but
|
type="button"
|
||||||
// strongly nudged for the audit log.
|
onClick={(e) => {
|
||||||
<div className="p-3 space-y-3">
|
if (stopPropagation) e.stopPropagation();
|
||||||
<div className="flex items-start gap-2">
|
}}
|
||||||
<AlertTriangle className="size-4 shrink-0 text-amber-600 mt-0.5" />
|
className={cn(
|
||||||
<div className="text-sm">
|
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
|
||||||
<p className="font-medium text-foreground">Override transition</p>
|
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
<p className="text-xs text-muted-foreground">
|
STAGE_BADGE[stage],
|
||||||
{STAGE_LABELS[stage]} → {STAGE_LABELS[overrideTarget]} isn't a standard next
|
className,
|
||||||
step. The change will be flagged in the audit log.
|
)}
|
||||||
</p>
|
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>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<label
|
// Default view: just the stage list. No upfront textarea —
|
||||||
htmlFor="stage-override-reason"
|
// earlier UX put a "Reason (optional)…" field at the top
|
||||||
className="text-xs font-medium text-muted-foreground"
|
// which read as visually noisy for the >90% of changes that
|
||||||
>
|
// are normal transitions and never get a reason attached.
|
||||||
Reason (optional but recommended)
|
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||||
</label>
|
{PIPELINE_STAGES.map((s) => {
|
||||||
<Textarea
|
const isCurrent = s === stage;
|
||||||
id="stage-override-reason"
|
const isPending = pendingStage === s && mutation.isPending;
|
||||||
value={overrideReason}
|
const isOverride = s !== stage && !canTransitionStage(stage, s);
|
||||||
onChange={(e) => setOverrideReason(e.target.value)}
|
const blockedByPermission = isOverride && !canOverride;
|
||||||
placeholder="e.g. Skipping EOI, client signed contract directly"
|
return (
|
||||||
rows={2}
|
<li key={s}>
|
||||||
className="mt-1 text-sm"
|
<button
|
||||||
disabled={mutation.isPending}
|
type="button"
|
||||||
autoFocus
|
role="option"
|
||||||
/>
|
aria-selected={isCurrent}
|
||||||
</div>
|
disabled={mutation.isPending || blockedByPermission}
|
||||||
<div className="flex items-center justify-between gap-2">
|
onClick={() => pick(s)}
|
||||||
<Button
|
title={
|
||||||
type="button"
|
blockedByPermission
|
||||||
variant="ghost"
|
? `Override required (you don't have permission)`
|
||||||
size="sm"
|
: isOverride
|
||||||
onClick={cancelOverride}
|
? 'Non-standard transition — confirm step required'
|
||||||
disabled={mutation.isPending}
|
: undefined
|
||||||
className="gap-1"
|
}
|
||||||
>
|
className={cn(
|
||||||
<ChevronLeft className="size-3.5" />
|
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||||
Back
|
'transition-colors hover:bg-muted/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
</Button>
|
isCurrent && 'font-medium',
|
||||||
<Button
|
)}
|
||||||
type="button"
|
>
|
||||||
size="sm"
|
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||||
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
|
|
||||||
the picker into a visual scan rather than just a list. */}
|
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
|
<span
|
||||||
className="text-[10px] uppercase tracking-wide text-amber-600"
|
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||||
title="Override required"
|
aria-hidden
|
||||||
>
|
/>
|
||||||
⚑
|
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||||
</span>
|
{isPending ? (
|
||||||
) : null}
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
</button>
|
) : isCurrent ? (
|
||||||
</li>
|
<Check className="size-3.5 text-muted-foreground" />
|
||||||
);
|
) : isOverride && canOverride ? (
|
||||||
})}
|
<span
|
||||||
</ul>
|
className="text-[10px] uppercase tracking-wide text-amber-600"
|
||||||
)}
|
title="Override required"
|
||||||
</PopoverContent>
|
>
|
||||||
</Popover>
|
⚑
|
||||||
<AlertDialog
|
</span>
|
||||||
open={!!openConfirmTarget}
|
) : null}
|
||||||
onOpenChange={(o) => {
|
</button>
|
||||||
if (!o && !unlinking) setOpenConfirmTarget(null);
|
</li>
|
||||||
}}
|
);
|
||||||
>
|
})}
|
||||||
<AlertDialogContent>
|
</ul>
|
||||||
<AlertDialogHeader>
|
)}
|
||||||
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
|
</PopoverContent>
|
||||||
<AlertDialogDescription>
|
</Popover>
|
||||||
This interest has {linkedBerthCount} linked{' '}
|
<AlertDialog
|
||||||
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
|
open={!!openConfirmTarget}
|
||||||
usually means restarting the lead — keeping the berth links would leave them showing as
|
onOpenChange={(o) => {
|
||||||
under offer on the public map for a deal that's no longer in progress.
|
if (!o && !unlinking) setOpenConfirmTarget(null);
|
||||||
</AlertDialogDescription>
|
}}
|
||||||
</AlertDialogHeader>
|
>
|
||||||
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<AlertDialogContent>
|
||||||
<AlertDialogCancel disabled={unlinking}>Cancel</AlertDialogCancel>
|
<AlertDialogHeader>
|
||||||
<Button
|
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
|
||||||
type="button"
|
<AlertDialogDescription>
|
||||||
variant="outline"
|
This interest has {linkedBerthCount} linked{' '}
|
||||||
disabled={unlinking}
|
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
|
||||||
onClick={() => openConfirmTarget && keepBerthsAndOpen(openConfirmTarget)}
|
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.
|
||||||
Keep berth links
|
</AlertDialogDescription>
|
||||||
</Button>
|
</AlertDialogHeader>
|
||||||
<AlertDialogAction
|
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
disabled={unlinking}
|
<AlertDialogCancel disabled={unlinking}>Cancel</AlertDialogCancel>
|
||||||
onClick={(e) => {
|
<Button
|
||||||
e.preventDefault();
|
type="button"
|
||||||
if (openConfirmTarget) void unlinkAllAndOpen(openConfirmTarget);
|
variant="outline"
|
||||||
}}
|
disabled={unlinking}
|
||||||
>
|
onClick={() => openConfirmTarget && keepBerthsAndOpen(openConfirmTarget)}
|
||||||
{unlinking && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
>
|
||||||
Unlink {linkedBerthCount} {linkedBerthCount === 1 ? 'berth' : 'berths'} & reset
|
Keep berth links
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
<AlertDialogAction
|
||||||
</AlertDialogContent>
|
disabled={unlinking}
|
||||||
</AlertDialog>
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,9 +129,7 @@ export function Topbar({ ports, user }: TopbarProps) {
|
|||||||
straight at the new path. The Reminders section's
|
straight at the new path. The Reminders section's
|
||||||
useCreateFromUrl handler still picks up ?create=1. */}
|
useCreateFromUrl handler still picks up ?create=1. */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() => router.push(`${base}/inbox?create=1#reminders` as unknown as Route)}
|
||||||
router.push(`${base}/inbox?create=1#reminders` as unknown as Route)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
New Reminder
|
New Reminder
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
Reference in New Issue
Block a user