Files
pn-new-crm/src/components/dashboard/date-range-picker.tsx
Matt 14ae41d0fa feat(uat-b1): ship Wave A-E of Bucket 1 audit findings
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>
2026-05-25 03:40:37 +02:00

180 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}