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