3 Commits

Author SHA1 Message Date
Matt Ciaccio
21868ee5fc feat(berths,seed): polish detail display + prune ports to Port Nimara + Amador
Berth detail (src/components/berths/berth-tabs.tsx):
- Numeric display polish, exposed by the new NocoDB-sourced seed:
  - Power capacity now renders with kW unit (e.g. "330 kW")
  - Voltage now renders with V unit (e.g. "480 V")
  - All metric/imperial values rounded to <= 2 decimals
    (was: "62.999112 m" -> now: "62.99 m")
  - Nominal Boat Size shows full ft + m pair (was: ft only)

Seed ports (src/lib/db/seed.ts):
- Drop Marina Azzurra and Harbor Royale; install now seeds only:
  - Port Nimara  (the real install)
  - Port Amador  (secondary, for multi-tenant isolation tests / Panama
                  scaffolding)
- Existing dev DBs are not touched; this only affects fresh `pnpm db:seed`
  runs. Users wanting to migrate should drop existing rows in the obsolete
  ports manually before re-seeding.

Verification:
- lint clean, tsc unchanged from baseline (36 pre-existing errors), 858/858
  vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:59:36 +02:00
Matt Ciaccio
c7ab816c99 feat(seed): replace 12 hand-rolled berths with 117-row NocoDB snapshot
The old seed only had 12 berths with made-up area names ("North Pier",
"Central Basin", etc.) and placeholder dimensions. Devs now get the real
117 berths exported from the legacy NocoDB Berths table — every editable
column populated with real production values.

What's in the snapshot (src/lib/db/seed-data/berths.json):
- 117 berths total (61 available / 45 under_offer / 11 sold)
- Areas A through E (matches NocoDB single-select)
- All numeric fields filled: length / width / draft (ft + m), water depth,
  nominal boat size, power capacity (kW), voltage (V)
- All NocoDB single-selects filled where present: side pontoon,
  mooring type, cleat/bollard type+capacity, access
- Bow facing, status_override_mode, berth_approved carried forward as-is
- Status normalized to lowercase snake_case ("Under Offer" -> "under_offer")
- Mooring numbers reformatted A1 -> A-01 to keep the existing "Letter-NN"
  convention used elsewhere in the codebase

Pre-sorted to preserve seed semantics:
  idx 0..4   -> 5 available  (small)   -- "open" / "details_sent" interests
  idx 5..9   -> 5 under_offer (medium) -- "eoi_signed" / "deposit" / "contract"
  idx 10..11 -> 2 sold (large)         -- "completed" interests
This means existing interest/reservation seeds that index berthRows[0..11]
keep their semantic alignment without code changes.

End-to-end verified by clearing Marina Azzurra and re-seeding:
  Port "Marina Azzurra" -- 117 berths, 8 clients, 3 companies, 12 yachts,
                           15 interests, 8 reservations

Future devs running `pnpm db:seed` on a fresh DB will now get realistic
berth data automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:41:12 +02:00
Matt Ciaccio
e40b6c3d99 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>
2026-05-03 15:30:32 +02:00
13 changed files with 14444 additions and 235 deletions

View File

