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' && ( - setEndDialogOpen(true)}> - - End tenancy - + <> + setRenewDialogOpen(true)}> + + Renew + + setTransferDialogOpen(true)}> + + Transfer + + setEndDialogOpen(true)}> + + End tenancy + + > )} @@ -370,6 +394,18 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) { initialTenureType={res.tenureType} initialNotes={res.notes} /> + + ); } diff --git a/src/components/tenancies/tenancy-renew-dialog.tsx b/src/components/tenancies/tenancy-renew-dialog.tsx new file mode 100644 index 00000000..a8ff5b1e --- /dev/null +++ b/src/components/tenancies/tenancy-renew-dialog.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Textarea } from '@/components/ui/textarea'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; + +const PERMANENT = new Set(['permanent', 'fee_simple', 'strata_lot']); + +interface TenancyRenewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tenancyId: string; + tenureType: string; + currentEndDate: string | Date | null; +} + +function toIso(d: string | Date | null | undefined): string { + if (!d) return ''; + return (d instanceof Date ? d : new Date(d)).toISOString().slice(0, 10); +} + +export function TenancyRenewDialog(props: TenancyRenewDialogProps) { + return props.open ? : null; +} + +function Inner({ + open, + onOpenChange, + tenancyId, + tenureType, + currentEndDate, +}: TenancyRenewDialogProps) { + const qc = useQueryClient(); + const today = new Date(); + const isPermanent = PERMANENT.has(tenureType); + const [newStartDate, setNewStartDate] = useState(toIso(currentEndDate ?? today)); + const [newEndDate, setNewEndDate] = useState(''); + const [notes, setNotes] = useState(''); + + const mutation = useMutation({ + mutationFn: async () => { + return apiFetch(`/api/v1/tenancies/${tenancyId}/renew`, { + method: 'POST', + body: { + newStartDate, + ...(newEndDate ? { newEndDate } : {}), + ...(notes.trim() ? { notes: notes.trim() } : {}), + }, + }); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tenancy', tenancyId] }); + qc.invalidateQueries({ queryKey: ['tenancies'] }); + toast.success(isPermanent ? 'Tenancy renewed in place' : 'Renewal created'); + onOpenChange(false); + }, + onError: (err) => toastError(err), + }); + + return ( + + + + Renew tenancy + + {isPermanent + ? 'Permanent tenancies are renewed in place — start and end dates update on the existing row.' + : 'A new tenancy row will be minted with previousTenancyId pointing to this one. The current row is ended at its existing end date.'} + + + + + New start date + + + + + New end date + {isPermanent ? ( + (optional) + ) : ( + (required) + )} + + + + + Notes (optional) + setNotes(e.target.value)} + /> + + + + onOpenChange(false)}> + Cancel + + mutation.mutate()} disabled={mutation.isPending}> + {mutation.isPending && } + Renew + + + + + ); +} diff --git a/src/components/tenancies/tenancy-transfer-dialog.tsx b/src/components/tenancies/tenancy-transfer-dialog.tsx new file mode 100644 index 00000000..04e3f42c --- /dev/null +++ b/src/components/tenancies/tenancy-transfer-dialog.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Textarea } from '@/components/ui/textarea'; +import { ClientPicker } from '@/components/shared/client-picker'; +import { YachtPicker } from '@/components/yachts/yacht-picker'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; + +interface TenancyTransferDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tenancyId: string; +} + +export function TenancyTransferDialog(props: TenancyTransferDialogProps) { + return props.open ? : null; +} + +function Inner({ open, onOpenChange, tenancyId }: TenancyTransferDialogProps) { + const qc = useQueryClient(); + const [newClientId, setNewClientId] = useState(null); + const [newYachtId, setNewYachtId] = useState(null); + const [transferDate, setTransferDate] = useState(new Date().toISOString().slice(0, 10)); + const [notes, setNotes] = useState(''); + + const mutation = useMutation({ + mutationFn: async () => { + if (!newClientId) throw new Error('Pick a new client'); + if (!newYachtId) throw new Error('Pick a new yacht'); + return apiFetch(`/api/v1/tenancies/${tenancyId}/transfer`, { + method: 'POST', + body: { + newClientId, + newYachtId, + transferDate, + ...(notes.trim() ? { notes: notes.trim() } : {}), + }, + }); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tenancy', tenancyId] }); + qc.invalidateQueries({ queryKey: ['tenancies'] }); + toast.success('Tenancy transferred'); + onOpenChange(false); + }, + onError: (err) => toastError(err), + }); + + return ( + + + + Transfer tenancy + + Hand this berth to a new client + yacht. The current tenancy is ended at the transfer + date; a new active row is created with transferredFromTenancyId pointing back. + + + + + New client + + + + New yacht + + + + Transfer date + + + + Notes (optional) + setNotes(e.target.value)} + /> + + + + onOpenChange(false)}> + Cancel + + mutation.mutate()} disabled={mutation.isPending}> + {mutation.isPending && } + Transfer + + + + + ); +} diff --git a/src/lib/services/berth-tenancies.service.ts b/src/lib/services/berth-tenancies.service.ts index a3cba3fa..3ab1fb8b 100644 --- a/src/lib/services/berth-tenancies.service.ts +++ b/src/lib/services/berth-tenancies.service.ts @@ -18,6 +18,8 @@ import type { EndTenancyInput, CancelInput, ListTenanciesInput, + RenewTenancyInput, + TransferTenancyInput, UpdateTenancyInput, } from '@/lib/validators/tenancies'; @@ -404,6 +406,218 @@ export async function updateTenancy( return updated; } +// ─── Renew (tenure-aware) ──────────────────────────────────────────────────── + +const PERMANENT_TENURES = new Set(['permanent', 'fee_simple', 'strata_lot']); + +/** + * Renew an active tenancy. Per docs/tenancies-design.md: + * - permanent / fee_simple / strata_lot: mutate the existing row in + * place (one record forever) — startDate moves forward, endDate may + * extend or null out, notes append. + * - fixed_term / seasonal: end the current row at its existing + * endDate (or now if missing), then mint a successor row with + * previousTenancyId pointing back. Successor inherits berth / + * client / yacht / interestId / contractFileId; rep can re-edit + * via the standard update action. + */ +export async function renewTenancy( + tenancyId: string, + portId: string, + data: RenewTenancyInput, + meta: AuditMeta, +): Promise { + const existing = await loadScoped(tenancyId, portId); + if (existing.status !== 'active') { + throw new ValidationError( + `Cannot renew a ${existing.status} tenancy — renewals require an active record.`, + ); + } + + if (PERMANENT_TENURES.has(existing.tenureType)) { + // Mutate-in-place path. End date can extend (or be removed entirely). + const [updated] = await db + .update(berthTenancies) + .set({ + startDate: data.newStartDate, + endDate: data.newEndDate ?? null, + notes: data.notes + ? `${existing.notes ? `${existing.notes}\n` : ''}Renewal: ${data.notes}` + : existing.notes, + updatedAt: new Date(), + }) + .where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId))) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'berth_tenancy', + entityId: tenancyId, + oldValue: { startDate: existing.startDate, endDate: existing.endDate }, + newValue: { startDate: updated!.startDate, endDate: updated!.endDate, kind: 'renew' }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'berth_tenancy:activated', { + tenancyId, + berthId: updated!.berthId, + }); + return updated!; + } + + // Mint-successor path (fixed_term / seasonal). End the current row + // first so the partial unique index on (berthId, status='active') + // doesn't conflict when the new row activates. + if (!data.newEndDate) { + throw new ValidationError( + `Renewals on ${existing.tenureType} tenancies require a new end date.`, + ); + } + + return db.transaction(async (tx) => { + const closeDate = existing.endDate ?? new Date(); + await tx + .update(berthTenancies) + .set({ status: 'ended', endDate: closeDate, updatedAt: new Date() }) + .where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId))); + + const [inserted] = await tx + .insert(berthTenancies) + .values({ + portId, + berthId: existing.berthId, + clientId: existing.clientId, + yachtId: existing.yachtId, + interestId: existing.interestId, + status: 'active', + startDate: data.newStartDate, + endDate: data.newEndDate, + tenureType: existing.tenureType, + contractFileId: existing.contractFileId, + previousTenancyId: tenancyId, + notes: data.notes + ? `Renewal of #${tenancyId.slice(0, 8)}: ${data.notes}` + : `Renewal of #${tenancyId.slice(0, 8)}`, + createdBy: meta.userId, + }) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'berth_tenancy', + entityId: inserted!.id, + newValue: { + previousTenancyId: tenancyId, + kind: 'renew_successor', + startDate: inserted!.startDate, + endDate: inserted!.endDate, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'berth_tenancy:created', { + tenancyId: inserted!.id, + berthId: inserted!.berthId, + }); + return inserted!; + }); +} + +// ─── Transfer (client/yacht swap) ──────────────────────────────────────────── + +/** + * Hand a berth's active tenancy to a new client / yacht pair. The + * current row is ended at `transferDate`, then a fresh active row is + * minted with `transferredFromTenancyId` pointing at the predecessor. + * Berth + tenure type + contractFileId carry over; interestId resets + * to null (the original deal is closed). + */ +export async function transferTenancy( + tenancyId: string, + portId: string, + data: TransferTenancyInput, + meta: AuditMeta, +): Promise { + const existing = await loadScoped(tenancyId, portId); + if (existing.status !== 'active') { + throw new ValidationError( + `Cannot transfer a ${existing.status} tenancy — transfers require an active record.`, + ); + } + + // Cross-validate new client + yacht live in this port. + const client = await db.query.clients.findFirst({ + where: and(eq(clients.id, data.newClientId), eq(clients.portId, portId)), + }); + if (!client) throw new ValidationError('new client not found in this port'); + const yacht = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, data.newYachtId), eq(yachts.portId, portId)), + }); + if (!yacht) throw new ValidationError('new yacht not found in this port'); + await assertClientOwnsOrRepresentsYacht( + { currentOwnerType: yacht.currentOwnerType, currentOwnerId: yacht.currentOwnerId }, + data.newClientId, + ); + + return db.transaction(async (tx) => { + await tx + .update(berthTenancies) + .set({ status: 'ended', endDate: data.transferDate, updatedAt: new Date() }) + .where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId))); + + const [inserted] = await tx + .insert(berthTenancies) + .values({ + portId, + berthId: existing.berthId, + clientId: data.newClientId, + yachtId: data.newYachtId, + interestId: null, + status: 'active', + startDate: data.transferDate, + endDate: existing.endDate, + tenureType: existing.tenureType, + contractFileId: existing.contractFileId, + transferredFromTenancyId: tenancyId, + notes: data.notes + ? `Transferred from #${tenancyId.slice(0, 8)}: ${data.notes}` + : `Transferred from #${tenancyId.slice(0, 8)}`, + createdBy: meta.userId, + }) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'berth_tenancy', + entityId: inserted!.id, + newValue: { + transferredFromTenancyId: tenancyId, + kind: 'transfer', + previousClientId: existing.clientId, + newClientId: data.newClientId, + previousYachtId: existing.yachtId, + newYachtId: data.newYachtId, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'berth_tenancy:created', { + tenancyId: inserted!.id, + berthId: inserted!.berthId, + }); + return inserted!; + }); +} + // ─── Get ───────────────────────────────────────────────────────────────────── export async function getById(id: string, portId: string): Promise { diff --git a/src/lib/validators/tenancies.ts b/src/lib/validators/tenancies.ts index b52181b8..2cc7f7ee 100644 --- a/src/lib/validators/tenancies.ts +++ b/src/lib/validators/tenancies.ts @@ -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; +/** 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; + +/** 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; + export const listTenanciesSchema = baseListQuerySchema.extend({ status: z.enum(TENANCY_STATUSES).optional(), berthId: z.string().optional(),