Files
pn-new-crm/src/components/berths/berth-detail-header.tsx
Matt 15a139e86f
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 6m49s
feat(berths): website auto-promote toggle + manual-override soft-pin priority
- website_berth_autopromote_enabled (default OFF): a website registration for a
  specific, currently-available berth auto-creates a prospect (client + optional
  yacht + interest) and links the berth is_specific_interest=true, flipping the
  public map to Under Offer; general/residence/contact submissions stay
  capture-only. Marks the submission converted so a rep never double-creates it.
- derivePublicStatus now honours a manual pin (soft pin): a manually-set status
  wins over the interest-derived Under Offer, but a real permanent tenancy or an
  explicit sold still override it.
- berth rules engine respects a manual pin EXCEPT for sale triggers (-> sold),
  so a confirmed sale still wins but soft auto-changes never stomp a pin.
- Reset-to-automatic action (service + API POST /berths/[id]/status/reset + UI)
  to drop a manual pin; lock badge on every manual override (list + detail);
  divergence banner prompting reset when a pinned-Available berth has a deal.
- migration stage map updated to the §4b signed-off mapping: GQI -> enquiry
  unless it named a berth/size marker (-> qualified); SQI -> qualified.

Tests: +public-berths soft-pin cases, +website-intake-promote helpers,
+migration GQI marker rule. 1582 unit/integration green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:10:04 +02:00