@@ -44,6 +44,17 @@ type BerthDetailData = {
draftFt: string | null; draftFt: string | null;
draftM: string | null; draftM: string | null;
widthIsMinimum: boolean | 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; price: string | null;
priceCurrency: string; priceCurrency: string;
tenureType: string; tenureType: string;

View File

@@ -16,18 +16,22 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths'; 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 { interface BerthFormProps {
berth: { berth: {
@@ -42,16 +46,27 @@ interface BerthFormProps {
draftFt: string | null; draftFt: string | null;
draftM: string | null; draftM: string | null;
widthIsMinimum: boolean | 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; price: string | null;
priceCurrency: string; priceCurrency: string;
tenureType: string; tenureType: string;
tenureYears: number | null; tenureYears: number | null;
tenureStartDate: string | null; tenureStartDate: string | null;
tenureEndDate: string | null; tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null; berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>; tags: Array<{ id: string; name: string; color: string }>;
}; };
@@ -59,10 +74,42 @@ interface BerthFormProps {
onOpenChange: (open: boolean) => void; 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) { export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id)); const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
const numOrUndef = (v: string | null) => (v != null && v !== '' ? Number(v) : undefined);
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -73,23 +120,34 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
resolver: zodResolver(updateBerthSchema), resolver: zodResolver(updateBerthSchema),
defaultValues: { defaultValues: {
area: berth.area ?? undefined, area: berth.area ?? undefined,
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined, lengthFt: numOrUndef(berth.lengthFt),
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined, lengthM: numOrUndef(berth.lengthM),
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined, widthFt: numOrUndef(berth.widthFt),
widthM: berth.widthM ? Number(berth.widthM) : undefined, widthM: numOrUndef(berth.widthM),
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined, draftFt: numOrUndef(berth.draftFt),
draftM: berth.draftM ? Number(berth.draftM) : undefined, draftM: numOrUndef(berth.draftM),
widthIsMinimum: berth.widthIsMinimum ?? false, 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, priceCurrency: berth.priceCurrency,
tenureType: berth.tenureType as 'permanent' | 'fixed_term', tenureType: berth.tenureType as 'permanent' | 'fixed_term',
tenureYears: berth.tenureYears ?? undefined, tenureYears: berth.tenureYears ?? undefined,
tenureStartDate: berth.tenureStartDate ?? undefined, tenureStartDate: berth.tenureStartDate ?? undefined,
tenureEndDate: berth.tenureEndDate ?? 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, berthApproved: berth.berthApproved ?? false,
}, },
}); });
@@ -120,6 +178,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
} }
const tenureType = watch('tenureType'); 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 ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
@@ -136,18 +202,18 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="area">Area</Label> <Label>Area</Label>
<Input id="area" {...register('area')} placeholder="e.g. Marina A" /> <SelectOrEmpty
value={area}
onChange={(v) => setValue('area', v)}
options={BERTH_AREAS}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="mooringType">Mooring Type</Label> <Label htmlFor="bowFacing">Bow Facing</Label>
<Input id="mooringType" {...register('mooringType')} /> <Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
</div> </div>
</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"> <div className="flex items-center gap-2">
<Switch <Switch
id="berthApproved" id="berthApproved"
@@ -168,29 +234,46 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Length (ft)</Label> <Label>Length (ft)</Label>
<Input type="number" step="0.1" {...register('lengthFt')} /> <Input type="number" step="0.01" {...register('lengthFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Length (m)</Label> <Label>Length (m)</Label>
<Input type="number" step="0.1" {...register('lengthM')} /> <Input type="number" step="0.01" {...register('lengthM')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Width (ft)</Label> <Label>Width (ft)</Label>
<Input type="number" step="0.1" {...register('widthFt')} /> <Input type="number" step="0.01" {...register('widthFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Width (m)</Label> <Label>Width (m)</Label>
<Input type="number" step="0.1" {...register('widthM')} /> <Input type="number" step="0.01" {...register('widthM')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Draft (ft)</Label> <Label>Draft (ft)</Label>
<Input type="number" step="0.1" {...register('draftFt')} /> <Input type="number" step="0.01" {...register('draftFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Draft (m)</Label> <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> </div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="widthIsMinimum" id="widthIsMinimum"
@@ -199,6 +282,107 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
/> />
<Label htmlFor="widthIsMinimum">Width is minimum</Label> <Label htmlFor="widthIsMinimum">Width is minimum</Label>
</div> </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> </div>
<Separator /> <Separator />
@@ -262,25 +446,6 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<Separator /> <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 */} {/* Tags */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">

View File

@@ -57,13 +57,45 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
} }
function OverviewTab({ berth }: { berth: BerthData }) { function OverviewTab({ berth }: { berth: BerthData }) {
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
const fmt = (v: string | null, fractionDigits = 2): string | null => {
if (v == null || v === '') return null;
const n = Number(v);
if (Number.isNaN(n)) return v;
return n.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
});
};
const formatDim = (ft: string | null, m: string | null) => { const formatDim = (ft: string | null, m: string | null) => {
const parts = []; const parts = [];
if (ft) parts.push(`${ft} ft`); const ftFmt = fmt(ft);
if (m) parts.push(`${m} m`); const mFmt = fmt(m);
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null; return parts.length > 0 ? parts.join(' / ') : null;
}; };
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
const ftFmt = fmt(ft, 0);
const mFmt = fmt(m);
const parts: string[] = [];
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null;
};
const formatPower = (kw: string | null) => {
const v = fmt(kw, 0);
return v ? `${v} kW` : null;
};
const formatVoltage = (v: string | null) => {
const fv = fmt(v, 0);
return fv ? `${fv} V` : null;
};
const price = berth.price const price = berth.price
? new Intl.NumberFormat('en-US', { ? new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
@@ -97,7 +129,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} /> <SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<SpecRow <SpecRow
label="Nominal Boat Size" label="Nominal Boat Size"
value={berth.nominalBoatSize || berth.nominalBoatSizeM} value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
/> />
<SpecRow <SpecRow
label="Water Depth" label="Water Depth"
@@ -122,8 +154,8 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle> <CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 divide-y"> <CardContent className="pt-0 divide-y">
<SpecRow label="Power Capacity" value={berth.powerCapacity} /> <SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
<SpecRow label="Voltage" value={berth.voltage} /> <SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
<SpecRow label="Cleat Type" value={berth.cleatType} /> <SpecRow label="Cleat Type" value={berth.cleatType} />
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} /> <SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
<SpecRow label="Bollard Type" value={berth.bollardType} /> <SpecRow label="Bollard Type" value={berth.bollardType} />

View File

@@ -123,6 +123,49 @@ export const BERTH_STATUSES = ['available', 'under_offer', 'sold'] as const;
export type BerthStatus = (typeof BERTH_STATUSES)[number]; export type BerthStatus = (typeof BERTH_STATUSES)[number];
// ─── Berth single-select catalogues (mirror NocoDB) ──────────────────────────
// Stored as free text in the DB so legacy values still load, but the form
// presents only the canonical options below.
export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const;
export const BERTH_SIDE_PONTOON_OPTIONS = [
'No',
'Quay SB',
'Quay PT',
'Quay SB, Yes PT',
'Quay PT, Yes SB',
'Yes SB',
'Yes PT',
'Yes SB, PT',
'Finger SB',
'Finger PT',
] as const;
export const BERTH_MOORING_TYPES = [
'Side Pier / Med Mooring',
'2x Med Mooring',
'Side Pier / Finger',
'Finger / Med Mooring',
'2x Finger',
] as const;
export const BERTH_CLEAT_TYPES = ['A3', 'A5'] as const;
export const BERTH_CLEAT_CAPACITIES = ['10-14 ton break load', '20-24 ton break load'] as const;
export const BERTH_BOLLARD_TYPES = ['Bull bollard type A', 'Bull bollard type B'] as const;
export const BERTH_BOLLARD_CAPACITIES = ['20 ton break load', '40 ton break load'] as const;
export const BERTH_ACCESS_OPTIONS = [
'Car to Vessel',
'Car to Quai, Cart to Vessel',
'Cart to Vessel',
'Car (3t) to Vessel',
'Car (3.5t) to Vessel',
] as const;
// ─── Lead Categories ───────────────────────────────────────────────────────── // ─── Lead Categories ─────────────────────────────────────────────────────────
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const; export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;

View File

@@ -0,0 +1,15 @@
-- Convert text columns to numeric. NULLs survive; empty strings become NULL;
-- whitespace is trimmed before casting so legacy data with stray spaces converts cleanly.
ALTER TABLE "berths"
ALTER COLUMN "nominal_boat_size" SET DATA TYPE numeric
USING NULLIF(TRIM("nominal_boat_size"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "nominal_boat_size_m" SET DATA TYPE numeric
USING NULLIF(TRIM("nominal_boat_size_m"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "power_capacity" SET DATA TYPE numeric
USING NULLIF(TRIM("power_capacity"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "voltage" SET DATA TYPE numeric
USING NULLIF(TRIM("voltage"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths" ADD COLUMN "status_override_mode" text;

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,13 @@
"when": 1777671562738, "when": 1777671562738,
"tag": "0019_lazy_vampiro", "tag": "0019_lazy_vampiro",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1777814682110,
"tag": "0020_medical_betty_brant",
"breakpoints": true
} }
] ]
} }

View File

@@ -33,14 +33,15 @@ export const berths = pgTable(
widthM: numeric('width_m'), widthM: numeric('width_m'),
draftM: numeric('draft_m'), draftM: numeric('draft_m'),
widthIsMinimum: boolean('width_is_minimum').default(false), widthIsMinimum: boolean('width_is_minimum').default(false),
nominalBoatSize: text('nominal_boat_size'), // Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
nominalBoatSizeM: text('nominal_boat_size_m'), nominalBoatSize: numeric('nominal_boat_size'),
nominalBoatSizeM: numeric('nominal_boat_size_m'),
waterDepth: numeric('water_depth'), waterDepth: numeric('water_depth'),
waterDepthM: numeric('water_depth_m'), waterDepthM: numeric('water_depth_m'),
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false), waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
sidePontoon: text('side_pontoon'), sidePontoon: text('side_pontoon'),
powerCapacity: text('power_capacity'), powerCapacity: numeric('power_capacity'), // kW
voltage: text('voltage'), voltage: numeric('voltage'), // V at 60Hz
mooringType: text('mooring_type'), mooringType: text('mooring_type'),
cleatType: text('cleat_type'), cleatType: text('cleat_type'),
cleatCapacity: text('cleat_capacity'), cleatCapacity: text('cleat_capacity'),
@@ -58,6 +59,9 @@ export const berths = pgTable(
statusLastChangedBy: text('status_last_changed_by'), // user ID statusLastChangedBy: text('status_last_changed_by'), // user ID
statusLastChangedReason: text('status_last_changed_reason'), statusLastChangedReason: text('status_last_changed_reason'),
statusLastModified: timestamp('status_last_modified', { withTimezone: true }), statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
// Reserved for future "manual override" semantics; not surfaced in the UI today.
statusOverrideMode: text('status_override_mode'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, },

View File

@@ -4,7 +4,13 @@
* Exports `seedPortData(portId, portSlug)` — creates a realistic, * Exports `seedPortData(portId, portSlug)` — creates a realistic,
* multi-cardinality data fixture for one port: * multi-cardinality data fixture for one port:
* *
* - 12 berths (5 available / 5 reserved-active / 2 sold) * - 117 berths imported from a snapshot of the legacy NocoDB Berths
* table (`src/lib/db/seed-data/berths.json`). The snapshot is reordered
* so the first 12 entries satisfy the index assumptions used further
* down for interest/reservation linkage:
* idx 0..4 — available (small)
* idx 5..9 — under_offer (medium)
* idx 10..11 — sold (large)
* - 3 companies (2 active, 1 dissolved) with primary billing addresses * - 3 companies (2 active, 1 dissolved) with primary billing addresses
* - 8 clients + contacts + primary addresses * - 8 clients + contacts + primary addresses
* - Memberships tying clients to companies (incl. multi-company + ended) * - Memberships tying clients to companies (incl. multi-company + ended)
@@ -39,6 +45,44 @@ import {
getStandardEoiTemplateHtml, getStandardEoiTemplateHtml,
STANDARD_EOI_MERGE_FIELDS, STANDARD_EOI_MERGE_FIELDS,
} from '@/lib/pdf/templates/eoi-standard-inapp'; } from '@/lib/pdf/templates/eoi-standard-inapp';
import berthSnapshot from './seed-data/berths.json';
// ─── Berth snapshot ──────────────────────────────────────────────────────────
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
// Refresh by re-running the snapshot script (see git history of this file).
type SeedBerth = {
legacyId: number;
mooringNumber: string;
legacyMooringNumber: string;
area: string | null;
status: 'available' | 'under_offer' | 'sold';
lengthFt: number | null;
widthFt: number | null;
draftFt: number | null;
lengthM: number | null;
widthM: number | null;
draftM: number | null;
widthIsMinimum: boolean;
nominalBoatSize: number | null;
nominalBoatSizeM: number | null;
waterDepth: number | null;
waterDepthM: number | null;
waterDepthIsMinimum: boolean;
sidePontoon: string | null;
powerCapacity: number | null;
voltage: number | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
price: number | null;
bowFacing: string | null;
berthApproved: boolean;
statusOverrideMode: string | null;
};
const BERTH_SNAPSHOT = berthSnapshot as SeedBerth[];
// ─── Tunables ──────────────────────────────────────────────────────────────── // ─── Tunables ────────────────────────────────────────────────────────────────
@@ -77,144 +121,44 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
return withTransaction(async (tx) => { return withTransaction(async (tx) => {
// ── 1. Berths ────────────────────────────────────────────────────────── // ── 1. Berths ──────────────────────────────────────────────────────────
// 12 berths: [0..4] available, [5..9] will be reserved-active, [10..11] sold. // 117 berths seeded from the legacy NocoDB Berths snapshot.
// We mark 5..9 as 'under_offer' (closest to "reserved via active reservation") // The JSON file is pre-sorted so the first 12 indexes satisfy the
// and 10..11 as 'sold'; 0..4 remain 'available'. // status semantics expected by the interest/reservation seeds:
const BERTH_SPECS: Array<{ // idx 0..4 available, idx 5..9 under_offer, idx 10..11 sold.
mooring: string;
area: string;
lengthM: string;
widthM: string;
draftM: string;
price: string;
status: 'available' | 'under_offer' | 'sold';
}> = [
{
mooring: 'A-01',
area: 'North Pier',
lengthM: '15',
widthM: '5',
draftM: '2.5',
price: '250000',
status: 'available',
},
{
mooring: 'A-02',
area: 'North Pier',
lengthM: '18',
widthM: '5.5',
draftM: '2.8',
price: '320000',
status: 'available',
},
{
mooring: 'A-03',
area: 'North Pier',
lengthM: '20',
widthM: '6',
draftM: '3.0',
price: '420000',
status: 'available',
},
{
mooring: 'B-01',
area: 'Central Basin',
lengthM: '25',
widthM: '7',
draftM: '3.5',
price: '580000',
status: 'available',
},
{
mooring: 'B-02',
area: 'Central Basin',
lengthM: '30',
widthM: '8',
draftM: '4.0',
price: '780000',
status: 'available',
},
{
mooring: 'B-03',
area: 'Central Basin',
lengthM: '35',
widthM: '8.5',
draftM: '4.2',
price: '950000',
status: 'under_offer',
},
{
mooring: 'C-01',
area: 'South Marina',
lengthM: '40',
widthM: '9',
draftM: '4.5',
price: '1250000',
status: 'under_offer',
},
{
mooring: 'C-02',
area: 'South Marina',
lengthM: '45',
widthM: '10',
draftM: '4.8',
price: '1600000',
status: 'under_offer',
},
{
mooring: 'C-03',
area: 'South Marina',
lengthM: '50',
widthM: '11',
draftM: '5.0',
price: '2100000',
status: 'under_offer',
},
{
mooring: 'D-01',
area: 'Superyacht Dock',
lengthM: '60',
widthM: '13',
draftM: '5.5',
price: '3200000',
status: 'under_offer',
},
{
mooring: 'D-02',
area: 'Superyacht Dock',
lengthM: '70',
widthM: '14',
draftM: '6.0',
price: '4500000',
status: 'sold',
},
{
mooring: 'D-03',
area: 'Superyacht Dock',
lengthM: '80',
widthM: '15',
draftM: '6.5',
price: '6800000',
status: 'sold',
},
];
const berthRows = await tx const berthRows = await tx
.insert(berths) .insert(berths)
.values( .values(
BERTH_SPECS.map((b) => ({ BERTH_SNAPSHOT.map((b) => ({
portId, portId,
mooringNumber: b.mooring, mooringNumber: b.mooringNumber,
area: b.area, area: b.area,
status: b.status, status: b.status,
lengthM: b.lengthM, lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
widthM: b.widthM, widthFt: b.widthFt != null ? String(b.widthFt) : null,
draftM: b.draftM, draftFt: b.draftFt != null ? String(b.draftFt) : null,
lengthFt: (Number(b.lengthM) * 3.28084).toFixed(2), lengthM: b.lengthM != null ? String(b.lengthM) : null,
widthFt: (Number(b.widthM) * 3.28084).toFixed(2), widthM: b.widthM != null ? String(b.widthM) : null,
draftFt: (Number(b.draftM) * 3.28084).toFixed(2), draftM: b.draftM != null ? String(b.draftM) : null,
price: b.price, widthIsMinimum: b.widthIsMinimum,
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
waterDepthIsMinimum: b.waterDepthIsMinimum,
sidePontoon: b.sidePontoon,
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
voltage: b.voltage != null ? String(b.voltage) : null,
mooringType: b.mooringType,
cleatType: b.cleatType,
cleatCapacity: b.cleatCapacity,
bollardType: b.bollardType,
bollardCapacity: b.bollardCapacity,
access: b.access,
price: b.price != null ? String(b.price) : null,
priceCurrency: 'USD', priceCurrency: 'USD',
bowFacing: b.bowFacing,
berthApproved: b.berthApproved,
statusOverrideMode: b.statusOverrideMode,
tenureType: 'permanent' as const, tenureType: 'permanent' as const,
})), })),
) )

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,16 @@
* Seed script for Port Nimara CRM. * Seed script for Port Nimara CRM.
* *
* Top-level orchestrator: * Top-level orchestrator:
* 1. Create 3 ports (idempotent): * 1. Create the operational ports (idempotent):
* - Port Nimara * - Port Nimara (primary install — the real marina)
* - Marina Azzurra * - Port Amador (secondary, kept for multi-tenant isolation tests
* - Harbor Royale * and as scaffolding for a future Panama install)
* 2. Create 5 system roles with full permission maps * 2. Create 5 system roles with full permission maps
* 3. Create the super admin user profile placeholder (matt@portnimara.com) * 3. Create the super admin user profile placeholder (matt@portnimara.com)
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts * 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
* to produce the realistic multi-cardinality fixture * to produce the realistic multi-cardinality fixture
* (berths, clients, companies, yachts, memberships, interests, * (117 berths from the NocoDB snapshot, plus clients, companies, yachts,
* reservations, ownership-transfer history). * memberships, interests, reservations, ownership-transfer history).
* 5. Print a summary. * 5. Print a summary.
* *
* Run with: pnpm db:seed * Run with: pnpm db:seed
@@ -186,7 +186,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
generate_eoi: true, generate_eoi: true,
export: true, export: true,
}, },
berths: { view: true, edit: false, import: false, manage_waiting_list: true }, berths: { view: true, edit: true, import: false, manage_waiting_list: true },
documents: { documents: {
view: true, view: true,
create: true, create: true,
@@ -260,7 +260,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
generate_eoi: true, generate_eoi: true,
export: true, export: true,
}, },
berths: { view: true, edit: false, import: false, manage_waiting_list: true }, berths: { view: true, edit: true, import: false, manage_waiting_list: true },
documents: { documents: {
view: true, view: true,
create: true, create: true,
@@ -413,19 +413,15 @@ const PORT_DEFINITIONS: Array<{
defaultCurrency: 'USD', defaultCurrency: 'USD',
timezone: 'America/Anguilla', timezone: 'America/Anguilla',
}, },
// Second port kept for multi-tenant isolation tests (cross-port scoping,
// permission boundaries). Drop or rename if the production install is
// single-port.
{ {
name: 'Marina Azzurra', name: 'Port Amador',
slug: 'marina-azzurra', slug: 'port-amador',
primaryColor: '#2E86AB', primaryColor: '#D97706',
defaultCurrency: 'EUR', defaultCurrency: 'USD',
timezone: 'Europe/Rome', timezone: 'America/Panama',
},
{
name: 'Harbor Royale',
slug: 'harbor-royale',
primaryColor: '#8B1E3F',
defaultCurrency: 'GBP',
timezone: 'Europe/London',
}, },
]; ];

View File

@@ -180,14 +180,14 @@ export async function updateBerth(
draftFt: n(data.draftFt), draftFt: n(data.draftFt),
draftM: n(data.draftM), draftM: n(data.draftM),
widthIsMinimum: data.widthIsMinimum, widthIsMinimum: data.widthIsMinimum,
nominalBoatSize: data.nominalBoatSize, nominalBoatSize: n(data.nominalBoatSize),
nominalBoatSizeM: data.nominalBoatSizeM, nominalBoatSizeM: n(data.nominalBoatSizeM),
waterDepth: n(data.waterDepth), waterDepth: n(data.waterDepth),
waterDepthM: n(data.waterDepthM), waterDepthM: n(data.waterDepthM),
waterDepthIsMinimum: data.waterDepthIsMinimum, waterDepthIsMinimum: data.waterDepthIsMinimum,
sidePontoon: data.sidePontoon, sidePontoon: data.sidePontoon,
powerCapacity: data.powerCapacity, powerCapacity: n(data.powerCapacity),
voltage: data.voltage, voltage: n(data.voltage),
mooringType: data.mooringType, mooringType: data.mooringType,
cleatType: data.cleatType, cleatType: data.cleatType,
cleatCapacity: data.cleatCapacity, cleatCapacity: data.cleatCapacity,
@@ -481,8 +481,8 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
priceCurrency: data.priceCurrency ?? 'USD', priceCurrency: data.priceCurrency ?? 'USD',
tenureType: data.tenureType ?? 'permanent', tenureType: data.tenureType ?? 'permanent',
mooringType: data.mooringType, mooringType: data.mooringType,
powerCapacity: data.powerCapacity, powerCapacity: data.powerCapacity?.toString(),
voltage: data.voltage, voltage: data.voltage?.toString(),
access: data.access, access: data.access,
bowFacing: data.bowFacing, bowFacing: data.bowFacing,
sidePontoon: data.sidePontoon, sidePontoon: data.sidePontoon,

View File

@@ -18,8 +18,8 @@ export const createBerthSchema = z.object({
status: z.enum(BERTH_STATUSES).default('available'), status: z.enum(BERTH_STATUSES).default('available'),
tenureType: z.enum(['permanent', 'fixed_term']).optional(), tenureType: z.enum(['permanent', 'fixed_term']).optional(),
mooringType: z.string().optional(), mooringType: z.string().optional(),
powerCapacity: z.string().optional(), powerCapacity: z.coerce.number().optional(), // kW
voltage: z.string().optional(), voltage: z.coerce.number().optional(), // V at 60Hz
access: z.string().optional(), access: z.string().optional(),
bowFacing: z.string().optional(), bowFacing: z.string().optional(),
sidePontoon: z.string().optional(), sidePontoon: z.string().optional(),
@@ -38,14 +38,14 @@ export const updateBerthSchema = z.object({
draftFt: z.coerce.number().optional(), draftFt: z.coerce.number().optional(),
draftM: z.coerce.number().optional(), draftM: z.coerce.number().optional(),
widthIsMinimum: z.boolean().optional(), widthIsMinimum: z.boolean().optional(),
nominalBoatSize: z.string().optional(), nominalBoatSize: z.coerce.number().optional(), // ft
nominalBoatSizeM: z.string().optional(), nominalBoatSizeM: z.coerce.number().optional(), // m
waterDepth: z.coerce.number().optional(), waterDepth: z.coerce.number().optional(),
waterDepthM: z.coerce.number().optional(), waterDepthM: z.coerce.number().optional(),
waterDepthIsMinimum: z.boolean().optional(), waterDepthIsMinimum: z.boolean().optional(),
sidePontoon: z.string().optional(), sidePontoon: z.string().optional(),
powerCapacity: z.string().optional(), powerCapacity: z.coerce.number().optional(), // kW
voltage: z.string().optional(), voltage: z.coerce.number().optional(), // V at 60Hz
mooringType: z.string().optional(), mooringType: z.string().optional(),
cleatType: z.string().optional(), cleatType: z.string().optional(),
cleatCapacity: z.string().optional(), cleatCapacity: z.string().optional(),