feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,17 @@ type BerthDetailData = {
|
||||
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;
|
||||
|
||||
@@ -16,18 +16,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
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 { 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: {
|
||||
@@ -42,16 +46,27 @@ interface BerthFormProps {
|
||||
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;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
access: string | null;
|
||||
berthApproved: boolean | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
@@ -59,10 +74,42 @@ interface BerthFormProps {
|
||||
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,
|
||||
@@ -73,23 +120,34 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
resolver: zodResolver(updateBerthSchema),
|
||||
defaultValues: {
|
||||
area: berth.area ?? undefined,
|
||||
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined,
|
||||
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined,
|
||||
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined,
|
||||
widthM: berth.widthM ? Number(berth.widthM) : undefined,
|
||||
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined,
|
||||
draftM: berth.draftM ? Number(berth.draftM) : 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,
|
||||
price: berth.price ? Number(berth.price) : undefined,
|
||||
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,
|
||||
powerCapacity: berth.powerCapacity ?? undefined,
|
||||
voltage: berth.voltage ?? undefined,
|
||||
mooringType: berth.mooringType ?? undefined,
|
||||
access: berth.access ?? undefined,
|
||||
berthApproved: berth.berthApproved ?? false,
|
||||
},
|
||||
});
|
||||
@@ -120,6 +178,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
}
|
||||
|
||||
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}>
|
||||
@@ -136,18 +202,18 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area">Area</Label>
|
||||
<Input id="area" {...register('area')} placeholder="e.g. Marina A" />
|
||||
<Label>Area</Label>
|
||||
<SelectOrEmpty
|
||||
value={area}
|
||||
onChange={(v) => setValue('area', v)}
|
||||
options={BERTH_AREAS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mooringType">Mooring Type</Label>
|
||||
<Input id="mooringType" {...register('mooringType')} />
|
||||
<Label htmlFor="bowFacing">Bow Facing</Label>
|
||||
<Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access">Access</Label>
|
||||
<Input id="access" {...register('access')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="berthApproved"
|
||||
@@ -168,41 +234,159 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Length (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthFt')} />
|
||||
<Input type="number" step="0.01" {...register('lengthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Length (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthM')} />
|
||||
<Input type="number" step="0.01" {...register('lengthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthFt')} />
|
||||
<Input type="number" step="0.01" {...register('widthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthM')} />
|
||||
<Input type="number" step="0.01" {...register('widthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftFt')} />
|
||||
<Input type="number" step="0.01" {...register('draftFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftM')} />
|
||||
<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 items-center gap-2">
|
||||
<Switch
|
||||
id="widthIsMinimum"
|
||||
checked={watch('widthIsMinimum') ?? false}
|
||||
onCheckedChange={(v) => setValue('widthIsMinimum', v)}
|
||||
/>
|
||||
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
|
||||
<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 & 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">
|
||||
@@ -262,25 +446,6 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Infrastructure */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Infrastructure
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Power Capacity</Label>
|
||||
<Input {...register('powerCapacity')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Voltage</Label>
|
||||
<Input {...register('voltage')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
||||
Reference in New Issue
Block a user