Compare commits
3 Commits
feat/dedup
...
feat/berth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21868ee5fc | ||
|
|
c7ab816c99 | ||
|
|
e40b6c3d99 |
@@ -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,29 +234,46 @@ 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 flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="widthIsMinimum"
|
||||
@@ -199,6 +282,107 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
/>
|
||||
<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 />
|
||||
@@ -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">
|
||||
|
||||
@@ -57,13 +57,45 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
}
|
||||
|
||||
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 parts = [];
|
||||
if (ft) parts.push(`${ft} ft`);
|
||||
if (m) parts.push(`${m} m`);
|
||||
const ftFmt = fmt(ft);
|
||||
const mFmt = fmt(m);
|
||||
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||
if (mFmt) parts.push(`${mFmt} m`);
|
||||
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
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@@ -97,7 +129,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||
<SpecRow
|
||||
label="Nominal Boat Size"
|
||||
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
||||
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
||||
/>
|
||||
<SpecRow
|
||||
label="Water Depth"
|
||||
@@ -122,8 +154,8 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
||||
<SpecRow label="Voltage" value={berth.voltage} />
|
||||
<SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
|
||||
<SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
|
||||
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
||||
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
||||
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
||||
|
||||
@@ -123,6 +123,49 @@ export const BERTH_STATUSES = ['available', 'under_offer', 'sold'] as const;
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
|
||||
|
||||
15
src/lib/db/migrations/0020_medical_betty_brant.sql
Normal file
15
src/lib/db/migrations/0020_medical_betty_brant.sql
Normal 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;
|
||||
10246
src/lib/db/migrations/meta/0020_snapshot.json
Normal file
10246
src/lib/db/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
||||
"when": 1777671562738,
|
||||
"tag": "0019_lazy_vampiro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1777814682110,
|
||||
"tag": "0020_medical_betty_brant",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,14 +33,15 @@ export const berths = pgTable(
|
||||
widthM: numeric('width_m'),
|
||||
draftM: numeric('draft_m'),
|
||||
widthIsMinimum: boolean('width_is_minimum').default(false),
|
||||
nominalBoatSize: text('nominal_boat_size'),
|
||||
nominalBoatSizeM: text('nominal_boat_size_m'),
|
||||
// Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
|
||||
nominalBoatSize: numeric('nominal_boat_size'),
|
||||
nominalBoatSizeM: numeric('nominal_boat_size_m'),
|
||||
waterDepth: numeric('water_depth'),
|
||||
waterDepthM: numeric('water_depth_m'),
|
||||
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
|
||||
sidePontoon: text('side_pontoon'),
|
||||
powerCapacity: text('power_capacity'),
|
||||
voltage: text('voltage'),
|
||||
powerCapacity: numeric('power_capacity'), // kW
|
||||
voltage: numeric('voltage'), // V at 60Hz
|
||||
mooringType: text('mooring_type'),
|
||||
cleatType: text('cleat_type'),
|
||||
cleatCapacity: text('cleat_capacity'),
|
||||
@@ -58,6 +59,9 @@ export const berths = pgTable(
|
||||
statusLastChangedBy: text('status_last_changed_by'), // user ID
|
||||
statusLastChangedReason: text('status_last_changed_reason'),
|
||||
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(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* Exports `seedPortData(portId, portSlug)` — creates a realistic,
|
||||
* 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
|
||||
* - 8 clients + contacts + primary addresses
|
||||
* - Memberships tying clients to companies (incl. multi-company + ended)
|
||||
@@ -39,6 +45,44 @@ import {
|
||||
getStandardEoiTemplateHtml,
|
||||
STANDARD_EOI_MERGE_FIELDS,
|
||||
} 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -77,144 +121,44 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
|
||||
return withTransaction(async (tx) => {
|
||||
// ── 1. Berths ──────────────────────────────────────────────────────────
|
||||
// 12 berths: [0..4] available, [5..9] will be reserved-active, [10..11] sold.
|
||||
// We mark 5..9 as 'under_offer' (closest to "reserved via active reservation")
|
||||
// and 10..11 as 'sold'; 0..4 remain 'available'.
|
||||
const BERTH_SPECS: Array<{
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
// 117 berths seeded from the legacy NocoDB Berths snapshot.
|
||||
// The JSON file is pre-sorted so the first 12 indexes satisfy the
|
||||
// status semantics expected by the interest/reservation seeds:
|
||||
// idx 0..4 available, idx 5..9 under_offer, idx 10..11 sold.
|
||||
const berthRows = await tx
|
||||
.insert(berths)
|
||||
.values(
|
||||
BERTH_SPECS.map((b) => ({
|
||||
BERTH_SNAPSHOT.map((b) => ({
|
||||
portId,
|
||||
mooringNumber: b.mooring,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthM: b.lengthM,
|
||||
widthM: b.widthM,
|
||||
draftM: b.draftM,
|
||||
lengthFt: (Number(b.lengthM) * 3.28084).toFixed(2),
|
||||
widthFt: (Number(b.widthM) * 3.28084).toFixed(2),
|
||||
draftFt: (Number(b.draftM) * 3.28084).toFixed(2),
|
||||
price: b.price,
|
||||
lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
|
||||
widthFt: b.widthFt != null ? String(b.widthFt) : null,
|
||||
draftFt: b.draftFt != null ? String(b.draftFt) : null,
|
||||
lengthM: b.lengthM != null ? String(b.lengthM) : null,
|
||||
widthM: b.widthM != null ? String(b.widthM) : null,
|
||||
draftM: b.draftM != null ? String(b.draftM) : null,
|
||||
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',
|
||||
bowFacing: b.bowFacing,
|
||||
berthApproved: b.berthApproved,
|
||||
statusOverrideMode: b.statusOverrideMode,
|
||||
tenureType: 'permanent' as const,
|
||||
})),
|
||||
)
|
||||
|
||||
3746
src/lib/db/seed-data/berths.json
Normal file
3746
src/lib/db/seed-data/berths.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,16 @@
|
||||
* Seed script for Port Nimara CRM.
|
||||
*
|
||||
* Top-level orchestrator:
|
||||
* 1. Create 3 ports (idempotent):
|
||||
* - Port Nimara
|
||||
* - Marina Azzurra
|
||||
* - Harbor Royale
|
||||
* 1. Create the operational ports (idempotent):
|
||||
* - Port Nimara (primary install — the real marina)
|
||||
* - Port Amador (secondary, kept for multi-tenant isolation tests
|
||||
* and as scaffolding for a future Panama install)
|
||||
* 2. Create 5 system roles with full permission maps
|
||||
* 3. Create the super admin user profile placeholder (matt@portnimara.com)
|
||||
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
|
||||
* to produce the realistic multi-cardinality fixture
|
||||
* (berths, clients, companies, yachts, memberships, interests,
|
||||
* reservations, ownership-transfer history).
|
||||
* (117 berths from the NocoDB snapshot, plus clients, companies, yachts,
|
||||
* memberships, interests, reservations, ownership-transfer history).
|
||||
* 5. Print a summary.
|
||||
*
|
||||
* Run with: pnpm db:seed
|
||||
@@ -186,7 +186,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: 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: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -260,7 +260,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: 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: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -413,19 +413,15 @@ const PORT_DEFINITIONS: Array<{
|
||||
defaultCurrency: 'USD',
|
||||
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',
|
||||
slug: 'marina-azzurra',
|
||||
primaryColor: '#2E86AB',
|
||||
defaultCurrency: 'EUR',
|
||||
timezone: 'Europe/Rome',
|
||||
},
|
||||
{
|
||||
name: 'Harbor Royale',
|
||||
slug: 'harbor-royale',
|
||||
primaryColor: '#8B1E3F',
|
||||
defaultCurrency: 'GBP',
|
||||
timezone: 'Europe/London',
|
||||
name: 'Port Amador',
|
||||
slug: 'port-amador',
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -180,14 +180,14 @@ export async function updateBerth(
|
||||
draftFt: n(data.draftFt),
|
||||
draftM: n(data.draftM),
|
||||
widthIsMinimum: data.widthIsMinimum,
|
||||
nominalBoatSize: data.nominalBoatSize,
|
||||
nominalBoatSizeM: data.nominalBoatSizeM,
|
||||
nominalBoatSize: n(data.nominalBoatSize),
|
||||
nominalBoatSizeM: n(data.nominalBoatSizeM),
|
||||
waterDepth: n(data.waterDepth),
|
||||
waterDepthM: n(data.waterDepthM),
|
||||
waterDepthIsMinimum: data.waterDepthIsMinimum,
|
||||
sidePontoon: data.sidePontoon,
|
||||
powerCapacity: data.powerCapacity,
|
||||
voltage: data.voltage,
|
||||
powerCapacity: n(data.powerCapacity),
|
||||
voltage: n(data.voltage),
|
||||
mooringType: data.mooringType,
|
||||
cleatType: data.cleatType,
|
||||
cleatCapacity: data.cleatCapacity,
|
||||
@@ -481,8 +481,8 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
|
||||
priceCurrency: data.priceCurrency ?? 'USD',
|
||||
tenureType: data.tenureType ?? 'permanent',
|
||||
mooringType: data.mooringType,
|
||||
powerCapacity: data.powerCapacity,
|
||||
voltage: data.voltage,
|
||||
powerCapacity: data.powerCapacity?.toString(),
|
||||
voltage: data.voltage?.toString(),
|
||||
access: data.access,
|
||||
bowFacing: data.bowFacing,
|
||||
sidePontoon: data.sidePontoon,
|
||||
|
||||
@@ -18,8 +18,8 @@ export const createBerthSchema = z.object({
|
||||
status: z.enum(BERTH_STATUSES).default('available'),
|
||||
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
|
||||
mooringType: z.string().optional(),
|
||||
powerCapacity: z.string().optional(),
|
||||
voltage: z.string().optional(),
|
||||
powerCapacity: z.coerce.number().optional(), // kW
|
||||
voltage: z.coerce.number().optional(), // V at 60Hz
|
||||
access: z.string().optional(),
|
||||
bowFacing: z.string().optional(),
|
||||
sidePontoon: z.string().optional(),
|
||||
@@ -38,14 +38,14 @@ export const updateBerthSchema = z.object({
|
||||
draftFt: z.coerce.number().optional(),
|
||||
draftM: z.coerce.number().optional(),
|
||||
widthIsMinimum: z.boolean().optional(),
|
||||
nominalBoatSize: z.string().optional(),
|
||||
nominalBoatSizeM: z.string().optional(),
|
||||
nominalBoatSize: z.coerce.number().optional(), // ft
|
||||
nominalBoatSizeM: z.coerce.number().optional(), // m
|
||||
waterDepth: z.coerce.number().optional(),
|
||||
waterDepthM: z.coerce.number().optional(),
|
||||
waterDepthIsMinimum: z.boolean().optional(),
|
||||
sidePontoon: z.string().optional(),
|
||||
powerCapacity: z.string().optional(),
|
||||
voltage: z.string().optional(),
|
||||
powerCapacity: z.coerce.number().optional(), // kW
|
||||
voltage: z.coerce.number().optional(), // V at 60Hz
|
||||
mooringType: z.string().optional(),
|
||||
cleatType: z.string().optional(),
|
||||
cleatCapacity: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user