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

} +
+ +
+ + +
+ +
+ +