Wave A (Interest+EOI form quick wins): - Auto-select yacht after inline-create from interest form - EOI generate dialog: "View EOI" action toast - Interest form berth picker: formatBerthRange compact label - Remove "Generate EOI" button from Documents tab (clean removal) - Interest auto-assign: only sales_agent/sales_manager auto-claim ownership on create (explicit role check via user_port_roles join) - LinkedBerthRowItem dims: drop "D" suffix + "L × W" format - ExternalEoiUploadDialog: prefillSignatories prop threaded from active EOI signers - EOI signature progress on Overview milestone card footer Wave B (a11y + i18n sweeps): - aria-live on supplemental-info error state - text-[10px] -> text-xs in client-pipeline-summary - Currency formatter: locale default removed (Intl uses runtime) - en-US/en-GB hardcoded toLocaleString swept across 13 components Wave C (Primary berth always in EOI bundle): - Service guard strengthened on update path - Migration 0083 backfills historical primary rows Wave D (Onboarding super_admin discoverability): - /api/v1/admin/onboarding/status endpoint + shared service - Topbar OnboardingBanner (super_admin, session-dismissible) - OnboardingTile dashboard widget (rail group, self-hides at 100%) - Celebration toast + invalidate of shared status on last tick Wave E (Branded post-completion email idempotency): - Verified handleDocumentCompleted already owns the email fan-out - Added regression test for the polling path + idempotency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.6 KiB
TypeScript
180 lines
6.6 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { Calendar } from 'lucide-react';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import { cn } from '@/lib/utils';
|
||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||
|
||
interface DateRangePickerProps {
|
||
value: DateRange;
|
||
onChange: (next: DateRange) => void;
|
||
className?: string;
|
||
}
|
||
|
||
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
|
||
{ value: 'today', label: 'Today' },
|
||
{ value: '7d', label: '7d' },
|
||
{ value: '30d', label: '30d' },
|
||
{ value: '90d', label: '90d' },
|
||
];
|
||
|
||
/**
|
||
* Format a custom range as a compact button label, e.g. "Apr 14 – May 4".
|
||
* Same year omits the year on both sides; different years includes both.
|
||
*/
|
||
function formatCustom(range: { from: string; to: string }): string {
|
||
const from = new Date(`${range.from}T00:00:00.000Z`);
|
||
const to = new Date(`${range.to}T00:00:00.000Z`);
|
||
const sameYear = from.getUTCFullYear() === to.getUTCFullYear();
|
||
const fmt: Intl.DateTimeFormatOptions = sameYear
|
||
? { month: 'short', day: 'numeric', timeZone: 'UTC' }
|
||
: { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' };
|
||
return `${from.toLocaleDateString(undefined, fmt)} – ${to.toLocaleDateString(undefined, fmt)}`;
|
||
}
|
||
|
||
/**
|
||
* Today's date as a YYYY-MM-DD string in UTC. Used as the default for the
|
||
* "to" picker so users can't accidentally pick a future date by leaving the
|
||
* field empty.
|
||
*/
|
||
function todayIso(): string {
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const isCustom = isCustomRange(value);
|
||
|
||
// Local state for the popover form. Seeded from the current value if it's
|
||
// already custom, otherwise defaults to a 14-day window ending today.
|
||
const [draftFrom, setDraftFrom] = useState<string>(() => {
|
||
if (isCustom) return value.from;
|
||
const d = new Date();
|
||
d.setUTCDate(d.getUTCDate() - 14);
|
||
return d.toISOString().slice(0, 10);
|
||
});
|
||
const [draftTo, setDraftTo] = useState<string>(() => (isCustom ? value.to : todayIso()));
|
||
|
||
const today = todayIso();
|
||
const draftValid =
|
||
draftFrom !== '' &&
|
||
draftTo !== '' &&
|
||
draftFrom <= draftTo &&
|
||
draftFrom <= today &&
|
||
draftTo <= today;
|
||
|
||
function applyCustom() {
|
||
if (!draftValid) return;
|
||
onChange({ kind: 'custom', from: draftFrom, to: draftTo });
|
||
setOpen(false);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
role="tablist"
|
||
aria-label="Date range"
|
||
className={cn(
|
||
'inline-flex items-center rounded-lg border border-border bg-muted/40 p-0.5 shadow-xs',
|
||
className,
|
||
)}
|
||
>
|
||
{PRESETS.map((opt) => {
|
||
const active = !isCustom && opt.value === value;
|
||
return (
|
||
<Button
|
||
key={opt.value}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => onChange(opt.value)}
|
||
className={cn(
|
||
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring',
|
||
active
|
||
? 'bg-background text-foreground shadow-sm'
|
||
: 'text-muted-foreground hover:text-foreground',
|
||
)}
|
||
data-testid={`range-${opt.value}`}
|
||
>
|
||
{opt.label}
|
||
</Button>
|
||
);
|
||
})}
|
||
|
||
{/* Custom range - popover with two date inputs and an Apply button */}
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={isCustom}
|
||
variant="ghost"
|
||
size="sm"
|
||
className={cn(
|
||
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring inline-flex items-center gap-1',
|
||
isCustom
|
||
? 'bg-background text-foreground shadow-sm'
|
||
: 'text-muted-foreground hover:text-foreground',
|
||
)}
|
||
data-testid="range-custom"
|
||
>
|
||
<Calendar className="h-3 w-3" aria-hidden />
|
||
{isCustom ? formatCustom(value) : 'Custom'}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="end"
|
||
// min() caps the popover at "viewport minus 16px" on narrow
|
||
// phones so it never overflows; otherwise sits at a compact
|
||
// 260px. Date inputs inside use w-auto so iOS's intrinsic
|
||
// date-input width (which ignores parent constraints) sizes
|
||
// to its own content rather than overflowing.
|
||
className="w-[min(260px,calc(100vw-1rem))] p-3"
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||
Custom range
|
||
</div>
|
||
<label className="block text-xs">
|
||
<span className="block text-muted-foreground mb-1">From</span>
|
||
<input
|
||
type="date"
|
||
value={draftFrom}
|
||
/* `max` capped at min(draftTo, today). Without the today
|
||
cap, users could pick a future From, end up with an
|
||
empty result, and not understand why. */
|
||
max={draftTo && draftTo < today ? draftTo : today}
|
||
onChange={(e) => setDraftFrom(e.target.value)}
|
||
className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-hidden focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||
/>
|
||
</label>
|
||
<label className="block text-xs">
|
||
<span className="block text-muted-foreground mb-1">To</span>
|
||
<input
|
||
type="date"
|
||
value={draftTo}
|
||
min={draftFrom || undefined}
|
||
max={today}
|
||
onChange={(e) => setDraftTo(e.target.value)}
|
||
className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-hidden focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||
/>
|
||
</label>
|
||
<div className="flex items-center justify-end gap-2 pt-1">
|
||
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
||
Cancel
|
||
</Button>
|
||
<Button size="sm" onClick={applyCustom} disabled={!draftValid}>
|
||
Apply
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
);
|
||
}
|