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>
188 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|