feat(recommender): API endpoint + interest-detail panel + add-to-interest dialog
This commit is contained in:
470
src/components/interests/berth-recommender-panel.tsx
Normal file
470
src/components/interests/berth-recommender-panel.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronUp, Filter, Flame, Plus, RefreshCw, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { AddBerthToInterestDialog } from '@/components/interests/add-berth-to-interest-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types (mirror the recommender service Recommendation shape) ───────────
|
||||
|
||||
type Tier = 'A' | 'B' | 'C' | 'D';
|
||||
|
||||
interface HeatBreakdown {
|
||||
recency: number;
|
||||
furthestStage: number;
|
||||
interestCount: number;
|
||||
eoiCount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
tier: Tier;
|
||||
fitScore: number;
|
||||
sizeBufferPct: number | null;
|
||||
heat: HeatBreakdown | null;
|
||||
reasons: {
|
||||
dimensional: string;
|
||||
pipeline: string;
|
||||
amenities?: string;
|
||||
heat?: string;
|
||||
};
|
||||
lengthFt: number | null;
|
||||
widthFt: number | null;
|
||||
draftFt: number | null;
|
||||
status: string;
|
||||
amenities: {
|
||||
powerCapacity: number | null;
|
||||
voltage: number | null;
|
||||
access: string | null;
|
||||
mooringType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface AmenityFilters {
|
||||
minPowerCapacityKw?: number;
|
||||
requiredVoltage?: number;
|
||||
requiredAccess?: string;
|
||||
requiredMooringType?: string;
|
||||
requiredCleatCapacity?: string;
|
||||
}
|
||||
|
||||
interface BerthRecommenderPanelProps {
|
||||
interestId: string;
|
||||
/** Display label for the dimensions in the header. */
|
||||
desiredLengthFt: number | null;
|
||||
desiredWidthFt: number | null;
|
||||
desiredDraftFt: number | null;
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
|
||||
A: { label: 'Open', tone: 'border-emerald-200 bg-emerald-50 text-emerald-800' },
|
||||
B: { label: 'Fall-through', tone: 'border-amber-200 bg-amber-50 text-amber-800' },
|
||||
C: { label: 'Active interest', tone: 'border-sky-200 bg-sky-50 text-sky-800' },
|
||||
D: { label: 'Late stage', tone: 'border-slate-300 bg-slate-100 text-slate-700' },
|
||||
};
|
||||
|
||||
function statusToPill(status: string): StatusPillStatus {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'active';
|
||||
case 'under_offer':
|
||||
return 'sent';
|
||||
case 'sold':
|
||||
return 'completed';
|
||||
case 'reserved':
|
||||
return 'partial';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
return status.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
function formatDimensions(
|
||||
length: number | null,
|
||||
width: number | null,
|
||||
draft: number | null,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (length !== null) parts.push(`${length.toFixed(1)}ft L`);
|
||||
if (width !== null) parts.push(`${width.toFixed(1)}ft W`);
|
||||
if (draft !== null) parts.push(`${draft.toFixed(1)}ft D`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function formatDesired(length: number | null, width: number | null, draft: number | null): string {
|
||||
const parts: string[] = [];
|
||||
if (length !== null) parts.push(`${length}ft L`);
|
||||
if (width !== null) parts.push(`${width}ft W`);
|
||||
if (draft !== null) parts.push(`${draft}ft D`);
|
||||
return parts.length > 0 ? parts.join(' · ') : 'no dimensions set';
|
||||
}
|
||||
|
||||
interface RecommendationCardProps {
|
||||
rec: Recommendation;
|
||||
portSlug: string;
|
||||
onAdd: (rec: Recommendation) => void;
|
||||
}
|
||||
|
||||
function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tier = TIER_LABELS[rec.tier];
|
||||
const showHeat = rec.heat && rec.heat.total > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{rec.mooringNumber}</span>
|
||||
{rec.area ? <span className="text-xs text-muted-foreground">{rec.area}</span> : null}
|
||||
<StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
tier.tone,
|
||||
)}
|
||||
>
|
||||
Tier {rec.tier} · {tier.label}
|
||||
</span>
|
||||
{showHeat ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-800">
|
||||
<Flame className="size-3" />
|
||||
Heat {Math.round(rec.heat!.total)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDimensions(rec.lengthFt, rec.widthFt, rec.draftFt)}
|
||||
{rec.sizeBufferPct !== null ? (
|
||||
<span>
|
||||
{' '}
|
||||
· {rec.sizeBufferPct >= 0 ? '+' : ''}
|
||||
{rec.sizeBufferPct}% vs desired
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-2 font-medium text-foreground">Fit {rec.fitScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<div className="space-y-3 border-t bg-muted/20 p-3">
|
||||
<dl className="space-y-1 text-xs">
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-28 shrink-0 text-muted-foreground">Dimensional</dt>
|
||||
<dd>{rec.reasons.dimensional}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-28 shrink-0 text-muted-foreground">Pipeline</dt>
|
||||
<dd>{rec.reasons.pipeline}</dd>
|
||||
</div>
|
||||
{rec.reasons.amenities ? (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-28 shrink-0 text-muted-foreground">Amenities</dt>
|
||||
<dd>{rec.reasons.amenities}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{rec.reasons.heat ? (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-28 shrink-0 text-muted-foreground">Heat</dt>
|
||||
<dd>{rec.reasons.heat}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd(rec);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
Add to interest
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/${portSlug}/berths/${rec.berthId}`}>View berth</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AmenityFilterFormProps {
|
||||
filters: AmenityFilters;
|
||||
onChange: (next: AmenityFilters) => void;
|
||||
}
|
||||
|
||||
function AmenityFilterForm({ filters, onChange }: AmenityFilterFormProps) {
|
||||
const update = <K extends keyof AmenityFilters>(key: K, value: AmenityFilters[K]) => {
|
||||
const next = { ...filters };
|
||||
if (value === undefined || value === '' || (typeof value === 'number' && Number.isNaN(value))) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 rounded-lg border bg-muted/20 p-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="filter-power" className="text-xs">
|
||||
Min power (kW)
|
||||
</Label>
|
||||
<Input
|
||||
id="filter-power"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={filters.minPowerCapacityKw ?? ''}
|
||||
onChange={(e) =>
|
||||
update('minPowerCapacityKw', e.target.value ? parseFloat(e.target.value) : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="filter-voltage" className="text-xs">
|
||||
Voltage
|
||||
</Label>
|
||||
<Input
|
||||
id="filter-voltage"
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={filters.requiredVoltage ?? ''}
|
||||
onChange={(e) =>
|
||||
update('requiredVoltage', e.target.value ? parseInt(e.target.value, 10) : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="filter-mooring" className="text-xs">
|
||||
Mooring type
|
||||
</Label>
|
||||
<Select
|
||||
value={filters.requiredMooringType ?? ''}
|
||||
onValueChange={(v) => update('requiredMooringType', v || undefined)}
|
||||
>
|
||||
<SelectTrigger id="filter-mooring">
|
||||
<SelectValue placeholder="Any" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stern_to">Stern-to</SelectItem>
|
||||
<SelectItem value="alongside">Alongside</SelectItem>
|
||||
<SelectItem value="bow_to">Bow-to</SelectItem>
|
||||
<SelectItem value="swing">Swing mooring</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="filter-cleat" className="text-xs">
|
||||
Cleat capacity
|
||||
</Label>
|
||||
<Select
|
||||
value={filters.requiredCleatCapacity ?? ''}
|
||||
onValueChange={(v) => update('requiredCleatCapacity', v || undefined)}
|
||||
>
|
||||
<SelectTrigger id="filter-cleat">
|
||||
<SelectValue placeholder="Any" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem value="heavy">Heavy</SelectItem>
|
||||
<SelectItem value="superyacht">Superyacht</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="filter-access" className="text-xs">
|
||||
Access
|
||||
</Label>
|
||||
<Select
|
||||
value={filters.requiredAccess ?? ''}
|
||||
onValueChange={(v) => update('requiredAccess', v || undefined)}
|
||||
>
|
||||
<SelectTrigger id="filter-access">
|
||||
<SelectValue placeholder="Any" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="land">Land access</SelectItem>
|
||||
<SelectItem value="dinghy">Dinghy only</SelectItem>
|
||||
<SelectItem value="walk_on">Walk-on</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BerthRecommenderPanel({
|
||||
interestId,
|
||||
desiredLengthFt,
|
||||
desiredWidthFt,
|
||||
desiredDraftFt,
|
||||
}: BerthRecommenderPanelProps) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [amenityFilters, setAmenityFilters] = useState<AmenityFilters>({});
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [pendingBerth, setPendingBerth] = useState<Recommendation | null>(null);
|
||||
|
||||
const hasDimensions = desiredLengthFt !== null;
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => ['berth-recommendations', interestId, amenityFilters, showAll] as const,
|
||||
[interestId, amenityFilters, showAll],
|
||||
);
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
queryKey,
|
||||
enabled: hasDimensions,
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...(showAll ? { topN: 999 } : {}),
|
||||
...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}),
|
||||
},
|
||||
}).then((r) => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const recommendations = data ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="size-4 text-brand-600" />
|
||||
Recommendations for {formatDesired(desiredLengthFt, desiredWidthFt, desiredDraftFt)}
|
||||
</CardTitle>
|
||||
{!hasDimensions ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set desired dimensions to see recommendations.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
disabled={!hasDimensions}
|
||||
>
|
||||
<Filter className="mr-1.5 size-3.5" />
|
||||
{filtersOpen ? 'Hide filters' : 'Add filters'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={!hasDimensions || isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{filtersOpen && hasDimensions ? (
|
||||
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasDimensions ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once length, width, and draft are set on this interest, the recommender will surface
|
||||
berths that fit. Edit the desired dimensions on the{' '}
|
||||
<Link href="?tab=overview" className="text-primary underline">
|
||||
Overview tab
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : isFetching && recommendations.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : recommendations.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No berths match the current dimensions and filters.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.berthId}
|
||||
rec={rec}
|
||||
portSlug={portSlug}
|
||||
onAdd={setPendingBerth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasDimensions && recommendations.length > 0 ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Show top recommendations' : 'Show all feasible'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
{pendingBerth ? (
|
||||
<AddBerthToInterestDialog
|
||||
interestId={interestId}
|
||||
berth={{
|
||||
berthId: pendingBerth.berthId,
|
||||
mooringNumber: pendingBerth.mooringNumber,
|
||||
}}
|
||||
open={pendingBerth !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingBerth(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user