Files
pn-new-crm/src/components/berths/berth-form.tsx
Matt Ciaccio fc7595faf8 fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:

* 38 client components / 56 toast.error sites converted to
  toastError(err) so the new admin error inspector becomes usable from
  user-reported issues — every failed inline-edit, save, send, archive,
  upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
  the existing AppError subclasses.  Adds new error codes:
  DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
  DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
  IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
  UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
  post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
  (client-merge "already been merged", expense/interest "couldn't find
  that …", documenso "signing service didn't respond").

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).

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

470 lines
16 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 { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
import {
BERTH_AREAS,
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<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-[480px] sm:w-[540px] 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>
<Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
</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>
<Input type="number" step="0.01" {...register('price')} />
</div>
<div className="space-y-2">
<Label>Currency</Label>
<Input {...register('priceCurrency')} placeholder="USD" maxLength={3} />
</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>
);
}