feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -0,0 +1,187 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface BerthOption {
id: string;
mooringNumber: string;
area: string | null;
status: string;
}
interface BerthPickerProps {
value: string | null;
onChange: (berthId: string | null) => void;
/** When set, the dropdown is scoped to berths linked through any of
* this client's interests (via interest_berths.primary). Other berths
* are hidden so the picker mirrors the relationship the rep is
* already building. */
clientId?: string | null;
placeholder?: string;
disabled?: boolean;
}
/**
* Searchable berth picker. Free-text search when no client is selected;
* scoped to a client's primary-berth set when `clientId` is provided.
*
* The scoped query fetches the client's interests (limit 25) and
* intersects on `berthId`, which mirrors the relationship semantics the
* rest of the CRM uses ("berths that show up on this client's deals").
*/
export function BerthPicker({
value,
onChange,
clientId,
placeholder = 'Select berth...',
disabled,
}: BerthPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
// Free-text search path — used when there's no clientId scope.
const { data: searchData } = useQuery<{ data: BerthOption[] }>({
queryKey: ['berth-picker', 'search', debounced],
queryFn: () => {
const params = new URLSearchParams({ page: '1', limit: '10', order: 'asc' });
// The list endpoint doesn't accept `search`, so we filter
// client-side; pulling a larger page lets the typeahead feel
// responsive without round-tripping per keystroke.
params.set('limit', '50');
return apiFetch(`/api/v1/berths?${params.toString()}`);
},
enabled: open && !clientId,
});
// Scoped path — pull this client's interests (with their primary
// berth) and dedupe the berth set.
const { data: clientInterests } = useQuery<{
data: Array<{ berthId: string | null; berthMooringNumber: string | null }>;
}>({
queryKey: ['berth-picker', 'client', clientId],
queryFn: () => {
const params = new URLSearchParams({
page: '1',
limit: '25',
order: 'desc',
includeArchived: 'false',
clientId: clientId!,
});
return apiFetch(`/api/v1/interests?${params.toString()}`);
},
enabled: open && !!clientId,
});
const options: BerthOption[] = useMemo(() => {
if (clientId) {
const rows = clientInterests?.data ?? [];
const seen = new Set<string>();
const out: BerthOption[] = [];
for (const r of rows) {
if (!r.berthId || seen.has(r.berthId)) continue;
seen.add(r.berthId);
out.push({
id: r.berthId,
mooringNumber: r.berthMooringNumber ?? '',
area: null,
status: '',
});
}
if (!debounced) return out;
const q = debounced.toLowerCase();
return out.filter((b) => b.mooringNumber.toLowerCase().includes(q));
}
const rows = searchData?.data ?? [];
if (!debounced) return rows;
const q = debounced.toLowerCase();
return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q));
}, [clientId, clientInterests, searchData, debounced]);
const labelFor = (o: BerthOption) =>
o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`;
const selectedLabel = (() => {
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
return match ? labelFor(match) : `Berth ${value.slice(0, 8)}`;
})();
return (
// `modal` is required when this picker is rendered inside a Sheet /
// Dialog — without it the CommandInput stays focus-blocked by the
// outer Sheet's focus trap and clicks/typing are silently dropped.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder={clientId ? "Search this client's berths…" : 'Search berths…'}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
{clientId ? 'No berths linked to this client.' : 'No berths found.'}
</CommandEmpty>
<CommandGroup>
{value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
) : null}
{options.map((o) => (
<CommandItem
key={o.id}
value={o.id}
onSelect={() => {
onChange(o.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{labelFor(o)}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -58,7 +58,10 @@ export function ClientPicker({
})();
return (
<Popover open={open} onOpenChange={setOpen}>
// `modal` is required when this picker is rendered inside a Sheet /
// Dialog — without it the CommandInput stays focus-blocked by the
// outer Sheet's focus trap and clicks/typing are silently dropped.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -76,6 +79,18 @@ export function ClientPicker({
<CommandList>
<CommandEmpty>No clients found.</CommandEmpty>
<CommandGroup>
{value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
) : null}
{options.map((c) => (
<CommandItem
key={c.id}

View File

@@ -87,7 +87,11 @@ export function CountryCombobox({
const selected = value ? options.find((o) => o.code === value) : undefined;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
// modal: required when this combobox is nested inside a Sheet
// (Radix Dialog). Without it, the parent Dialog's pointer-events
// handling swallows the trigger's tap on iOS Safari — same fix
// pattern as TimezoneCombobox.
<Popover modal open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}

View File

@@ -19,19 +19,76 @@ interface CurrencyInputProps extends Omit<
className?: string;
}
const groupFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
useGrouping: true,
});
function formatGrouped(value: number | string): string {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return '';
return groupFormatter.format(n);
}
function parseTyped(raw: string): { display: string; numeric: number | null } {
// Strip everything except digits, '.', '-'. Commas are formatting noise from
// our own display and are removed before re-grouping. (Locale note: this
// assumes '.' as decimal separator, matching the en-US formatter below.)
let cleaned = raw.replace(/[^\d.-]/g, '');
// Keep only the first '.' (additional dots are dropped).
const firstDot = cleaned.indexOf('.');
if (firstDot !== -1) {
cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '');
}
// Sign: only honour a leading '-'; strip any others.
const negative = cleaned.startsWith('-');
cleaned = (negative ? '-' : '') + cleaned.replace(/-/g, '');
if (cleaned === '' || cleaned === '-') return { display: cleaned, numeric: null };
const dot = cleaned.indexOf('.');
const intPart = dot === -1 ? cleaned : cleaned.slice(0, dot);
const fracPart = dot === -1 ? null : cleaned.slice(dot + 1);
const intDigitsOnly = intPart.replace('-', '');
const intNumeric = intDigitsOnly === '' ? 0 : Number(intDigitsOnly);
const numeric = (negative ? -1 : 1) * (intNumeric + (fracPart ? Number(`0.${fracPart}`) || 0 : 0));
const intDisplay =
intDigitsOnly === ''
? (negative ? '-' : '')
: (negative ? '-' : '') + groupFormatter.format(intNumeric);
const display = fracPart === null ? intDisplay : `${intDisplay}.${fracPart}`;
return { display, numeric: Number.isFinite(numeric) ? numeric : null };
}
/**
* Numeric input pre-decorated with a currency symbol. The display
* value is the raw number the user typed (we don't fight the keystroke
* cadence by re-formatting on every key) — formatted display lives in
* read-only contexts via `formatCurrency()`. This keeps form behaviour
* predictable while still scoping the input to a money field via the
* symbol prefix and the `decimal` inputMode.
* Numeric input pre-decorated with a currency symbol and thousand-separator
* grouping (e.g. `3,528,000.50`). Uses `type="text"` + `inputMode="decimal"`
* so we can render commas (HTML `type="number"` strips them) while still
* surfacing the decimal keypad on iOS/Android. The parent receives a raw
* number via `onChange`; the formatted string is local UI state.
*/
export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
({ value, onChange, currency = 'USD', className, ...props }, ref) => {
({ value, onChange, currency = 'USD', className, onBlur, onFocus, ...props }, ref) => {
const symbol = currencySymbol(currency);
const display = value === null || value === undefined || value === '' ? '' : String(value);
const [display, setDisplay] = React.useState<string>(() =>
value === null || value === undefined || value === '' ? '' : formatGrouped(value),
);
const focusedRef = React.useRef(false);
// Re-sync the display when the controlled value changes externally (form
// reset, parent-driven update). Skip while the input is focused so we
// don't fight the user's keystrokes.
React.useEffect(() => {
if (focusedRef.current) return;
if (value === null || value === undefined || value === '') {
setDisplay('');
} else {
setDisplay(formatGrouped(value));
}
}, [value]);
return (
<div className="relative">
@@ -43,19 +100,29 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
</span>
<Input
ref={ref}
type="number"
type="text"
inputMode="decimal"
step="0.01"
min="0"
autoComplete="off"
value={display}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
onChange(null);
return;
const { display: nextDisplay, numeric } = parseTyped(e.target.value);
setDisplay(nextDisplay);
onChange(numeric);
}}
onFocus={(e) => {
focusedRef.current = true;
onFocus?.(e);
}}
onBlur={(e) => {
focusedRef.current = false;
// On blur, canonicalize to a clean grouped representation so the
// user sees the final value rather than any half-typed state.
if (value === null || value === undefined || value === '') {
setDisplay('');
} else {
setDisplay(formatGrouped(value));
}
const n = Number(raw);
onChange(Number.isFinite(n) ? n : null);
onBlur?.(e);
}}
className={cn('pl-9 tabular-nums', className)}
{...props}

View File

@@ -65,6 +65,15 @@ interface DataTableProps<TData> {
* sort, and selection stay in sync across the breakpoint.
*/
cardRender?: (row: Row<TData>) => React.ReactNode;
/**
* Optional grouping key for the mobile card list. When set, consecutive
* rows that share the same returned key are visually grouped under a
* header showing the key. Rendered only on mobile (next to cardRender);
* the desktop table is unaffected. Useful for berths-by-area,
* documents-by-folder, etc. — pre-sort the data on the same key so
* adjacent rows already share groups.
*/
mobileGroupBy?: (row: TData) => string | null | undefined;
/**
* Per-column visibility map. Keys are column IDs, values mean
* "currently visible". Columns absent from the map are visible by
@@ -90,6 +99,7 @@ export function DataTable<TData>({
onRowClick,
getRowClassName,
cardRender,
mobileGroupBy,
columnVisibility,
}: DataTableProps<TData>) {
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
@@ -259,7 +269,30 @@ export function DataTable<TData>({
{emptyState ?? 'No results.'}
</li>
) : (
rows.map((row) => <li key={row.id}>{cardRender(row)}</li>)
(() => {
// Walk rows once, emitting a section header <li> every time
// the groupBy key changes. Keeps the existing flex-col gap-2
// rhythm; the header sits above the first card of each group
// with a faint top divider for visual rest between blocks.
let lastGroup: string | null | undefined;
const nodes: React.ReactNode[] = [];
rows.forEach((row, i) => {
const group = mobileGroupBy ? mobileGroupBy(row.original) : undefined;
if (mobileGroupBy && group !== lastGroup) {
nodes.push(
<li key={`__group_${group ?? '_none'}_${i}`} className="px-1 pt-3">
<div className="flex items-center gap-3 text-base font-bold tracking-tight text-foreground">
<span>{group ?? 'Other'}</span>
<span aria-hidden className="h-px flex-1 bg-border" />
</div>
</li>,
);
lastGroup = group;
}
nodes.push(<li key={row.id}>{cardRender(row)}</li>);
});
return nodes;
})()
)}
</ul>
)}

View File

@@ -5,11 +5,27 @@ import { Drawer as VaulDrawer } from 'vaul';
import { cn } from '@/lib/utils';
// Default `shouldScaleBackground` to FALSE for smoother drag animations.
// Scaling the underlying page during the swipe rasterises a heavy DOM
// (dashboard widgets, charts, queries firing) into a composited layer
// every frame, which stutters on mid-tier phones. The bg-black/60
// overlay alone provides enough depth signal. Individual call sites can
// still opt back in if they have a lightweight page underneath.
//
// Also default `repositionInputs={false}` — when the drawer has form
// inputs, Vaul's viewport repositioning logic conflicts with iOS's
// keyboard handling and produces the visible scroll-then-jump we hit
// in the search overlay.
const Drawer = ({
shouldScaleBackground = true,
shouldScaleBackground = false,
repositionInputs = false,
...props
}: React.ComponentProps<typeof VaulDrawer.Root>) => (
<VaulDrawer.Root shouldScaleBackground={shouldScaleBackground} {...props} />
<VaulDrawer.Root
shouldScaleBackground={shouldScaleBackground}
repositionInputs={repositionInputs}
{...props}
/>
);
Drawer.displayName = 'Drawer';

View File

@@ -22,6 +22,13 @@ interface SelectOption {
interface BaseProps {
value: string | null | undefined;
/**
* Optional formatted version shown in display mode only. The edit
* input still works against the raw `value` (so the input shows the
* editable raw number, not the formatted string). Useful for
* currency, percentages, etc.
*/
displayValue?: string | null;
onSave: (next: string | null) => Promise<void>;
placeholder?: string;
emptyText?: string;
@@ -43,7 +50,15 @@ interface TextareaProps extends BaseProps {
rows?: number;
}
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
interface DateProps extends BaseProps {
variant: 'date';
/** Optional min/max bounds in YYYY-MM-DD form (e.g. for incorporation dates that
* can't be in the future). */
min?: string;
max?: string;
}
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps | DateProps;
/**
* Click-to-edit field used in detail panels. Shows the value as plain text
@@ -51,7 +66,15 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
const { value, onSave, placeholder, emptyText = '-', className, disabled } = props;
const {
value,
displayValue,
onSave,
placeholder,
emptyText = '-',
className,
disabled,
} = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);
@@ -131,11 +154,42 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
);
}
if (props.variant === 'date') {
// Native date input: the browser provides the calendar UI, ISO-formatted
// value (YYYY-MM-DD) keeps the backend payload uniform. Saves on change
// (no extra blur tap on mobile) and on Enter; Escape reverts.
return (
<div className={cn('flex items-center gap-1', className)}>
<Input
type="date"
value={draft}
min={props.min}
max={props.max}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
void commit(next);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
disabled={saving || disabled}
className="h-8 text-sm w-auto"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (props.variant === 'textarea') {
if (!editing) {
return (
<ReadButton
value={value || null}
displayValue={displayValue}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
@@ -178,6 +232,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
return (
<ReadButton
value={value || null}
displayValue={displayValue}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
@@ -216,6 +271,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
function ReadButton({
value,
displayValue,
emptyText,
disabled,
onClick,
@@ -224,6 +280,8 @@ function ReadButton({
className,
}: {
value: string | null;
/** Optional formatted version for display only (currency, percent, etc.) */
displayValue?: string | null;
emptyText: string;
disabled?: boolean;
onClick: () => void;
@@ -258,7 +316,7 @@ function ReadButton({
!value && 'text-muted-foreground',
)}
>
{value ?? emptyText}
{value ? (displayValue ?? value) : emptyText}
</span>
{!disabled && (
<Icon

View File

@@ -146,7 +146,7 @@ export function InlinePhoneField({
{display ?? emptyText}
</span>
{!disabled && (
<Pencil className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50" />
<Pencil className="h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60" />
)}
</button>
);

View File

@@ -0,0 +1,143 @@
'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface InterestOption {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
}
interface InterestPickerProps {
value: string | null;
onChange: (interestId: string | null) => void;
/** When set, only this client's interests are listed. */
clientId?: string | null;
placeholder?: string;
disabled?: boolean;
}
/**
* Searchable interest picker. Mirrors ClientPicker. When `clientId` is
* provided the dropdown scopes to that client — so picking the client
* first naturally narrows the interest options.
*/
export function InterestPicker({
value,
onChange,
clientId,
placeholder = 'Select interest...',
disabled,
}: InterestPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
const { data } = useQuery<{ data: InterestOption[] }>({
queryKey: ['interest-picker', clientId ?? null, debounced],
queryFn: () => {
const params = new URLSearchParams({
page: '1',
limit: '10',
order: 'desc',
includeArchived: 'false',
});
if (debounced) params.set('search', debounced);
if (clientId) params.set('clientId', clientId);
return apiFetch(`/api/v1/interests?${params.toString()}`);
},
enabled: open,
});
const options = data?.data ?? [];
const labelFor = (o: InterestOption) => {
const parts = [o.clientName ?? 'Unknown client'];
if (o.berthMooringNumber) parts.push(`Berth ${o.berthMooringNumber}`);
parts.push(o.pipelineStage.replace(/_/g, ' '));
return parts.join(' · ');
};
const selectedLabel = (() => {
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
return match ? labelFor(match) : `Interest ${value.slice(0, 8)}`;
})();
return (
// `modal` is required when this picker is rendered inside a Sheet /
// Dialog — without it the CommandInput stays focus-blocked by the
// outer Sheet's focus trap and clicks/typing are silently dropped.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder={clientId ? "Search this client's interests…" : 'Search interests…'}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>No interests found.</CommandEmpty>
<CommandGroup>
{value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
) : null}
{options.map((o) => (
<CommandItem
key={o.id}
value={o.id}
onSelect={() => {
onChange(o.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{labelFor(o)}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -31,8 +31,8 @@ interface ListCardProps {
* Shared shell for every mobile list card. Wraps the body in a Link to the
* detail page, paints an optional status accent bar on the left edge, and
* exposes a top-right slot for an actions menu. Touch/hover feedback comes
* from a soft `hover:bg-muted/30` + `active:bg-muted/50` tint, no shadow
* shifts (which feel jittery on mobile).
* from a soft brand-blue tint via `hover:bg-accent/40` + `active:bg-accent`,
* no shadow shifts (which feel jittery on mobile).
*/
export function ListCard({
href,
@@ -52,7 +52,7 @@ export function ListCard({
<article
className={cn(
'group relative overflow-hidden rounded-lg border bg-card shadow-xs',
'transition-colors hover:bg-muted/30 active:bg-muted/50',
'transition-colors hover:bg-accent/40 active:bg-accent',
className,
)}
>

View File

@@ -64,10 +64,35 @@ export function OwnerPicker({
const options = data?.data ?? [];
// Selected display label - show entity's name from current options if
// available, otherwise a truncated id fallback.
// Resolve the current value's display name even before the picker is opened.
// Without this primer query the trigger button rendered "Client <8-char-id>"
// on first paint and only filled in the real name after the user opened the
// dropdown (which kicked the list query). The lookup hits a per-id endpoint
// when possible and falls back to scanning the cached options array.
const valueLookupEndpoint = value
? value.type === 'client'
? `/api/v1/clients/${value.id}`
: `/api/v1/companies/${value.id}`
: null;
const { data: valueDetail } = useQuery<{
data: { id: string; name?: string | null; fullName?: string | null };
}>({
queryKey: ['owner-picker-resolve', value?.type, value?.id],
queryFn: () => apiFetch(valueLookupEndpoint!),
enabled: !!value && !!valueLookupEndpoint,
staleTime: 60_000,
});
// Selected display label - prefer the resolved entity name; fall back to a
// truncated id only when both the primer query and the options list miss.
const selectedLabel = (() => {
if (!value) return placeholder;
if (valueDetail?.data) {
const name =
value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name;
if (name) return name;
}
const match = options.find((o) => o.id === value.id);
if (match) {
return type === 'client'
@@ -80,7 +105,7 @@ export function OwnerPicker({
})();
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"

View File

@@ -0,0 +1,104 @@
'use client';
import * as React from 'react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
/** Common follow-up cadences reps reach for, in days. */
const PRESETS = [1, 3, 7, 14, 30] as const;
interface ReminderDaysInputProps {
value: number | null | undefined;
onChange: (value: number | null) => void;
id?: string;
disabled?: boolean;
/** Custom-input placeholder, defaults to "e.g. 21". */
placeholder?: string;
className?: string;
}
/**
* Days-from-now reminder picker. Quick-pick chips for the four or five
* cadences reps actually use (1 day, 3, 1 week, 2 weeks, 1 month) plus a
* custom integer input for everything else. Clearer than a raw number field
* and surfaces the common cases without the rep having to type.
*
* Storage is still a single integer (number of days), so callers can keep
* their existing form/zod shape unchanged.
*/
export function ReminderDaysInput({
value,
onChange,
id,
disabled,
placeholder = 'e.g. 21',
className,
}: ReminderDaysInputProps) {
const isPreset = typeof value === 'number' && (PRESETS as readonly number[]).includes(value);
const [customStr, setCustomStr] = React.useState<string>(() =>
!isPreset && typeof value === 'number' ? String(value) : '',
);
// Sync external value → custom input when it changes to a non-preset.
React.useEffect(() => {
if (typeof value === 'number' && !(PRESETS as readonly number[]).includes(value)) {
setCustomStr(String(value));
} else if (value == null) {
setCustomStr('');
}
}, [value]);
return (
<div className={cn('space-y-2', className)}>
<div className="flex flex-wrap items-center gap-1.5">
{PRESETS.map((days) => (
<button
key={days}
type="button"
disabled={disabled}
onClick={() => onChange(days)}
className={cn(
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
value === days
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-foreground hover:bg-accent',
disabled && 'cursor-not-allowed opacity-60',
)}
aria-pressed={value === days}
>
{labelFor(days)}
</button>
))}
</div>
<Input
id={id}
type="number"
inputMode="numeric"
min={1}
step={1}
placeholder={placeholder}
disabled={disabled}
value={customStr}
onChange={(e) => {
const raw = e.target.value;
setCustomStr(raw);
if (raw === '') {
onChange(null);
return;
}
const n = Number.parseInt(raw, 10);
if (Number.isFinite(n) && n > 0) onChange(n);
}}
/>
</div>
);
}
function labelFor(days: number): string {
if (days === 1) return '1 day';
if (days === 7) return '1 week';
if (days === 14) return '2 weeks';
if (days === 30) return '1 month';
return `${days} days`;
}

View File

@@ -75,7 +75,7 @@ export function SubdivisionCombobox({
else triggerLabel = placeholder;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={handleOpenChange} modal>
<PopoverTrigger asChild>
<Button
id={id}

View File

@@ -37,6 +37,13 @@ export function TagPicker({
// Extend options to include color
const tagOptions = options as Array<{ value: string; label: string; color?: string }>;
// If the port has no tags configured AND the rep also hasn't selected any
// (e.g. a tag was deleted after selection), don't render the picker at all —
// the affordance is noise until tags are set up under Admin → Tags.
if (!isLoading && tagOptions.length === 0 && selectedIds.length === 0) {
return null;
}
function toggleTag(tagId: string) {
if (selectedIds.includes(tagId)) {
onChange(selectedIds.filter((id) => id !== tagId));
@@ -53,7 +60,7 @@ export function TagPicker({
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"

View File

@@ -77,7 +77,12 @@ export function TimezoneCombobox({
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
// `modal` is critical for iOS Safari when this combobox is nested
// inside a Sheet (Radix Dialog). Without it, the parent Dialog's
// pointer-events handling can swallow the trigger's touch event,
// so tapping the button does nothing on iPhone. modal=true makes
// Radix isolate the Popover's pointer context from the parent.
<Popover modal open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}