feat(recommender): API endpoint + interest-detail panel + add-to-interest dialog
This commit is contained in:
72
src/app/api/v1/interests/[id]/berths/route.ts
Normal file
72
src/app/api/v1/interests/[id]/berths/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
|
import { upsertInterestBerth } from '@/lib/services/interest-berths.service';
|
||||||
|
import { createAuditLog } from '@/lib/audit';
|
||||||
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
|
||||||
|
const addBerthSchema = z.object({
|
||||||
|
berthId: z.string().min(1),
|
||||||
|
/** Drives the public-map "Under Offer" sub-status. See plan §5.4. */
|
||||||
|
isSpecificInterest: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/interests/[id]/berths — link a berth (non-primary) to an interest.
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('interests', 'edit', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, addBerthSchema);
|
||||||
|
const interestId = params.id!;
|
||||||
|
|
||||||
|
// Tenant scope: interest must belong to this port.
|
||||||
|
const interest = await db.query.interests.findFirst({
|
||||||
|
where: eq(interests.id, interestId),
|
||||||
|
});
|
||||||
|
if (!interest || interest.portId !== ctx.portId) {
|
||||||
|
throw new NotFoundError('Interest');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant scope: berth must belong to this port (never trust a client-
|
||||||
|
// supplied id to cross port boundaries — plan §14.10).
|
||||||
|
const berth = await db.query.berths.findFirst({
|
||||||
|
where: and(eq(berths.id, body.berthId), eq(berths.portId, ctx.portId)),
|
||||||
|
});
|
||||||
|
if (!berth) {
|
||||||
|
throw new ValidationError('berthId not found in this port');
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await upsertInterestBerth(interestId, body.berthId, {
|
||||||
|
isSpecificInterest: body.isSpecificInterest,
|
||||||
|
addedBy: ctx.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'interest',
|
||||||
|
entityId: interestId,
|
||||||
|
newValue: { berthId: body.berthId, isSpecificInterest: body.isSpecificInterest },
|
||||||
|
metadata: { type: 'berth_added_to_interest' },
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${ctx.portId}`, 'interest:berthLinked', {
|
||||||
|
interestId,
|
||||||
|
berthId: body.berthId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: link }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
44
src/app/api/v1/interests/[id]/recommend-berths/route.ts
Normal file
44
src/app/api/v1/interests/[id]/recommend-berths/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { recommendBerths } from '@/lib/services/berth-recommender.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST body — mirrors `RecommendBerthsArgs` minus the `interestId` (route
|
||||||
|
* param) and `portId` (resolved from the auth context — never trust a
|
||||||
|
* client-supplied port, plan §14.10).
|
||||||
|
*/
|
||||||
|
const recommendBerthsSchema = z.object({
|
||||||
|
topN: z.number().int().min(1).max(999).optional(),
|
||||||
|
maxOversizePct: z.number().min(0).max(1000).optional(),
|
||||||
|
showLateStage: z.boolean().optional(),
|
||||||
|
amenityFilters: z
|
||||||
|
.object({
|
||||||
|
minPowerCapacityKw: z.number().min(0).optional(),
|
||||||
|
requiredVoltage: z.number().int().min(0).optional(),
|
||||||
|
requiredAccess: z.string().min(1).optional(),
|
||||||
|
requiredMooringType: z.string().min(1).optional(),
|
||||||
|
requiredCleatCapacity: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/interests/[id]/recommend-berths
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('interests', 'view', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, recommendBerthsSchema);
|
||||||
|
const data = await recommendBerths({
|
||||||
|
interestId: params.id!,
|
||||||
|
portId: ctx.portId,
|
||||||
|
...body,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
150
src/components/interests/add-berth-to-interest-dialog.tsx
Normal file
150
src/components/interests/add-berth-to-interest-dialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AddBerthToInterestDialogProps {
|
||||||
|
interestId: string;
|
||||||
|
berth: { berthId: string; mooringNumber: string };
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAdded?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleChoice = 'specific' | 'exploring';
|
||||||
|
|
||||||
|
export function AddBerthToInterestDialog({
|
||||||
|
interestId,
|
||||||
|
berth,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAdded,
|
||||||
|
}: AddBerthToInterestDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [choice, setChoice] = useState<RoleChoice>('specific');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (isSpecificInterest: boolean) =>
|
||||||
|
apiFetch(`/api/v1/interests/${interestId}/berths`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { berthId: berth.berthId, isSpecificInterest },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate the recommender cache + linked-berths cache so both
|
||||||
|
// surfaces re-fetch immediately. (See plan §5.3 / §5.5.)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berth-recommendations', interestId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interest-berths', interestId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||||
|
onAdded?.();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
mutation.mutate(choice === 'specific');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add berth {berth.mooringNumber} to interest</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose how this berth relates to the deal. This drives whether it shows as “Under
|
||||||
|
Offer” on the public map.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={choice}
|
||||||
|
onValueChange={(v) => setChoice(v as RoleChoice)}
|
||||||
|
className="gap-3"
|
||||||
|
>
|
||||||
|
<RoleCard
|
||||||
|
value="specific"
|
||||||
|
checked={choice === 'specific'}
|
||||||
|
title="Pitching specifically"
|
||||||
|
description="The client is pitched on this exact berth."
|
||||||
|
consequence="This berth will appear as under interest on the public map."
|
||||||
|
icon={<Eye className="size-4" />}
|
||||||
|
/>
|
||||||
|
<RoleCard
|
||||||
|
value="exploring"
|
||||||
|
checked={choice === 'exploring'}
|
||||||
|
title="Just exploring"
|
||||||
|
description="The berth is being considered or covered by the EOI bundle, but not pitched specifically."
|
||||||
|
consequence="This berth is hidden from the public map."
|
||||||
|
icon={<EyeOff className="size-4" />}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{mutation.isError ? (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{(mutation.error as Error)?.message ?? 'Failed to add berth.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleSubmit} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? <Loader2 className="mr-1.5 size-3.5 animate-spin" /> : null}
|
||||||
|
Add berth to interest
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleCardProps {
|
||||||
|
value: RoleChoice;
|
||||||
|
checked: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
consequence: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleCard({ value, checked, title, description, consequence, icon }: RoleCardProps) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
htmlFor={`role-${value}`}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||||
|
checked ? 'border-brand-300 bg-brand-50/50 ring-1 ring-brand-200' : 'border-border',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem value={value} id={`role-${value}`} className="mt-1" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-semibold">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
<p className="text-xs font-medium text-foreground/80">{consequence}</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ interface InterestData {
|
|||||||
} | null;
|
} | null;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
/** Yacht-fit dimensions (numeric strings from postgres). Drive the
|
||||||
|
* recommender panel guard ("Set desired dimensions to see recommendations"). */
|
||||||
|
desiredLengthFt: string | null;
|
||||||
|
desiredWidthFt: string | null;
|
||||||
|
desiredDraftFt: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { NotesList } from '@/components/shared/notes-list';
|
|||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { RecommendationList } from '@/components/interests/recommendation-list';
|
import { RecommendationList } from '@/components/interests/recommendation-list';
|
||||||
|
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
|
||||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
||||||
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
|
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
|
||||||
import { InterestFilesTab } from '@/components/interests/interest-files-tab';
|
import { InterestFilesTab } from '@/components/interests/interest-files-tab';
|
||||||
@@ -37,6 +38,10 @@ interface InterestTabsOptions {
|
|||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
interest: {
|
interest: {
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
|
/** Drives the recommender panel mounted on the Overview tab. */
|
||||||
|
desiredLengthFt?: string | null;
|
||||||
|
desiredWidthFt?: string | null;
|
||||||
|
desiredDraftFt?: string | null;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
eoiStatus: string | null;
|
eoiStatus: string | null;
|
||||||
@@ -306,6 +311,12 @@ function OverviewTab({
|
|||||||
activeMilestone = 'contract';
|
activeMilestone = 'contract';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toNum = (v: string | null | undefined): number | null => {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Sales-process milestones - the heart of the system. Each section is a
|
{/* Sales-process milestones - the heart of the system. Each section is a
|
||||||
@@ -498,6 +509,16 @@ function OverviewTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Berth recommender (plan §5.3) - always-mounted card driven by the
|
||||||
|
interest's desired dimensions. Renders an inline guidance message
|
||||||
|
when dimensions aren't set yet. */}
|
||||||
|
<BerthRecommenderPanel
|
||||||
|
interestId={interestId}
|
||||||
|
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
||||||
|
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
||||||
|
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user