Files
pn-new-crm/src/components/interests/linked-berths-list.tsx
Matt 3ffee79f3f feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
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>
2026-05-12 14:50:58 +02:00

536 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
/**
* Linked-berths list — plan §5.5.
*
* Shows every berth currently linked to the interest with per-row controls:
* - "Specifically pitching" toggle (`is_specific_interest`) — drives the
* public-map "Under Offer" sub-status. Each state surfaces its consequence
* in plain text below the toggle.
* - "Mark in EOI bundle" toggle (`is_in_eoi_bundle`).
* - "Set as primary" button when this row isn't already primary. The
* service helper handles the demote-prior-primary case in a single tx.
* - "Bypass EOI for this berth" with a reason textarea. Only rendered when
* the parent interest's `eoiStatus === 'signed'`. Writes
* `eoi_bypass_reason`, `eoi_bypassed_by`, `eoi_bypassed_at`.
* - "Remove" — calls `removeInterestBerth`.
*/
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Anchor, Loader2, Star, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { HelpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
// ─── Types (mirror the API GET shape — see interest-berths.service.ts) ─────
export interface LinkedBerthRow {
id: string;
interestId: string;
berthId: string;
isPrimary: boolean;
isSpecificInterest: boolean;
isInEoiBundle: boolean;
eoiBypassReason: string | null;
eoiBypassedBy: string | null;
eoiBypassedAt: string | null;
addedBy: string | null;
addedAt: string;
notes: string | null;
mooringNumber: string | null;
area: string | null;
status: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
}
interface LinkedBerthsResponse {
data: LinkedBerthRow[];
meta: { eoiStatus: string | null };
}
interface LinkedBerthsListProps {
interestId: string;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
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: string | null,
width: string | null,
draft: string | null,
): string | null {
const parts: string[] = [];
const toNum = (v: string | null) => {
if (v === null) return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
};
const l = toNum(length);
const w = toNum(width);
const d = toNum(draft);
if (l !== null) parts.push(`${l.toFixed(1)}ft L`);
if (w !== null) parts.push(`${w.toFixed(1)}ft W`);
if (d !== null) parts.push(`${d.toFixed(1)}ft D`);
return parts.length > 0 ? parts.join(' · ') : null;
}
const SPECIFIC_CONSEQUENCE_ON = 'This berth will appear as under interest on the public map.';
const SPECIFIC_CONSEQUENCE_OFF = 'This berth is hidden from the public map.';
// ─── Hooks ──────────────────────────────────────────────────────────────────
function useLinkedBerths(interestId: string) {
return useQuery({
queryKey: ['interest-berths', interestId] as const,
queryFn: () => apiFetch<LinkedBerthsResponse>(`/api/v1/interests/${interestId}/berths`),
staleTime: 30_000,
});
}
interface PatchPayload {
isPrimary?: boolean;
isSpecificInterest?: boolean;
isInEoiBundle?: boolean;
eoiBypassReason?: string | null;
}
function useUpdateLink(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { berthId: string; patch: PatchPayload }) =>
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}`, {
method: 'PATCH',
body: args.patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
qc.invalidateQueries({ queryKey: ['interests', interestId] });
},
});
}
function useRemoveLink(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (berthId: string) =>
apiFetch(`/api/v1/interests/${interestId}/berths/${berthId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
qc.invalidateQueries({ queryKey: ['interests', interestId] });
qc.invalidateQueries({ queryKey: ['berth-recommendations', interestId] });
},
});
}
// ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps {
row: LinkedBerthRow;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (reason: string | null) => void;
isPending: boolean;
}
function BypassDialog({ row, open, onOpenChange, onSubmit, isPending }: BypassDialogProps) {
const [reason, setReason] = useState(row.eoiBypassReason ?? '');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bypass EOI for berth {row.mooringNumber}</DialogTitle>
<DialogDescription>
Record why this berth&apos;s individual EOI is being waived. The interest&apos;s primary
EOI signature will cover it instead.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`bypass-reason-${row.berthId}`} className="text-xs">
Reason
</Label>
<Textarea
id={`bypass-reason-${row.berthId}`}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. covered under bundle EOI signed 2025-04-12"
rows={4}
/>
</div>
<DialogFooter className="gap-2 sm:gap-2">
{row.eoiBypassReason ? (
<Button
type="button"
variant="outline"
onClick={() => onSubmit(null)}
disabled={isPending}
>
Clear bypass
</Button>
) : null}
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
type="button"
onClick={() => onSubmit(reason.trim().length > 0 ? reason.trim() : null)}
disabled={isPending || reason.trim().length === 0}
>
{isPending ? <Loader2 className="mr-1.5 size-3.5 animate-spin" /> : null}
Save bypass
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Row ────────────────────────────────────────────────────────────────────
interface RowProps {
row: LinkedBerthRow;
portSlug: string;
eoiStatus: string | null;
onUpdate: (berthId: string, patch: PatchPayload) => void;
onRemove: (berthId: string) => void;
isPending: boolean;
}
function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPending }: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed';
return (
<div
className={cn(
'rounded-lg border bg-card p-3 text-sm',
row.isPrimary ? 'border-brand-300 ring-1 ring-brand-200' : 'border-border',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/${portSlug}/berths/${row.berthId}`}
className="font-semibold text-primary hover:underline"
>
{row.mooringNumber ?? row.berthId}
</Link>
{row.area ? <span className="text-xs text-muted-foreground">{row.area}</span> : null}
<StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill>
{row.isPrimary ? (
<span className="inline-flex items-center gap-1 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-800">
<Star className="size-3" />
Primary
</span>
) : null}
{row.eoiBypassReason ? (
<span className="inline-flex items-center rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800">
EOI bypassed
</span>
) : null}
</div>
{dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{!row.isPrimary ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onUpdate(row.berthId, { isPrimary: true })}
disabled={isPending}
>
<Star className="mr-1.5 size-3.5" />
Set as primary
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setConfirmRemove(true)}
disabled={isPending}
className="text-destructive hover:text-destructive"
aria-label={`Remove berth ${row.mooringNumber ?? row.berthId}`}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<TooltipProvider delayDuration={200}>
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
<div className="space-y-1">
{/* Switch sits next to its label (gap-2.5) instead of being
flexed to the far right via justify-between — when the
column is wide, justify-between created a confusing visual
gulf between the action and what it controls. */}
<div className="flex items-center gap-2.5">
<Switch
id={`specific-${row.berthId}`}
checked={row.isSpecificInterest}
disabled={isPending}
onCheckedChange={(checked) =>
onUpdate(row.berthId, { isSpecificInterest: checked })
}
/>
<Label
htmlFor={`specific-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Specifically pitching
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="What does Specifically pitching do?"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Mark this berth as one your client is actively considering. When on, the berth
appears as <strong>Under Offer</strong> on the public map and counts toward the
recommender&apos;s &quot;heat&quot; score. Turn off if the link is legal/EOI-only.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<Switch
id={`bundle-${row.berthId}`}
checked={row.isInEoiBundle}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
/>
<Label
htmlFor={`bundle-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Mark in EOI bundle
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="What does Mark in EOI bundle do?"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Include this berth in the EOI&apos;s signed berth range. When on, the berth is
covered by the same signature and shows up in the EOI&apos;s
<strong> Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
to keep the link without legal coverage.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
{row.isInEoiBundle
? 'Covered by the interests EOI signature.'
: 'Not covered by the EOI bundle.'}
</p>
</div>
</div>
</TooltipProvider>
{showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't
// float far-right while the toggles above are anchored left.
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Bypass EOI for this berth</p>
{row.eoiBypassReason ? (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground/80">Bypassed:</span>{' '}
{row.eoiBypassReason}
</p>
) : (
<p className="text-xs text-muted-foreground">
Record a reason if this berth doesn&apos;t need its own EOI.
</p>
)}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setBypassOpen(true)}
disabled={isPending}
>
{row.eoiBypassReason ? 'Edit bypass' : 'Add bypass'}
</Button>
</div>
) : null}
{bypassOpen ? (
<BypassDialog
row={row}
open={bypassOpen}
onOpenChange={setBypassOpen}
isPending={isPending}
onSubmit={(reason) => {
onUpdate(row.berthId, { eoiBypassReason: reason });
setBypassOpen(false);
}}
/>
) : null}
<Dialog open={confirmRemove} onOpenChange={setConfirmRemove}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove berth {row.mooringNumber} from interest?</DialogTitle>
<DialogDescription>
The berth itself isn&apos;t deleted only its link to this interest.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<Button
type="button"
variant="outline"
onClick={() => setConfirmRemove(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={() => {
onRemove(row.berthId);
setConfirmRemove(false);
}}
disabled={isPending}
>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ─── Component ──────────────────────────────────────────────────────────────
export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useLinkedBerths(interestId);
const updateMutation = useUpdateLink(interestId);
const removeMutation = useRemoveLink(interestId);
const rows = data?.data ?? [];
const eoiStatus = data?.meta.eoiStatus ?? null;
const isPending = updateMutation.isPending || removeMutation.isPending;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Anchor className="size-4 text-brand-600" />
Linked berths{rows.length > 0 ? ` (${rows.length})` : ''}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="space-y-2">
{[0, 1].map((i) => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No berths linked yet. Use the recommender below to add one.
</p>
) : (
<div className="space-y-2">
{rows.map((row) => (
<LinkedBerthRowItem
key={row.id}
row={row}
portSlug={portSlug}
eoiStatus={eoiStatus}
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
onRemove={(berthId) => removeMutation.mutate(berthId)}
isPending={isPending}
/>
))}
</div>
)}
{updateMutation.isError ? (
<p className="text-sm text-destructive">
{(updateMutation.error as Error)?.message ?? 'Failed to update berth.'}
</p>
) : null}
{removeMutation.isError ? (
<p className="text-sm text-destructive">
{(removeMutation.error as Error)?.message ?? 'Failed to remove berth.'}
</p>
) : null}
</CardContent>
</Card>
);
}