Files
pn-new-crm/src/components/berths/berth-form.tsx
Matt 689a114aba fix(audit-wave-9): copy/terminology sweep (copy-auditor)
Address the highest-impact items from the copy-auditor's CRITICAL +
HIGH + MEDIUM bands:

**C2 portal raw-status leak**
- Drop the staff-only `leadCategory` chip from the portal interests
  page entirely. Privacy + optics: clients should never see "hot lead"
  in their own portal. `eoiStatus` was already wrapped in
  `portalSigningLabel`; only the categorical chip remained.

**C3 signing-status label drift**
- Add `src/lib/labels/document-status.ts` as the single source of
  truth for the {draft, sent, partially_signed, completed, expired,
  cancelled} lifecycle: labels (CRM + portal variants), StatusPill
  variant, and the "active / in-flight" set.
- Wire it into interest-eoi-tab, interest-contract-tab,
  interest-reservation-tab — they previously redefined identical
  STATUS_LABELS / ACTIVE_STATUSES blocks per-file.

**H1 + M3 verbiage codemod**
- `Save Changes` → `Save changes` (sentence case, matches the
  surrounding admin/CRM pattern).
- `Saving...` (ASCII three dots) → `Saving…` (Unicode ellipsis).
  Matches the project's UTF-8-elsewhere convention and reads
  correctly via screen-readers.

**M1 envelope jargon → signing request**
- smart-archive-dialog: "Leave envelope pending" → "Leave signing
  request pending"; "Void the signing envelope" → "Cancel the signing
  request"; section header updated to match.
- document-detail: "voids the signing envelope" → "cancels the signing
  request".
- bulk-archive-wizard: "leave invoices/signing envelopes alone" →
  "leave invoices/signing requests alone".
- Documenso admin page intentionally keeps `envelope` (dev/integration
  vocabulary).

**M5 Hot Lead casing**
- Normalize `Hot Lead` / `General Interest` / `Specific Qualified` to
  sentence case in `constants.ts` LABEL_OVERRIDES and all per-file
  lead-category maps so the CRM trend (sentence case) is consistent.

**C1 surface-level rename**
- "Linked prospect (optional)" → "Linked interest (optional)" on the
  berth status-change dialog.
