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

@@ -42,6 +42,31 @@ export const updateTenancySchema = z
.refine((d) => Object.keys(d).length > 0, { message: 'At least one field must be provided' });
export type UpdateTenancyInput = z.infer<typeof updateTenancySchema>;
/** Renewal input. For permanent-class tenancies the existing row is
* mutated in place; for fixed_term / seasonal the existing row is
* ended at `previousEndDate` and a fresh row is minted with
* previousTenancyId pointing back. */
export const renewTenancySchema = z.object({
/** New start date for the renewal window (or the mutated row when permanent). */
newStartDate: z.coerce.date(),
/** New end date — required for fixed_term / seasonal; optional when permanent. */
newEndDate: z.coerce.date().optional(),
/** Optional notes appended to the successor / mutated row. */
notes: z.string().optional(),
});
export type RenewTenancyInput = z.infer<typeof renewTenancySchema>;
/** Transfer input — hand a berth's active tenancy to a new client/yacht
* pair. The current row is ended, a fresh active row is minted with
* transferredFromTenancyId pointing at the predecessor. */
export const transferTenancySchema = z.object({
newClientId: z.string().min(1),
newYachtId: z.string().min(1),
transferDate: z.coerce.date(),
notes: z.string().optional(),
});
export type TransferTenancyInput = z.infer<typeof transferTenancySchema>;
export const listTenanciesSchema = baseListQuerySchema.extend({
status: z.enum(TENANCY_STATUSES).optional(),
berthId: z.string().optional(),