573 lines
20 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown, Lock, Pencil, RefreshCw, RotateCcw } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
import { PermissionGate } from '@/components/shared/permission-gate';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthForm } from './berth-form';
import { mooringLetterDot } from './mooring-letter-tone';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES, stageBadgeClass, stageDotClass, stageLabel } from '@/lib/constants';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
export type BerthDetailData = {
id: string;
mooringNumber: string;
area: string | null;
status: string;
portId: string;
lengthFt: string | null;
lengthM: string | null;
widthFt: string | null;
widthM: string | null;
draftFt: string | null;
draftM: string | null;
widthIsMinimum: boolean | null;
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
bowFacing: string | null;
price: string | null;
priceCurrency: string;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null;
statusLastChangedReason: string | null;
statusLastModified: string | null;
/** 'manual' = a human pinned this status (wins over derived signals);
* 'automated' = set by the rules engine; null = pure derived. */
statusOverrideMode: string | null;
/** Pipeline stage of the most recent active interest on this berth, if any.
* Used to flag a manual pin that diverges from an active deal. */
latestInterestStage: string | null;
tags: Array<{ id: string; name: string; color: string }>;
};
interface BerthDetailHeaderProps {
berth: BerthDetailData;
}
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
/** Used to sort the picker - most recently interacted with floats to the top. */
updatedAt?: string;
}
function StatusChangeDialog({
berthId,
currentStatus,
open,
onOpenChange,
}: {
berthId: string;
currentStatus: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const reasonChips = useVocabulary('berth_status_change_reasons');
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<UpdateBerthStatusInput>({
resolver: zodResolver(updateBerthStatusSchema),
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
const status = watch('status');
const interestId = watch('interestId');
const showInterestPicker = status === 'under_offer' || status === 'sold';
// Active interests for this port - used to populate the prospect
// selector when status moves to under_offer / sold. Only fetched when
// the picker is actually visible to avoid an unnecessary round-trip
// for available-status changes.
const interestsQuery = useQuery<{
data: Array<{
id: string;
clientName: string;
pipelineStage: string;
updatedAt?: string;
}>;
}>({
queryKey: ['interests', 'status-link-picker'],
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
enabled: open && showInterestPicker,
staleTime: 60_000,
});
const interestOptions: InterestOption[] = interestsQuery.data?.data ?? [];
async function onSubmit(data: UpdateBerthStatusInput) {
try {
await apiFetch(`/api/v1/berths/${berthId}/status`, {
method: 'PATCH',
body: data,
});
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
toast.success('Status updated');
reset();
onOpenChange(false);
} catch (err: unknown) {
toastError(err);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Status</DialogTitle>
</DialogHeader>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
<FormErrorSummary
errors={errors}
labels={{ status: 'Status', reason: 'Reason', interestId: 'Linked interest' }}
/>
<div className="space-y-2">
<Label>New Status</Label>
<Select
value={status}
onValueChange={(v) => {
setValue('status', v as (typeof BERTH_STATUSES)[number]);
// Clear the interest pick when moving back to available so
// a stale value doesn't sneak through on submit.
if (v === 'available') setValue('interestId', undefined);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BERTH_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
{reasonChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{reasonChips.map((chip) => (
<button
type="button"
key={chip}
onClick={() => setValue('reason', chip, { shouldDirty: true })}
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
>
{chip}
</button>
))}
</div>
)}
<Textarea {...register('reason')} placeholder="Reason for status change..." rows={3} />
</div>
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked interest (optional)</Label>
<InterestLinkPicker
value={interestId ?? null}
options={interestOptions}
onChange={(id) => setValue('interestId', id ?? undefined)}
/>
<p className="text-xs text-muted-foreground">
Link this status change to the interest it relates to. The change will appear on
that interest&apos;s timeline, and the berth gets attached to it automatically if it
wasn&apos;t already.
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Update Status'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
/**
* Confirm dialog for "Reset to automatic" - drops the manual pin so the berth
* resumes derived status. A reason is required (audit trail). An optional
* `defaultReason` pre-fills the box for the one-click banner path.
*/
function ResetOverrideDialog({
berthId,
open,
onOpenChange,
defaultReason = '',
}: {
berthId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
defaultReason?: string;
}) {
const queryClient = useQueryClient();
const [reason, setReason] = useState(defaultReason);
const resetMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/berths/${berthId}/status/reset`, {
method: 'POST',
body: { reason: reason.trim() || 'Resumed automatic status' },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
toast.success('Berth reset to automatic status');
onOpenChange(false);
},
onError: (err) => toastError(err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset to automatic status</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
This removes the manual pin so the berth&apos;s public status is governed by its
interests and tenancies again. The displayed status may change immediately.
</p>
<div className="space-y-2">
<Label>Reason *</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Why are you dropping the manual pin?"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || reason.trim().length === 0}
>
Reset to automatic
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
const [editOpen, setEditOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const isManualPin = berth.statusOverrideMode === 'manual';
// Divergence the rep should resolve: a berth manually pinned "Available"
// that nevertheless has an active deal on it - the public map shows
// Available even though something is happening. Prompt to resume automatic
// (which derives "Under Offer" from the active specific-interest link).
const pinDivergesFromDeal =
isManualPin && berth.status === 'available' && !!berth.latestInterestStage;
return (
<>
<DetailHeaderStrip>
{/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="flex-1 min-w-0 flex items-center gap-3 flex-wrap">
{/* Compact mooring chip - the mooring number sits inside a
rounded plate tinted by the mooring-letter palette (same
colour used for the row-accent in the berth list). The
redundant "B Dock" tag from the previous design is replaced
with a title attribute so the area only surfaces on hover,
keeping the header lean. */}
<div
className={cn(
'inline-flex h-12 min-w-13 items-center justify-center rounded-2xl px-3 text-lg font-bold tracking-tight text-white shadow-sm',
mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400',
)}
title={berth.area ? `${berth.area} Dock` : undefined}
aria-label={`Berth ${berth.mooringNumber}${berth.area ? `, ${berth.area} Dock` : ''}`}
>
{berth.mooringNumber}
</div>
<StatusPill
status={BERTH_STATUS_PILL[berth.status] ?? 'pending'}
className="px-3 py-1 text-sm"
>
{STATUS_LABELS[berth.status] ?? berth.status}
</StatusPill>
{isManualPin && (
<span
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700"
title={
berth.statusLastChangedReason
? `Manually set status (wins over automatic). Reason: ${berth.statusLastChangedReason}`
: 'Manually set status - wins over automatic derivation'
}
>
<Lock className="h-3 w-3" aria-hidden />
Manual
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
<PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden />
Change Status
</Button>
{isManualPin && (
<Button variant="outline" size="sm" onClick={() => setResetOpen(true)}>
<RotateCcw className="mr-1.5 h-4 w-4" aria-hidden />
Reset to automatic
</Button>
)}
<Button size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-4 w-4" aria-hidden />
Edit
</Button>
</PermissionGate>
</div>
</div>
</DetailHeaderStrip>
{pinDivergesFromDeal && (
<PermissionGate resource="berths" action="edit">
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 sm:flex-row sm:items-center sm:justify-between">
<span>
This berth is manually pinned <strong>Available</strong>, but it has an active deal.
The public map will keep showing Available until you reset it.
</span>
<Button
size="sm"
variant="outline"
className="shrink-0 border-amber-400 bg-white hover:bg-amber-100"
onClick={() => setResetOpen(true)}
>
<RotateCcw className="mr-1.5 h-4 w-4" aria-hidden />
Reset to automatic
</Button>
</div>
</PermissionGate>
)}
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
<StatusChangeDialog
berthId={berth.id}
currentStatus={berth.status}
open={statusOpen}
onOpenChange={setStatusOpen}
/>
<ResetOverrideDialog
berthId={berth.id}
open={resetOpen}
onOpenChange={setResetOpen}
defaultReason={pinDivergesFromDeal ? 'Resumed automatic status - active deal present' : ''}
/>
</>
);
}
/**
* Searchable combobox for picking a linked prospect when changing berth
* status. Replaces the bare Select which had no filter, no stage colours,
* and no recency sort - for ports with 200+ active interests that became
* a scroll-fest. Stage labels render with the same coloured pill the rest
* of the CRM uses for stage badges so the rep can scan the list visually.
*/
function InterestLinkPicker({
value,
options,
onChange,
}: {
value: string | null;
options: InterestOption[];
onChange: (id: string | null) => void;
}) {
const [open, setOpen] = useState(false);
// Sort with the most recently updated interest first so reps see the
// active deals at the top of the list - older / dormant ones drop
// beneath. `updatedAt` is set on every patch + every stage advance.
const sorted = [...options].sort((a, b) => {
if (!a.updatedAt && !b.updatedAt) return 0;
if (!a.updatedAt) return 1;
if (!b.updatedAt) return -1;
return b.updatedAt.localeCompare(a.updatedAt);
});
const selected = value ? sorted.find((o) => o.id === value) : null;
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
>
{selected ? (
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
'inline-block h-2 w-2 shrink-0 rounded-full',
stageDotClass(selected.pipelineStage),
)}
aria-hidden
/>
<span className="truncate">{selected.clientName}</span>
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
· {stageLabel(selected.pipelineStage)}
</span>
</span>
) : (
' - No interest - '
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popper-anchor-width) min-w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="Search prospects…" />
<CommandList>
<CommandEmpty>No prospects found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
- No interest -
</CommandItem>
</CommandGroup>
<CommandGroup heading="Most recent first">
{sorted.map((opt) => (
<CommandItem
// cmdk filters by `value`; include client name + stage so the
// search input matches both. Falling back to id keeps the
// option selectable even if a name is blank.
key={opt.id}
value={`${opt.clientName} ${stageLabel(opt.pipelineStage)} ${opt.id}`}
onSelect={() => {
onChange(opt.id);
setOpen(false);
}}
className="flex items-center gap-2"
>
<span
className={cn(
'inline-block h-2 w-2 shrink-0 rounded-full',
stageDotClass(opt.pipelineStage),
)}
aria-hidden
/>
<span className="flex-1 truncate">{opt.clientName || '(unnamed)'}</span>
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium',
stageBadgeClass(opt.pipelineStage),
)}
>
{stageLabel(opt.pipelineStage)}
</span>
{value === opt.id ? (
<Check className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
) : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}