feat(uat-batch): Group C Berth list features (3 new ships + 1 verified)

C20–C23 from the 2026-05-21 plan.

Shipped now:
  C21  Dimensions ft/m column toggle persisted to user prefs.
       `TablePreferences.dimensionUnit` ('ft' | 'm') added to the user-
       profiles JSONB. `useTablePreferences` returns `dimensionUnit` +
       `setDimensionUnit` alongside hidden/density. New
       `getBerthColumns(unit)` factory rewrites the dimensions /
       nominalBoatSize / waterDepth cells when ft is requested
       (waterDepth converts on-the-fly from the canonical meters
       column at 3.2808 ft/m). Berth-list toolbar gains a small
       ft/m toggle button next to the density toggle.
  C22  ft/m switching on Berth Requirements rows.
       `interest-tabs.tsx` Berth-requirements section now honours
       `interest.desiredLengthUnit`. Labels flip to "(m)" when set;
       value reads from `desired*M` columns; on save, both the chosen-
       unit and the canonical counterpart columns are PATCHed (3.28084
       ratio) so downstream surfaces (recommender, EOI merge fields)
       stay in lockstep. `InterestPatchField` widened with `desired*M`
       variants.
  C23  Berth list bulk-edit affordance.
       New `POST /api/v1/berths/bulk` (mirror of /interests/bulk):
       discriminated union of `change_status` / `change_tenure_type` /
       `add_tag` / `remove_tag` / `archive`, 500-id cap, per-row
       failure reporting, single `berths.edit` permission gate
       (no separate `archive` perm exists on berths today). Status
       mutations route through `updateBerthStatus` so under-offer /
       sold transitions still trigger the primary interest_berths
       auto-link + the rules-engine evaluation.
       BerthList toolbar wires `bulkActions` on the DataTable —
       Change status (Select dialog), Change tenure (permanent /
       fixed-term), Add tag, Remove tag, Archive (destructive +
       confirmation). Each dialog uses the same `bulkMutation` so
       toast + cache-invalidation behaviour is consistent across
       actions.

Already shipped (verified):
  C20  Berth list rates / pricing valid columns hidden by default —
       already in `BERTH_DEFAULT_HIDDEN`.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:22:30 +02:00
parent a0a4a5d487
commit 991e2223c7
6 changed files with 575 additions and 36 deletions

View File

@@ -0,0 +1,148 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths, berthTags } from '@/lib/db/schema/berths';
import {
archiveBerth,
updateBerth,
updateBerthStatus,
setBerthTags,
} from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
/**
* Synchronous bulk endpoint for the berths list — mirrors the
* /api/v1/interests/bulk shape so the rep-facing UX is consistent.
*
* Per-row loop with a 500-id cap. Bigger jobs belong on the BullMQ
* `bulk` queue; the synchronous path gives reps an immediate per-row
* failure list. Each row's mutation is independently transactional
* (the underlying service helpers wrap their own writes).
*/
const bulkSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('change_status'),
ids: z.array(z.string().min(1)).min(1).max(500),
status: z.enum(['available', 'under_offer', 'sold']),
}),
z.object({
action: z.literal('change_tenure_type'),
ids: z.array(z.string().min(1)).min(1).max(500),
tenureType: z.enum(['permanent', 'fixed_term']),
}),
z.object({
action: z.literal('add_tag'),
ids: z.array(z.string().min(1)).min(1).max(500),
tagId: z.string().min(1),
}),
z.object({
action: z.literal('remove_tag'),
ids: z.array(z.string().min(1)).min(1).max(500),
tagId: z.string().min(1),
}),
z.object({
action: z.literal('archive'),
ids: z.array(z.string().min(1)).min(1).max(500),
reason: z.string().max(500).optional(),
}),
]);
interface RowResult {
id: string;
ok: boolean;
error?: string;
}
// Berths share a single `edit` permission for non-price mutations (no
// separate `archive` perm today — sales-manager + super-admin own all
// edit paths).
const PERMISSION_BY_ACTION: Record<
z.infer<typeof bulkSchema>['action'],
{ resource: 'berths'; action: 'edit' }
> = {
change_status: { resource: 'berths', action: 'edit' },
change_tenure_type: { resource: 'berths', action: 'edit' },
add_tag: { resource: 'berths', action: 'edit' },
remove_tag: { resource: 'berths', action: 'edit' },
archive: { resource: 'berths', action: 'edit' },
};
export const POST = withAuth(async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
} catch (error) {
return errorResponse(error);
}
const perm = PERMISSION_BY_ACTION[body.action];
const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action];
if (!allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const results: RowResult[] = [];
for (const id of body.ids) {
try {
if (body.action === 'change_status') {
// Status mutations go through the dedicated path so the under-
// offer / sold transitions can auto-create the primary
// interest_berths link + emit the rules-engine evaluation.
await updateBerthStatus(
id,
ctx.portId,
{ status: body.status, reason: 'Bulk status change' },
meta,
);
} else if (body.action === 'change_tenure_type') {
await updateBerth(id, ctx.portId, { tenureType: body.tenureType }, meta);
} else if (body.action === 'archive') {
await archiveBerth(id, ctx.portId, { reason: body.reason ?? '' }, meta);
} else if (body.action === 'add_tag' || body.action === 'remove_tag') {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, ctx.portId)),
});
if (!berth) {
results.push({ id, ok: false, error: 'Not found' });
continue;
}
// Compose the new tag set, then re-write atomically.
const currentTags = await db
.select({ tagId: berthTags.tagId })
.from(berthTags)
.where(eq(berthTags.berthId, id));
const currentIds = new Set(currentTags.map((t) => t.tagId));
if (body.action === 'add_tag') currentIds.add(body.tagId);
else currentIds.delete(body.tagId);
await setBerthTags(id, ctx.portId, Array.from(currentIds), meta);
}
results.push({ id, ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
results.push({ id, ok: false, error: message });
}
}
const okCount = results.filter((r) => r.ok).length;
return NextResponse.json({
data: {
action: body.action,
total: results.length,
ok: okCount,
failed: results.length - okCount,
results,
},
});
});