From 911b51a6690b5e1339d6b5d46f9faebd0bc54454 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 17:10:06 +0200 Subject: [PATCH] feat(tenancies-p6-followup): generic create dialog + edit dialog + self-FKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: 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 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) --- src/app/api/v1/tenancies/[id]/handlers.ts | 32 +- src/app/api/v1/tenancies/handlers.ts | 28 +- src/app/api/v1/tenancies/route.ts | 3 +- .../clients/client-tenancies-tab.tsx | 13 + .../tenancies/tenancy-create-dialog.tsx | 322 ++++++++++++++++++ src/components/tenancies/tenancy-detail.tsx | 17 +- .../tenancies/tenancy-edit-dialog.tsx | 155 +++++++++ src/components/yachts/yacht-tabs.tsx | 29 +- .../db/migrations/0086_tenancies_self_fks.sql | 14 + src/lib/db/schema/tenancies.ts | 25 +- src/lib/services/berth-tenancies.service.ts | 50 +++ src/lib/validators/tenancies.ts | 14 + 12 files changed, 689 insertions(+), 13 deletions(-) create mode 100644 src/components/tenancies/tenancy-create-dialog.tsx create mode 100644 src/components/tenancies/tenancy-edit-dialog.tsx create mode 100644 src/lib/db/migrations/0086_tenancies_self_fks.sql diff --git a/src/app/api/v1/tenancies/[id]/handlers.ts b/src/app/api/v1/tenancies/[id]/handlers.ts index 219c3eec..d6da5c1f 100644 --- a/src/app/api/v1/tenancies/[id]/handlers.ts +++ b/src/app/api/v1/tenancies/[id]/handlers.ts @@ -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); diff --git a/src/app/api/v1/tenancies/handlers.ts b/src/app/api/v1/tenancies/handlers.ts index a7bd5cd4..81dd8b8e 100644 --- a/src/app/api/v1/tenancies/handlers.ts +++ b/src/app/api/v1/tenancies/handlers.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/v1/tenancies/route.ts b/src/app/api/v1/tenancies/route.ts index 773118b8..fb2bea03 100644 --- a/src/app/api/v1/tenancies/route.ts +++ b/src/app/api/v1/tenancies/route.ts @@ -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)); diff --git a/src/components/clients/client-tenancies-tab.tsx b/src/components/clients/client-tenancies-tab.tsx index 1811d9ef..d03d50af 100644 --- a/src/components/clients/client-tenancies-tab.tsx +++ b/src/components/clients/client-tenancies-tab.tsx @@ -3,7 +3,11 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { Plus } from 'lucide-react'; + import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list'; +import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialog'; +import { PermissionGate } from '@/components/shared/permission-gate'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; @@ -26,6 +30,7 @@ interface TenancyListResponse { export function ClientTenanciesTab({ clientId, activeTenancies }: ClientTenanciesTabProps) { const [showHistory, setShowHistory] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const activeRows: TenancyRow[] = activeTenancies.map((r) => ({ id: r.id, @@ -73,6 +78,12 @@ export function ClientTenanciesTab({ clientId, activeTenancies }: ClientTenancie

Active tenancies

+ + +
)}
+ + ); } diff --git a/src/components/tenancies/tenancy-create-dialog.tsx b/src/components/tenancies/tenancy-create-dialog.tsx new file mode 100644 index 00000000..5c76a4df --- /dev/null +++ b/src/components/tenancies/tenancy-create-dialog.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ClientPicker } from '@/components/shared/client-picker'; +import { YachtPicker } from '@/components/yachts/yacht-picker'; +import { BerthPicker } from '@/components/shared/berth-picker'; +import { FormErrorSummary } from '@/components/forms/form-error-summary'; +import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error'; +import { apiFetch } from '@/lib/api/client'; + +type TenureType = 'permanent' | 'fixed_term' | 'seasonal'; + +type FormValues = { + berthId: string | null; + clientId: string | null; + yachtId: string | null; + startDate: string; + tenureType: TenureType; + notes?: string; +}; + +interface TenancyCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Entry-point context — whichever of these is set pre-fills the + * corresponding picker. The other pickers stay enabled so the rep + * can pick the remaining fields. Exactly one is typically set: + * berthId from the Berth Tenancies tab, clientId from the Client + * Tenancies tab, yachtId from the Yacht Tenancies tab. */ + berthId?: string; + clientId?: string; + yachtId?: string; +} + +/** + * Generic create-tenancy dialog. Accepts optional pre-fills for berth / + * client / yacht so it can be opened from any of the three entity tab + * surfaces. POSTs to /api/v1/tenancies (the generic endpoint added in + * the P6 follow-up). + */ +export function TenancyCreateDialog({ + open, + onOpenChange, + berthId: initialBerthId, + clientId: initialClientId, + yachtId: initialYachtId, +}: TenancyCreateDialogProps) { + const queryClient = useQueryClient(); + const [formError, setFormError] = useState(null); + + const { + register, + handleSubmit, + watch, + setValue, + reset, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + berthId: initialBerthId ?? null, + clientId: initialClientId ?? null, + yachtId: initialYachtId ?? null, + startDate: new Date().toISOString().slice(0, 10), + tenureType: 'permanent', + notes: '', + }, + }); + const submitWithScroll = useFormScrollToError(handleSubmit, errors); + + useEffect(() => { + if (open) { + setFormError(null); + reset({ + berthId: initialBerthId ?? null, + clientId: initialClientId ?? null, + yachtId: initialYachtId ?? null, + startDate: new Date().toISOString().slice(0, 10), + tenureType: 'permanent', + notes: '', + }); + } + }, [open, reset, initialBerthId, initialClientId, initialYachtId]); + + const berthId = watch('berthId'); + const clientId = watch('clientId'); + const yachtId = watch('yachtId'); + const tenureType = watch('tenureType'); + + // When client changes, clear yacht (since yacht-picker is filtered by + // owner). Stays unwired when the dialog opened from the yacht tab + // (initialYachtId pre-fills both yacht + the implicit client). + useEffect(() => { + if (!initialYachtId) setValue('yachtId', null); + }, [clientId, setValue, initialYachtId]); + + function validate(data: FormValues): string | null { + if (!data.berthId) return 'Please select a berth'; + if (!data.clientId) return 'Please select a client'; + if (!data.yachtId) return 'Please select a yacht'; + return null; + } + + async function createPending(data: FormValues): Promise<{ id: string }> { + const res = await apiFetch<{ data: { id: string } }>('/api/v1/tenancies', { + method: 'POST', + body: { + berthId: data.berthId!, + clientId: data.clientId!, + yachtId: data.yachtId!, + startDate: data.startDate, + tenureType: data.tenureType, + notes: data.notes?.trim() || undefined, + }, + }); + return res.data; + } + + const createMutation = useMutation({ + mutationFn: async (data: FormValues) => { + const err = validate(data); + if (err) throw new Error(err); + await createPending(data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tenancies'] }); + queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'tenancies'] }); + if (initialClientId) + queryClient.invalidateQueries({ queryKey: ['clients', initialClientId] }); + if (initialYachtId) + queryClient.invalidateQueries({ queryKey: ['tenancies', 'by-yacht', initialYachtId] }); + toast.success('Tenancy created'); + onOpenChange(false); + }, + onError: (err: unknown) => { + const msg = err instanceof Error ? err.message : 'Failed to create tenancy'; + setFormError(msg); + }, + }); + + const createAndActivateMutation = useMutation({ + mutationFn: async (data: FormValues) => { + const err = validate(data); + if (err) throw new Error(err); + const pending = await createPending(data); + await apiFetch(`/api/v1/tenancies/${pending.id}`, { + method: 'PATCH', + body: { action: 'activate' }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tenancies'] }); + queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'tenancies'] }); + if (initialClientId) + queryClient.invalidateQueries({ queryKey: ['clients', initialClientId] }); + if (initialYachtId) + queryClient.invalidateQueries({ queryKey: ['tenancies', 'by-yacht', initialYachtId] }); + toast.success('Tenancy created and activated'); + onOpenChange(false); + }, + onError: (err: unknown) => { + const msg = err instanceof Error ? err.message : 'Failed to activate'; + if (/active tenancy|conflict|409/i.test(msg)) { + setFormError( + 'This berth already has an active tenancy. The pending record was created — activate it manually once the other tenancy ends.', + ); + } else { + setFormError(msg); + } + }, + }); + + const isPending = isSubmitting || createMutation.isPending || createAndActivateMutation.isPending; + + return ( + + + + Create tenancy + + Create a pending tenancy or activate it immediately. + + + +
+ + + {!initialBerthId ? ( +
+ + setValue('berthId', id)} /> +
+ ) : null} + + {!initialClientId ? ( +
+ + setValue('clientId', id)} /> +
+ ) : null} + + {!initialYachtId ? ( +
+ + setValue('yachtId', id)} + ownerFilter={clientId ? { type: 'client', id: clientId } : undefined} + disabled={!clientId && !initialClientId} + placeholder={ + clientId || initialClientId ? 'Select yacht...' : 'Select a client first' + } + /> +
+ ) : null} + +
+ + ( + + )} + /> + {errors.startDate &&

Required

} +
+ +
+ + +
+ +
+ +