feat(bulk-berths): 2-step wizard for new-port setup
Step 5 per PRE-DEPLOY-PLAN § 1.4.13.
Service: bulkAddBerths(portId, inputs, meta) — input-level dedup
catches in-batch duplicates, then a single SELECT against existing
port rows rejects with ConflictError on first collision. All inserts
in one round-trip; audit log + realtime alert.
Validator: bulkAddBerthsSchema with min(1) max(500) per call.
Route: POST /api/v1/berths/bulk-add gated on berths.create.
Wizard UI (/[portSlug]/admin/berths/bulk-add):
Step 1 — dock letter A-E, range start+end mooring numbers, tenure
default. Generates N empty rows.
Step 2 — editable table with per-row dimensions / pontoon / pricing.
"Apply to all" inputs in the header row copy a value down every
row at once (covers the "every row is 40ft × 15ft at €125k" case
in two clicks). Per-row remove button.
Drag-fill deferred. Server-side mooring uniqueness check is canonical;
client-side dedup is a pre-flight courtesy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BulkAddBerthsWizard } from '@/components/admin/bulk-add-berths-wizard';
|
||||
|
||||
export default function BulkAddBerthsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Bulk add berths"
|
||||
description="Create many berths at once. Pick a dock letter + range to generate the rows, then fill in per-row dimensions / pricing / pontoon. Standard fields (tenure, status) apply to every row; everything else is per-row."
|
||||
/>
|
||||
<BulkAddBerthsWizard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/app/api/v1/berths/bulk-add/route.ts
Normal file
32
src/app/api/v1/berths/bulk-add/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { bulkAddBerths } from '@/lib/services/berths.service';
|
||||
import { bulkAddBerthsSchema } from '@/lib/validators/berths';
|
||||
|
||||
/**
|
||||
* POST /api/v1/berths/bulk-add
|
||||
*
|
||||
* Bulk-insert berths for new-port setup. Cap of 500 rows per call;
|
||||
* the service rejects with ConflictError on first duplicate (within
|
||||
* the input array or against existing port rows). Wizard UI lives at
|
||||
* /[portSlug]/admin/berths/bulk-add.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const input = await parseBody(req, bulkAddBerthsSchema);
|
||||
const result = await bulkAddBerths(ctx.portId, input.berths, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
394
src/components/admin/bulk-add-berths-wizard.tsx
Normal file
394
src/components/admin/bulk-add-berths-wizard.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Two-step wizard for bulk-creating berths during new-port setup.
|
||||
*
|
||||
* Step 1: pick the dock letter, the range (start..end mooring number),
|
||||
* and the genuinely-standard defaults (tenure, status). Generates one
|
||||
* empty row per mooring in the range.
|
||||
*
|
||||
* Step 2: editable table of the generated rows. Reps fill in per-row
|
||||
* dimensions / pontoon / pricing. "Apply to all" inputs at the top
|
||||
* of each column copy a value down. Validation is inline.
|
||||
*
|
||||
* Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch — left as a
|
||||
* follow-up; keyboard-friendly "Apply to all" covers most of the
|
||||
* speed win without the complexity.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
|
||||
type DockLetter = (typeof DOCK_LETTERS)[number];
|
||||
|
||||
const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', ''] as const;
|
||||
|
||||
interface RowDraft {
|
||||
mooringNumber: string;
|
||||
area: string;
|
||||
status: 'available';
|
||||
tenureType: 'permanent' | 'fixed_term';
|
||||
lengthFt: string;
|
||||
widthFt: string;
|
||||
draftFt: string;
|
||||
sidePontoon: string;
|
||||
price: string;
|
||||
priceCurrency: string;
|
||||
}
|
||||
|
||||
function genRange(letter: DockLetter, start: number, end: number): RowDraft[] {
|
||||
const out: RowDraft[] = [];
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
out.push({
|
||||
mooringNumber: `${letter}${i}`,
|
||||
area: letter,
|
||||
status: 'available',
|
||||
tenureType: 'permanent',
|
||||
lengthFt: '',
|
||||
widthFt: '',
|
||||
draftFt: '',
|
||||
sidePontoon: '',
|
||||
price: '',
|
||||
priceCurrency: 'USD',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function BulkAddBerthsWizard() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<'sequence' | 'edit'>('sequence');
|
||||
|
||||
// Step 1 state
|
||||
const [letter, setLetter] = useState<DockLetter>('A');
|
||||
const [rangeStart, setRangeStart] = useState('1');
|
||||
const [rangeEnd, setRangeEnd] = useState('10');
|
||||
const [tenure, setTenure] = useState<'permanent' | 'fixed_term'>('permanent');
|
||||
|
||||
// Step 2 state
|
||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||
|
||||
function handleGenerate() {
|
||||
const s = parseInt(rangeStart, 10);
|
||||
const e = parseInt(rangeEnd, 10);
|
||||
if (!Number.isFinite(s) || !Number.isFinite(e) || s < 0 || e < s) {
|
||||
toast.error('Invalid range');
|
||||
return;
|
||||
}
|
||||
if (e - s > 499) {
|
||||
toast.error('Cap is 500 berths per batch.');
|
||||
return;
|
||||
}
|
||||
const seeded = genRange(letter, s, e).map((r) => ({ ...r, tenureType: tenure }));
|
||||
setRows(seeded);
|
||||
setStep('edit');
|
||||
}
|
||||
|
||||
function setRowField<K extends keyof RowDraft>(idx: number, key: K, value: RowDraft[K]) {
|
||||
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [key]: value } : r)));
|
||||
}
|
||||
|
||||
function applyToAll<K extends keyof RowDraft>(key: K, value: RowDraft[K]) {
|
||||
setRows((prev) => prev.map((r) => ({ ...r, [key]: value })));
|
||||
}
|
||||
|
||||
function removeRow(idx: number) {
|
||||
setRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
berths: rows.map((r) => ({
|
||||
mooringNumber: r.mooringNumber,
|
||||
area: r.area,
|
||||
status: r.status,
|
||||
tenureType: r.tenureType,
|
||||
lengthFt: r.lengthFt ? Number(r.lengthFt) : undefined,
|
||||
widthFt: r.widthFt ? Number(r.widthFt) : undefined,
|
||||
draftFt: r.draftFt ? Number(r.draftFt) : undefined,
|
||||
price: r.price ? Number(r.price) : undefined,
|
||||
priceCurrency: r.priceCurrency || undefined,
|
||||
sidePontoon: r.sidePontoon || undefined,
|
||||
})),
|
||||
};
|
||||
const res = await apiFetch<{ data: { inserted: number } }>('/api/v1/berths/bulk-add', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Created ${data.inserted} berths`);
|
||||
router.push(`/${portSlug}/berths`);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
if (step === 'sequence') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Step 1 — Sequence</CardTitle>
|
||||
<CardDescription>
|
||||
Pick the dock letter and the mooring-number range. Tenure + status apply to every row;
|
||||
everything else (dimensions, pricing, pontoon) is filled per row in Step 2.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Dock letter</Label>
|
||||
<Select value={letter} onValueChange={(v) => setLetter(v as DockLetter)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCK_LETTERS.map((l) => (
|
||||
<SelectItem key={l} value={l}>
|
||||
{l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Range start</Label>
|
||||
<Input value={rangeStart} onChange={(e) => setRangeStart(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Range end</Label>
|
||||
<Input value={rangeEnd} onChange={(e) => setRangeEnd(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Tenure (all rows)</Label>
|
||||
<Select
|
||||
value={tenure}
|
||||
onValueChange={(v) => setTenure(v as 'permanent' | 'fixed_term')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="permanent">Permanent</SelectItem>
|
||||
<SelectItem value="fixed_term">Fixed term</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will generate {Math.max(0, parseInt(rangeEnd, 10) - parseInt(rangeStart, 10) + 1)} rows
|
||||
(e.g. {letter}
|
||||
{rangeStart} … {letter}
|
||||
{rangeEnd}).
|
||||
</p>
|
||||
<Button onClick={handleGenerate}>Continue →</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Step 2 — Fill in each row</CardTitle>
|
||||
<CardDescription>
|
||||
Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the
|
||||
header to copy a value down every row at once.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-2">Mooring</th>
|
||||
<th className="py-2 pr-2">Length (ft)</th>
|
||||
<th className="py-2 pr-2">Width (ft)</th>
|
||||
<th className="py-2 pr-2">Draft (ft)</th>
|
||||
<th className="py-2 pr-2">Side pontoon</th>
|
||||
<th className="py-2 pr-2">Price</th>
|
||||
<th className="py-2 pr-2">Currency</th>
|
||||
<th className="py-2 pr-2" />
|
||||
</tr>
|
||||
<tr className="border-b bg-muted/30">
|
||||
<td className="py-1 pr-2 text-[10px] text-muted-foreground">apply to all →</td>
|
||||
{(
|
||||
[
|
||||
['lengthFt', 'number'],
|
||||
['widthFt', 'number'],
|
||||
['draftFt', 'number'],
|
||||
] as const
|
||||
).map(([k, type]) => (
|
||||
<td key={k} className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type={type}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value) applyToAll(k, e.target.value);
|
||||
}}
|
||||
placeholder="all"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1 pr-2">
|
||||
<Select
|
||||
onValueChange={(v) => applyToAll('sidePontoon', v === '__none__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="all" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">(none)</SelectItem>
|
||||
{SIDE_PONTOON_OPTIONS.filter(Boolean).map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
onBlur={(e) => {
|
||||
if (e.target.value) applyToAll('price', e.target.value);
|
||||
}}
|
||||
placeholder="all"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
onBlur={(e) => {
|
||||
if (e.target.value) applyToAll('priceCurrency', e.target.value.toUpperCase());
|
||||
}}
|
||||
placeholder="all"
|
||||
/>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.mooringNumber} className="border-b last:border-b-0">
|
||||
<td className="py-1 pr-2 font-medium">{row.mooringNumber}</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
value={row.lengthFt}
|
||||
onChange={(e) => setRowField(idx, 'lengthFt', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
value={row.widthFt}
|
||||
onChange={(e) => setRowField(idx, 'widthFt', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
value={row.draftFt}
|
||||
onChange={(e) => setRowField(idx, 'draftFt', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Select
|
||||
value={row.sidePontoon || '__none__'}
|
||||
onValueChange={(v) =>
|
||||
setRowField(idx, 'sidePontoon', v === '__none__' ? '' : v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">—</SelectItem>
|
||||
{SIDE_PONTOON_OPTIONS.filter(Boolean).map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
value={row.price}
|
||||
onChange={(e) => setRowField(idx, 'price', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Input
|
||||
className="h-7 w-20 text-xs"
|
||||
value={row.priceCurrency}
|
||||
onChange={(e) =>
|
||||
setRowField(idx, 'priceCurrency', e.target.value.toUpperCase())
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => removeRow(idx)}
|
||||
aria-label={`Remove ${row.mooringNumber}`}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setStep('sequence')} disabled={mutation.isPending}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={mutation.isPending || rows.length === 0}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
`Create ${rows.length} berths`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -594,6 +594,80 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
|
||||
return berth!;
|
||||
}
|
||||
|
||||
// ─── Bulk add ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function bulkAddBerths(
|
||||
portId: string,
|
||||
inputs: CreateBerthInput[],
|
||||
meta: AuditMeta,
|
||||
): Promise<{ inserted: number; ids: string[] }> {
|
||||
// Input-level dedup: catch fat-finger duplicates in the wizard before
|
||||
// hitting the unique index.
|
||||
const seenMoorings = new Set<string>();
|
||||
for (const row of inputs) {
|
||||
if (seenMoorings.has(row.mooringNumber)) {
|
||||
throw new ConflictError(`Duplicate mooring number "${row.mooringNumber}" in input`);
|
||||
}
|
||||
seenMoorings.add(row.mooringNumber);
|
||||
}
|
||||
|
||||
const moorings = inputs.map((r) => r.mooringNumber);
|
||||
const existing = await db
|
||||
.select({ mooringNumber: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), inArray(berths.mooringNumber, moorings)));
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictError(
|
||||
`Mooring numbers already exist in this port: ${existing.map((r) => r.mooringNumber).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultCurrency = await getPortBerthsDefaultCurrency(portId);
|
||||
const values = inputs.map((row) => ({
|
||||
portId,
|
||||
mooringNumber: row.mooringNumber,
|
||||
area: row.area,
|
||||
status: row.status ?? 'available',
|
||||
lengthFt: row.lengthFt?.toString(),
|
||||
lengthM: row.lengthM?.toString(),
|
||||
widthFt: row.widthFt?.toString(),
|
||||
widthM: row.widthM?.toString(),
|
||||
draftFt: row.draftFt?.toString(),
|
||||
draftM: row.draftM?.toString(),
|
||||
price: row.price?.toString(),
|
||||
priceCurrency: row.priceCurrency ?? defaultCurrency,
|
||||
tenureType: row.tenureType ?? 'permanent',
|
||||
mooringType: row.mooringType,
|
||||
powerCapacity: row.powerCapacity?.toString(),
|
||||
voltage: row.voltage?.toString(),
|
||||
access: row.access,
|
||||
bowFacing: row.bowFacing,
|
||||
sidePontoon: row.sidePontoon,
|
||||
}));
|
||||
|
||||
const inserted = await db.insert(berths).values(values).returning({ id: berths.id });
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'berth',
|
||||
entityId: 'bulk',
|
||||
newValue: { count: inserted.length, mooringNumbers: moorings },
|
||||
metadata: { type: 'bulk_add' },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'berth:bulk_created',
|
||||
message: `${inserted.length} berths added`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return { inserted: inserted.length, ids: inserted.map((r) => r.id) };
|
||||
}
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
|
||||
@@ -27,6 +27,17 @@ export const createBerthSchema = z.object({
|
||||
|
||||
export type CreateBerthInput = z.infer<typeof createBerthSchema>;
|
||||
|
||||
// ─── Bulk Add Berths ─────────────────────────────────────────────────────────
|
||||
|
||||
export const bulkAddBerthsSchema = z.object({
|
||||
/** Per-row create payloads. Each row must carry a unique mooringNumber;
|
||||
* the service rejects with ConflictError on first duplicate (either
|
||||
* within the input array OR against an existing port row). */
|
||||
berths: z.array(createBerthSchema).min(1).max(500),
|
||||
});
|
||||
|
||||
export type BulkAddBerthsInput = z.infer<typeof bulkAddBerthsSchema>;
|
||||
|
||||
// ─── Update Berth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const updateBerthSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user