feat(tenancies-p6-followup): generic create dialog + edit dialog + self-FKs

- Migration 0086: berth_tenancies.previous_tenancy_id +
  transferred_from_tenancy_id self-FKs + partial indexes. Per
  docs/tenancies-design.md these chain renewal / transfer successors
  to predecessors for fixed-term and seasonal lineage. Schema mirrored
  in tenancies.ts with AnyPgColumn typed-import.
- POST /api/v1/tenancies (generic create): accepts berthId in the
  body so client + yacht tab entry points don't have to bounce through
  /api/v1/berths/[id]/tenancies. Same createPending service helper.
- TenancyCreateDialog: <TenancyCreateDialog open clientId? yachtId?
  berthId? /> with all three pickers; pre-fills the carrier from the
  parent entity. POSTs to /api/v1/tenancies; "Create" and
  "Create and activate" CTAs both wire to the new endpoint.
- Mounted on ClientTenanciesTab + YachtTenanciesTab behind
  <PermissionGate resource="tenancies" action="manage"> so reps can
  mint tenancies directly from those tabs without bouncing through
  the berth page.
- TenancyEditDialog: edit metadata only (start/end dates, tenure type,
  notes) via the new action='update' branch on the [id] PATCH route.
  Status transitions stay on activate/end/cancel. Wired into the
  tenancy detail page header. Outer wrapper unmounts on close so the
  form re-initialises from current row data without setState-in-effect.
- updateTenancy service helper + PATCH action='update' branch added.
  Audit-logged + emits berth_tenancy:activated to invalidate detail
  query caches.

Renew + Transfer dialogs deferred — both need lineage UX decisions
(tenure-aware mutate-in-place vs new-row spawn; client/yacht swap
semantics) and the self-FK columns this commit lands are the
underpinning. Next sub-task.

Verified: tsc clean, 1493/1493 vitest, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:10:06 +02:00
parent c4450dd852
commit 911b51a669
12 changed files with 689 additions and 13 deletions

View File

@@ -5,8 +5,15 @@ import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { requirePermission } from '@/lib/auth/permissions';
import { errorResponse } from '@/lib/errors';
import { activate, cancel, endTenancy, getById } from '@/lib/services/berth-tenancies.service';
import {
activate,
cancel,
endTenancy,
getById,
updateTenancy,
} from '@/lib/services/berth-tenancies.service';
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { TENURE_TYPES } from '@/lib/validators/tenancies';
// ─── PATCH body schema (action-based discriminated union) ────────────────────
@@ -25,6 +32,13 @@ const patchBodySchema = z.discriminatedUnion('action', [
action: z.literal('cancel'),
reason: z.string().optional(),
}),
z.object({
action: z.literal('update'),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().nullable().optional(),
tenureType: z.enum(TENURE_TYPES).optional(),
notes: z.string().nullable().optional(),
}),
]);
// ─── Handlers ────────────────────────────────────────────────────────────────
@@ -76,6 +90,22 @@ export const patchHandler: RouteHandler = async (req, ctx, params) => {
return NextResponse.json({ data: result });
}
if (body.action === 'update') {
requirePermission(ctx, 'tenancies', 'manage');
const result = await updateTenancy(
params.id!,
ctx.portId,
{
startDate: body.startDate,
endDate: body.endDate,
tenureType: body.tenureType,
notes: body.notes,
},
meta,
);
return NextResponse.json({ data: result });
}
// action === 'cancel'
requirePermission(ctx, 'tenancies', 'cancel');
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);

View File

@@ -1,11 +1,11 @@
import { NextResponse } from 'next/server';
import type { AuthContext } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listTenancies } from '@/lib/services/berth-tenancies.service';
import { createPending, listTenancies } from '@/lib/services/berth-tenancies.service';
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { listTenanciesSchema } from '@/lib/validators/tenancies';
import { createPendingSchema, listTenanciesSchema } from '@/lib/validators/tenancies';
/**
* Port-scoped global list of tenancies across all berths. Inner handler
@@ -35,3 +35,25 @@ export async function listHandler(req: Request, ctx: AuthContext): Promise<NextR
return errorResponse(error);
}
}
/**
* Generic create endpoint — accepts berthId in the body, so client / yacht
* tab entry points can mint a tenancy without bouncing through a
* berth-specific URL. Same service helper as POST /api/v1/berths/[id]/tenancies;
* the URL just differs in where berthId arrives from.
*/
export async function createHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
try {
await assertTenanciesModuleEnabled(ctx.portId);
const body = await parseBody(req as never, createPendingSchema);
const tenancy = await createPending(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: tenancy }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -1,4 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { listHandler } from './handlers';
import { createHandler, listHandler } from './handlers';
export const GET = withAuth(withPermission('tenancies', 'view', listHandler));
export const POST = withAuth(withPermission('tenancies', 'manage', createHandler));