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:
2026-05-14 15:45:06 +02:00
parent 4182652d49
commit 709ef350ff
5 changed files with 525 additions and 0 deletions

View File

@@ -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) {