- "Deal Documents" tab → "Interest Documents" (URL/route kept as
  `/deal-documents` to avoid breaking deep links; rename deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:12:40 +02:00

485 lines
17 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { TagPicker } from '@/components/shared/tag-picker';
import { CurrencyInput } from '@/components/shared/currency-input';
import { CurrencySelect } from '@/components/shared/currency-select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { z } from 'zod';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
import {
BERTH_AREAS,
BERTH_BOW_FACING_OPTIONS,
BERTH_SIDE_PONTOON_OPTIONS,
BERTH_MOORING_TYPES,
BERTH_CLEAT_TYPES,
BERTH_CLEAT_CAPACITIES,
BERTH_BOLLARD_TYPES,
BERTH_BOLLARD_CAPACITIES,
BERTH_ACCESS_OPTIONS,
} from '@/lib/constants';
interface BerthFormProps {
berth: {
id: string;
mooringNumber: string;
area: string | null;
status: 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;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
bowFacing: string | null;
price: string | null;
priceCurrency: string;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>;
};
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** Optional select that allows clearing back to "no value". */
function SelectOrEmpty({
value,
onChange,
options,
placeholder = 'Select…',
}: {
value: string | undefined;
onChange: (next: string | undefined) => void;
options: readonly string[];
placeholder?: string;
}) {
const NONE = '__none';
return (
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? undefined : v)}>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>-</SelectItem>
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
const queryClient = useQueryClient();
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
const numOrUndef = (v: string | null) => (v != null && v !== '' ? Number(v) : undefined);
const {
register,
handleSubmit,
setValue,
watch,
formState: { isSubmitting },
} = useForm<z.input<typeof updateBerthSchema>, unknown, UpdateBerthInput>({
resolver: zodResolver(updateBerthSchema),
defaultValues: {
area: berth.area ?? undefined,
lengthFt: numOrUndef(berth.lengthFt),
lengthM: numOrUndef(berth.lengthM),
widthFt: numOrUndef(berth.widthFt),
widthM: numOrUndef(berth.widthM),
draftFt: numOrUndef(berth.draftFt),
draftM: numOrUndef(berth.draftM),
widthIsMinimum: berth.widthIsMinimum ?? false,
nominalBoatSize: numOrUndef(berth.nominalBoatSize),
nominalBoatSizeM: numOrUndef(berth.nominalBoatSizeM),
waterDepth: numOrUndef(berth.waterDepth),
waterDepthM: numOrUndef(berth.waterDepthM),
waterDepthIsMinimum: berth.waterDepthIsMinimum ?? false,
sidePontoon: berth.sidePontoon ?? undefined,
powerCapacity: numOrUndef(berth.powerCapacity),
voltage: numOrUndef(berth.voltage),
mooringType: berth.mooringType ?? undefined,
cleatType: berth.cleatType ?? undefined,
cleatCapacity: berth.cleatCapacity ?? undefined,
bollardType: berth.bollardType ?? undefined,
bollardCapacity: berth.bollardCapacity ?? undefined,
access: berth.access ?? undefined,
bowFacing: berth.bowFacing ?? undefined,
price: numOrUndef(berth.price),
priceCurrency: berth.priceCurrency,
tenureType: berth.tenureType as 'permanent' | 'fixed_term',
tenureYears: berth.tenureYears ?? undefined,
tenureStartDate: berth.tenureStartDate ?? undefined,
tenureEndDate: berth.tenureEndDate ?? undefined,
berthApproved: berth.berthApproved ?? false,
},
});
const tagMutation = useMutation({
mutationFn: (ids: string[]) =>
apiFetch(`/api/v1/berths/${berth.id}/tags`, {
method: 'PUT',
body: { tagIds: ids },
}),
});
async function onSubmit(data: UpdateBerthInput) {
try {
await apiFetch(`/api/v1/berths/${berth.id}`, {
method: 'PATCH',
body: data,
});
await tagMutation.mutateAsync(tagIds);
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berth.id] });
toast.success('Berth updated');
onOpenChange(false);
} catch (err: unknown) {
toastError(err);
}
}
const tenureType = watch('tenureType');
const area = watch('area');
const sidePontoon = watch('sidePontoon');
const mooringType = watch('mooringType');
const cleatType = watch('cleatType');
const cleatCapacity = watch('cleatCapacity');
const bollardType = watch('bollardType');
const bollardCapacity = watch('bollardCapacity');
const access = watch('access');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:w-[540px] sm:max-w-none overflow-y-auto">
<SheetHeader>
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 py-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Basic Info
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Area</Label>
<SelectOrEmpty
value={area}
onChange={(v) => setValue('area', v)}
options={BERTH_AREAS}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bowFacing">Bow Facing</Label>
<SelectOrEmpty
value={watch('bowFacing')}
onChange={(v) => setValue('bowFacing', v)}
options={BERTH_BOW_FACING_OPTIONS}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="berthApproved"
checked={watch('berthApproved') ?? false}
onCheckedChange={(v) => setValue('berthApproved', v)}
/>
<Label htmlFor="berthApproved">Berth Approved</Label>
</div>
</div>
<Separator />
{/* Dimensions */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Dimensions
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Length (ft)</Label>
<Input type="number" step="0.01" {...register('lengthFt')} />
</div>
<div className="space-y-2">
<Label>Length (m)</Label>
<Input type="number" step="0.01" {...register('lengthM')} />
</div>
<div className="space-y-2">
<Label>Width (ft)</Label>
<Input type="number" step="0.01" {...register('widthFt')} />
</div>
<div className="space-y-2">
<Label>Width (m)</Label>
<Input type="number" step="0.01" {...register('widthM')} />
</div>
<div className="space-y-2">
<Label>Draft (ft)</Label>
<Input type="number" step="0.01" {...register('draftFt')} />
</div>
<div className="space-y-2">
<Label>Draft (m)</Label>
<Input type="number" step="0.01" {...register('draftM')} />
</div>
<div className="space-y-2">
<Label>Nominal Boat Size (ft)</Label>
<Input type="number" step="1" {...register('nominalBoatSize')} />
</div>
<div className="space-y-2">
<Label>Nominal Boat Size (m)</Label>
<Input type="number" step="0.01" {...register('nominalBoatSizeM')} />
</div>
<div className="space-y-2">
<Label>Water Depth (ft)</Label>
<Input type="number" step="0.01" {...register('waterDepth')} />
</div>
<div className="space-y-2">
<Label>Water Depth (m)</Label>
<Input type="number" step="0.01" {...register('waterDepthM')} />
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2">
<Switch
id="widthIsMinimum"
checked={watch('widthIsMinimum') ?? false}
onCheckedChange={(v) => setValue('widthIsMinimum', v)}
/>
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="waterDepthIsMinimum"
checked={watch('waterDepthIsMinimum') ?? false}
onCheckedChange={(v) => setValue('waterDepthIsMinimum', v)}
/>
<Label htmlFor="waterDepthIsMinimum">Water depth is minimum</Label>
</div>
</div>
</div>
<Separator />
{/* Mooring & Hardware */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Mooring &amp; Hardware
</h3>
<div className="space-y-2">
<Label>Side Pontoon</Label>
<SelectOrEmpty
value={sidePontoon}
onChange={(v) => setValue('sidePontoon', v)}
options={BERTH_SIDE_PONTOON_OPTIONS}
/>
</div>
<div className="space-y-2">
<Label>Mooring Type</Label>
<SelectOrEmpty
value={mooringType}
onChange={(v) => setValue('mooringType', v)}
options={BERTH_MOORING_TYPES}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Cleat Type</Label>
<SelectOrEmpty
value={cleatType}
onChange={(v) => setValue('cleatType', v)}
options={BERTH_CLEAT_TYPES}
/>
</div>
<div className="space-y-2">
<Label>Cleat Capacity</Label>
<SelectOrEmpty
value={cleatCapacity}
onChange={(v) => setValue('cleatCapacity', v)}
options={BERTH_CLEAT_CAPACITIES}
/>
</div>
<div className="space-y-2">
<Label>Bollard Type</Label>
<SelectOrEmpty
value={bollardType}
onChange={(v) => setValue('bollardType', v)}
options={BERTH_BOLLARD_TYPES}
/>
</div>
<div className="space-y-2">
<Label>Bollard Capacity</Label>
<SelectOrEmpty
value={bollardCapacity}
onChange={(v) => setValue('bollardCapacity', v)}
options={BERTH_BOLLARD_CAPACITIES}
/>
</div>
</div>
</div>
<Separator />
{/* Power */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Power
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Power Capacity (kW)</Label>
<Input type="number" step="1" {...register('powerCapacity')} />
</div>
<div className="space-y-2">
<Label>Voltage (V at 60Hz)</Label>
<Input type="number" step="1" {...register('voltage')} />
</div>
</div>
</div>
<Separator />
{/* Access */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Access
</h3>
<SelectOrEmpty
value={access}
onChange={(v) => setValue('access', v)}
options={BERTH_ACCESS_OPTIONS}
/>
</div>
<Separator />
{/* Price */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Price
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Price</Label>
<CurrencyInput
value={(watch('price') as number | null | undefined) ?? ''}
currency={watch('priceCurrency') ?? 'USD'}
onChange={(v) => setValue('price', v ?? undefined, { shouldDirty: true })}
/>
</div>
<div className="space-y-2">
<Label>Currency</Label>
<CurrencySelect
value={watch('priceCurrency') ?? 'USD'}
onValueChange={(v) => setValue('priceCurrency', v, { shouldDirty: true })}
/>
</div>
</div>
</div>
<Separator />
{/* Tenure */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Tenure
</h3>
<div className="space-y-2">
<Label>Tenure Type</Label>
<Select
value={tenureType}
onValueChange={(v) => setValue('tenureType', v as 'permanent' | 'fixed_term')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="fixed_term">Fixed Term</SelectItem>
</SelectContent>
</Select>
</div>
{tenureType === 'fixed_term' && (
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Years</Label>
<Input type="number" {...register('tenureYears')} />
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" {...register('tenureStartDate')} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" {...register('tenureEndDate')} />
</div>
</div>
)}
</div>
<Separator />
{/* Tags */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Tags
</h3>
<TagPicker selectedIds={tagIds} onChange={setTagIds} />
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Save changes'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}