Files
pn-new-crm/src/components/shared/berth-picker.tsx
Matt 3ffee79f3f 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>
2026-05-12 14:50:58 +02:00

188 lines
6.2 KiB
TypeScript

'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>
);
}