From 8f42940c52ae98ca33baff1005a7684eec9030d4 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 17:10:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch-3):=20wave-1=20primitives=20?= =?UTF-8?q?=E2=80=94=20DatePicker,=20DateTimePicker,=20FileInputButton,=20?= =?UTF-8?q?ColumnPicker=20hideAll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the foundational primitives that subsequent waves depend on. None of these introduce new deps — date-fns, react-day-picker, and shadcn Calendar were already in the tree. - `` and `` in src/components/ui — desktop popover wrapping the existing shadcn Calendar (caption-dropdown nav so reps can jump months/years for the SkipAheadBanner backfill UX), mobile native input via useIsMobile. Drop-in for `` / ``. - `` in src/components/ui — styled Button + hidden input, replaces browser-default file picker UI. Most queued sweep sites already used the hidden-input + Button-trigger pattern; the primitive lands for any new caller plus consistent filename display + clear button. - ColumnPicker `hideAll()` footer item — symmetric to existing `showAll()`, with the same visibility gate. Lands platform-wide via the shared component. - Migrated highest-leverage call sites to the new primitives: * MilestoneAdvanceButton (backfill UX) * Reminder form (datetime-local → DateTimePicker) * Snooze dialog (datetime-local → DateTimePicker) * External-EOI upload dialog (date + file picker) * Payments section (received-on date) - Remaining 15+ date-input call sites parked for a follow-up sweep — several use react-hook-form `register` patterns that need careful migration to the new controlled-value contract. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../interests/external-eoi-upload-dialog.tsx | 24 ++-- src/components/interests/interest-tabs.tsx | 11 +- src/components/interests/payments-section.tsx | 9 +- src/components/reminders/reminder-form.tsx | 8 +- src/components/reminders/snooze-dialog.tsx | 17 +-- src/components/shared/column-picker.tsx | 22 ++- src/components/ui/date-picker.tsx | 136 ++++++++++++++++++ src/components/ui/date-time-picker.tsx | 129 +++++++++++++++++ src/components/ui/file-input-button.tsx | 127 ++++++++++++++++ 9 files changed, 443 insertions(+), 40 deletions(-) create mode 100644 src/components/ui/date-picker.tsx create mode 100644 src/components/ui/date-time-picker.tsx create mode 100644 src/components/ui/file-input-button.tsx diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index 2ddd66fe..04db577b 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -6,6 +6,8 @@ import { Loader2, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { FileInputButton } from '@/components/ui/file-input-button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -132,12 +134,13 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
- setFile(e.target.files?.[0] ?? null)} - className="mt-1" - /> +
+ setFile(files[0] ?? null)} + /> +
@@ -153,12 +156,9 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
- setSignedAt(e.target.value)} - className="mt-1" - /> +
+ +
diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 6a289193..093f223a 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -11,7 +11,7 @@ import { parsePhone } from '@/lib/i18n/phone'; import type { DetailTab } from '@/components/shared/detail-layout'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { NotesList } from '@/components/shared/notes-list'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; @@ -315,13 +315,12 @@ function MilestoneAdvanceButton({ - setDate(e.target.value)} - className="h-9" + toDate={new Date()} + onChange={setDate} + placeholder="Pick a date" />

Defaults to today — back-date if the event happened earlier. diff --git a/src/components/interests/payments-section.tsx b/src/components/interests/payments-section.tsx index 2bd12504..e98ed027 100644 --- a/src/components/interests/payments-section.tsx +++ b/src/components/interests/payments-section.tsx @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Trash2, Receipt } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { @@ -325,12 +326,12 @@ function RecordPaymentSheet({

- setReceivedAt(e.target.value)} - required + onChange={setReceivedAt} + toDate={new Date()} + placeholder="Pick a date" />
diff --git a/src/components/reminders/reminder-form.tsx b/src/components/reminders/reminder-form.tsx index c2d02b09..cb64d01d 100644 --- a/src/components/reminders/reminder-form.tsx +++ b/src/components/reminders/reminder-form.tsx @@ -3,6 +3,7 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; +import { DateTimePicker } from '@/components/ui/date-time-picker'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -304,12 +305,11 @@ function ReminderFormBody({ ))}
- setDueAt(e.target.value)} - required + onChange={setDueAt} + placeholder="Pick a due date and time" />
diff --git a/src/components/reminders/snooze-dialog.tsx b/src/components/reminders/snooze-dialog.tsx index d6b52ac9..612ddd50 100644 --- a/src/components/reminders/snooze-dialog.tsx +++ b/src/components/reminders/snooze-dialog.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { DateTimePicker } from '@/components/ui/date-time-picker'; import { Label } from '@/components/ui/label'; import { Dialog, @@ -91,13 +91,14 @@ export function SnoozeDialog({ open, onOpenChange, reminderId, onSuccess }: Snoo
- setCustomDate(e.target.value)} - className="flex-1" - /> +
+ +
+ + + { + onChange(toIso(d)); + setOpen(false); + }} + captionLayout="dropdown" + startMonth={fromDate} + endMonth={toDate} + autoFocus + /> + + + ); +} diff --git a/src/components/ui/date-time-picker.tsx b/src/components/ui/date-time-picker.tsx new file mode 100644 index 00000000..e35cf765 --- /dev/null +++ b/src/components/ui/date-time-picker.tsx @@ -0,0 +1,129 @@ +'use client'; + +import * as React from 'react'; +import { Calendar as CalendarIcon, Clock } from 'lucide-react'; +import { format } from 'date-fns'; + +import { Button, type ButtonProps } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { cn } from '@/lib/utils'; + +export interface DateTimePickerProps { + /** datetime-local string ("YYYY-MM-DDTHH:mm") or empty for unset. */ + value?: string; + onChange: (next: string) => void; + placeholder?: string; + disabled?: boolean; + fromDate?: Date; + toDate?: Date; + className?: string; + title?: string; + size?: ButtonProps['size']; + id?: string; + name?: string; + forceVariant?: 'desktop' | 'mobile'; +} + +function parseDateTime(value: string | undefined): { date?: Date; time: string } { + if (!value) return { time: '' }; + // `2026-05-21T14:30` is the datetime-local shape Input emits. + const [datePart, timePart = ''] = value.split('T'); + const date = datePart ? new Date(`${datePart}T00:00:00`) : undefined; + if (date && Number.isNaN(date.getTime())) return { time: timePart }; + return { date, time: timePart }; +} + +function joinDateTime(date: Date | undefined, time: string): string { + if (!date) return ''; + const datePart = format(date, 'yyyy-MM-dd'); + const timePart = time && /^\d{2}:\d{2}/.test(time) ? time.slice(0, 5) : '00:00'; + return `${datePart}T${timePart}`; +} + +/** + * Cross-platform datetime picker. Desktop: shadcn Popover + Calendar + + * native time input in the footer (the shadcn-canonical pattern). + * Mobile: native `` so the OS picker takes + * over. Drop-in for ``. + */ +export function DateTimePicker({ + value, + onChange, + placeholder, + disabled, + fromDate, + toDate, + className, + title, + size = 'default', + id, + name, + forceVariant, +}: DateTimePickerProps) { + const isMobile = useIsMobile(); + const variant = forceVariant ?? (isMobile ? 'mobile' : 'desktop'); + const [open, setOpen] = React.useState(false); + const { date, time } = parseDateTime(value); + const displayTime = time || '09:00'; + + if (variant === 'mobile') { + return ( + onChange(e.target.value)} + disabled={disabled} + className={className} + title={title} + /> + ); + } + + return ( + + + + + + onChange(joinDateTime(d, displayTime))} + captionLayout="dropdown" + startMonth={fromDate} + endMonth={toDate} + autoFocus + /> +
+ + onChange(joinDateTime(date, e.target.value))} + className="h-9 w-32 [&::-webkit-calendar-picker-indicator]:hidden" + /> +
+
+
+ ); +} diff --git a/src/components/ui/file-input-button.tsx b/src/components/ui/file-input-button.tsx new file mode 100644 index 00000000..7d23e67e --- /dev/null +++ b/src/components/ui/file-input-button.tsx @@ -0,0 +1,127 @@ +'use client'; + +import * as React from 'react'; +import { Upload, X } from 'lucide-react'; + +import { Button, type ButtonProps } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export interface FileInputButtonProps { + /** File-picker `accept` attribute (MIME or extension list). */ + accept?: string; + /** Allow picking more than one file. Default: false. */ + multiple?: boolean; + /** Required: handler invoked with the picked FileList contents. */ + onFilesPicked: (files: File[]) => void; + /** Button label. Default: "Choose file" / "Choose files". */ + label?: string; + /** Show an Upload icon before the label. Default: true. */ + showIcon?: boolean; + /** Button variant. Default: 'outline'. */ + variant?: ButtonProps['variant']; + /** Button size. Default: 'sm'. */ + size?: ButtonProps['size']; + /** Disable the picker (e.g. while a previous upload is in flight). */ + disabled?: boolean; + /** Optional className applied to the wrapper. */ + className?: string; + /** Optional className applied to the trigger Button. */ + buttonClassName?: string; + /** Tooltip text on the trigger Button. */ + title?: string; + /** + * When true, renders the selected filename next to a clear (×) button + * underneath the picker — same idiom as the expense form. Default: + * false (the caller manages its own filename display). + */ + showSelectedFilename?: boolean; +} + +/** + * Styled file-picker primitive — replaces the raw browser-default + * `` UI that looks different across Chromium / + * Safari / Firefox. Renders a Button + hidden input; the Button forwards + * clicks to the input. Use everywhere we'd otherwise render a raw file + * input. + */ +export const FileInputButton = React.forwardRef( + function FileInputButton( + { + accept, + multiple = false, + onFilesPicked, + label, + showIcon = true, + variant = 'outline', + size = 'sm', + disabled, + className, + buttonClassName, + title, + showSelectedFilename = false, + }, + forwardedRef, + ) { + const inputRef = React.useRef(null); + const [selected, setSelected] = React.useState([]); + + React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement); + + const computedLabel = label ?? (multiple ? 'Choose files' : 'Choose file'); + + function handleChange(e: React.ChangeEvent) { + const list = e.target.files; + if (!list || list.length === 0) return; + const files = Array.from(list); + setSelected(files); + onFilesPicked(files); + } + + function clear() { + setSelected([]); + if (inputRef.current) inputRef.current.value = ''; + } + + return ( +
+ + + {showSelectedFilename && selected.length > 0 ? ( +
+ f.name).join(', ')}> + {selected.length === 1 ? selected[0]!.name : `${selected.length} files selected`} + + +
+ ) : null} +
+ ); + }, +);