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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user