From d32e557e56af8153b10614496f2c5b89db8b25c3 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 17:13:34 +0200 Subject: [PATCH] feat(tenancies-renew-transfer): tenure-aware renewal + transfer actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/app/api/v1/tenancies/[id]/renew/route.ts | 26 +++ .../api/v1/tenancies/[id]/transfer/route.ts | 26 +++ src/components/tenancies/tenancy-detail.tsx | 46 +++- .../tenancies/tenancy-renew-dialog.tsx | 125 ++++++++++ .../tenancies/tenancy-transfer-dialog.tsx | 116 ++++++++++ src/lib/services/berth-tenancies.service.ts | 214 ++++++++++++++++++ src/lib/validators/tenancies.ts | 25 ++ 7 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 src/app/api/v1/tenancies/[id]/renew/route.ts create mode 100644 src/app/api/v1/tenancies/[id]/transfer/route.ts create mode 100644 src/components/tenancies/tenancy-renew-dialog.tsx create mode 100644 src/components/tenancies/tenancy-transfer-dialog.tsx diff --git a/src/app/api/v1/tenancies/[id]/renew/route.ts b/src/app/api/v1/tenancies/[id]/renew/route.ts new file mode 100644 index 00000000..5915d938 --- /dev/null +++ b/src/app/api/v1/tenancies/[id]/renew/route.ts @@ -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)); diff --git a/src/app/api/v1/tenancies/[id]/transfer/route.ts b/src/app/api/v1/tenancies/[id]/transfer/route.ts new file mode 100644 index 00000000..19eede6f --- /dev/null +++ b/src/app/api/v1/tenancies/[id]/transfer/route.ts @@ -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)); diff --git a/src/components/tenancies/tenancy-detail.tsx b/src/components/tenancies/tenancy-detail.tsx index 8ed232c2..63e687b6 100644 --- a/src/components/tenancies/tenancy-detail.tsx +++ b/src/components/tenancies/tenancy-detail.tsx @@ -4,7 +4,17 @@ import { useState } from 'react'; import Link from 'next/link'; import type { Route } from 'next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeft, Bell, Download, FileSignature, Mail, Pencil, StopCircle } from 'lucide-react'; +import { + ArrowLeft, + ArrowRightLeft, + Bell, + Download, + FileSignature, + Mail, + Pencil, + RefreshCw, + StopCircle, +} from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -25,6 +35,8 @@ import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { ClientLink, YachtLink, BerthLink } from '@/components/tenancies/tenancy-list'; import { TenancyEditDialog } from '@/components/tenancies/tenancy-edit-dialog'; +import { TenancyRenewDialog } from '@/components/tenancies/tenancy-renew-dialog'; +import { TenancyTransferDialog } from '@/components/tenancies/tenancy-transfer-dialog'; interface TenancyDoc { id: string; @@ -121,6 +133,8 @@ interface TenancyDetailProps { export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) { const [endDialogOpen, setEndDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); + const [renewDialogOpen, setRenewDialogOpen] = useState(false); + const [transferDialogOpen, setTransferDialogOpen] = useState(false); const tenancy = useQuery<{ data: TenancyData }>({ queryKey: ['tenancy', tenancyId], queryFn: () => apiFetch(`/api/v1/tenancies/${tenancyId}`), @@ -292,10 +306,20 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) { Edit {res.status === 'active' && ( - + <> + + + + )}