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:
187
src/components/shared/berth-picker.tsx
Normal file
187
src/components/shared/berth-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
143
src/components/shared/interest-picker.tsx
Normal file
143
src/components/shared/interest-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
104
src/components/shared/reminder-days-input.tsx
Normal file
104
src/components/shared/reminder-days-input.tsx
Normal 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`;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user