feat(tenancies-renew-transfer): tenure-aware renewal + transfer actions

- renewTenancy service:
  - permanent / fee_simple / strata_lot → mutate-in-place (startDate
    moves forward, endDate may extend or null out)
  - fixed_term / seasonal → end the current row at its existing endDate
    + mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
  transferDate, mint fresh active row with transferredFromTenancyId
  pointing back. New client + yacht cross-validated against port +
  ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
  tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
  TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
  filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
  src/lib/validators/tenancies.ts.

Verified: tsc clean, 1493/1493 vitest.

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

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { renewTenancy } from '@/lib/services/berth-tenancies.service';
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { renewTenancySchema } from '@/lib/validators/tenancies';
const renewHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertTenanciesModuleEnabled(ctx.portId);
const body = await parseBody(req, renewTenancySchema);
const result = await renewTenancy(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
};
export const POST = withAuth(withPermission('tenancies', 'manage', renewHandler));

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { transferTenancy } from '@/lib/services/berth-tenancies.service';
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { transferTenancySchema } from '@/lib/validators/tenancies';
const transferHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertTenanciesModuleEnabled(ctx.portId);
const body = await parseBody(req, transferTenancySchema);
const result = await transferTenancy(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
};
export const POST = withAuth(withPermission('tenancies', 'manage', transferHandler));