Files
pn-new-crm/src/components/berths/berth-detail-header.tsx
Matt e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.

- next-intl ripped out (zero useTranslations callers ever existed):
  package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
  the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
  Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
  border-r/rounded-l-/rounded-r-) inside JSX className literals.
  Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
  label enabled at warn; 4 empty <th>/<td> action placeholders gain
  sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
  new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
  CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
  to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
  lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
  "Payments - Not received yet" or "Payments - \$X received - N payments
  - Expand"; per-interest collapse state persists in localStorage; the
  RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
  text-muted-foreground/{60,70,80} hits dropped to plain
  text-muted-foreground for AA contrast on muted bg. Icon-only
  (aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
  across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
  rewritten with cumulative state through today. Items genuinely still
  open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
  pixel E2E verification, and website-cutover work parked here so
  they don't get lost in the CRM audit doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:46 +02:00

441 lines
15 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown, Pencil, RefreshCw } from 'lucide-react';
import { 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;
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>
);
}
export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
const [editOpen, setEditOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
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>
</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>
<Button size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-4 w-4" aria-hidden />
Edit
</Button>
</PermissionGate>
</div>
</div>
</DetailHeaderStrip>
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
<StatusChangeDialog
berthId={berth.id}
currentStatus={berth.status}
open={statusOpen}
onOpenChange={setStatusOpen}
/>
</>
);
}
/**
* 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>
);
}