feat(tenancies-p6-followup): generic create dialog + edit dialog + self-FKs

- 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: <TenancyCreateDialog open clientId? yachtId?
  berthId? /> 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
  <PermissionGate resource="tenancies" action="manage"> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:10:06 +02:00
parent c4450dd852
commit 911b51a669
12 changed files with 689 additions and 13 deletions

View File

@@ -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);

View File

@@ -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<NextR
return errorResponse(error);
}
}
/**
* Generic create endpoint — accepts berthId in the body, so client / yacht
* tab entry points can mint a tenancy without bouncing through a
* berth-specific URL. Same service helper as POST /api/v1/berths/[id]/tenancies;
* the URL just differs in where berthId arrives from.
*/
export async function createHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
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);
}
}

View File

@@ -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));

View File

@@ -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
<div>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Active tenancies</h3>
<PermissionGate resource="tenancies" action="manage">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
Create tenancy
</Button>
</PermissionGate>
</div>
<TenancyList
tenancies={activeRows}
@@ -109,6 +120,8 @@ export function ClientTenanciesTab({ clientId, activeTenancies }: ClientTenancie
</p>
)}
</div>
<TenancyCreateDialog open={createOpen} onOpenChange={setCreateOpen} clientId={clientId} />
</div>
);
}

View File

@@ -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<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
control,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create tenancy</DialogTitle>
<DialogDescription>
Create a pending tenancy or activate it immediately.
</DialogDescription>
</DialogHeader>
<form className="space-y-4">
<FormErrorSummary
errors={errors}
labels={{
berthId: 'Berth',
clientId: 'Client',
yachtId: 'Yacht',
startDate: 'Start date',
}}
/>
{!initialBerthId ? (
<div className="space-y-2">
<Label>Berth</Label>
<BerthPicker value={berthId} onChange={(id) => setValue('berthId', id)} />
</div>
) : null}
{!initialClientId ? (
<div className="space-y-2">
<Label>Client</Label>
<ClientPicker value={clientId} onChange={(id) => setValue('clientId', id)} />
</div>
) : null}
{!initialYachtId ? (
<div className="space-y-2">
<Label>Yacht</Label>
<YachtPicker
value={yachtId}
onChange={(id) => setValue('yachtId', id)}
ownerFilter={clientId ? { type: 'client', id: clientId } : undefined}
disabled={!clientId && !initialClientId}
placeholder={
clientId || initialClientId ? 'Select yacht...' : 'Select a client first'
}
/>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="startDate">Start date</Label>
<Controller
control={control}
name="startDate"
rules={{ required: true }}
render={({ field }) => (
<DatePicker id="startDate" value={field.value ?? ''} onChange={field.onChange} />
)}
/>
{errors.startDate && <p className="text-xs text-destructive">Required</p>}
</div>
<div className="space-y-2">
<Label>Tenure</Label>
<Select
value={tenureType}
onValueChange={(v) => setValue('tenureType', v as TenureType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="fixed_term">Fixed term</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Textarea id="notes" rows={2} {...register('notes')} />
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<DialogFooter className="flex-col-reverse sm:flex-row gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={submitWithScroll((data) => {
setFormError(null);
createMutation.mutate(data);
})}
>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
)}
Create tenancy
</Button>
<Button
type="button"
disabled={isPending}
onClick={submitWithScroll((data) => {
setFormError(null);
createAndActivateMutation.mutate(data);
})}
>
{createAndActivateMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
)}
Create and activate
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,7 +4,7 @@ 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, StopCircle } from 'lucide-react';
import { ArrowLeft, Bell, Download, FileSignature, Mail, Pencil, StopCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -24,6 +24,7 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
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';
interface TenancyDoc {
id: string;
@@ -119,6 +120,7 @@ interface TenancyDetailProps {
export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const tenancy = useQuery<{ data: TenancyData }>({
queryKey: ['tenancy', tenancyId],
queryFn: () => apiFetch(`/api/v1/tenancies/${tenancyId}`),
@@ -285,6 +287,10 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
}
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setEditDialogOpen(true)}>
<Pencil className="mr-1.5 h-4 w-4" aria-hidden />
Edit
</Button>
{res.status === 'active' && (
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
<StopCircle className="mr-1.5 h-4 w-4" aria-hidden />
@@ -355,6 +361,15 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
open={endDialogOpen}
onOpenChange={setEndDialogOpen}
/>
<TenancyEditDialog
tenancyId={tenancyId}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
initialStartDate={res.startDate}
initialEndDate={res.endDate}
initialTenureType={res.tenureType}
initialNotes={res.notes}
/>
</div>
);
}

View File

@@ -0,0 +1,155 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type TenureType = 'permanent' | 'fixed_term' | 'seasonal';
function toIsoDate(d: string | Date | null | undefined): string {
if (!d) return '';
return (d instanceof Date ? d : new Date(d)).toISOString().slice(0, 10);
}
interface TenancyEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tenancyId: string;
initialStartDate: string | Date;
initialEndDate: string | Date | null;
initialTenureType: string;
initialNotes: string | null;
}
/**
* Edit non-status metadata on a tenancy (start/end dates, tenure type,
* notes). Status transitions go through the activate / end / cancel
* actions on the [id] PATCH route; this dialog only touches metadata.
*/
/**
* Outer wrapper: forces a fresh mount whenever the dialog opens, so the
* inner form re-initialises from the current row data without a
* setState-in-effect. Closed dialog stays unmounted so the form holds
* no stale state between opens.
*/
export function TenancyEditDialog(props: TenancyEditDialogProps) {
return props.open ? <TenancyEditDialogInner {...props} key={String(props.open)} /> : null;
}
function TenancyEditDialogInner({
open,
onOpenChange,
tenancyId,
initialStartDate,
initialEndDate,
initialTenureType,
initialNotes,
}: TenancyEditDialogProps) {
const qc = useQueryClient();
const [startDate, setStartDate] = useState(toIsoDate(initialStartDate));
const [endDate, setEndDate] = useState(toIsoDate(initialEndDate));
const [tenureType, setTenureType] = useState<TenureType>(initialTenureType as TenureType);
const [notes, setNotes] = useState(initialNotes ?? '');
const mutation = useMutation({
mutationFn: async () => {
return apiFetch(`/api/v1/tenancies/${tenancyId}`, {
method: 'PATCH',
body: {
action: 'update',
startDate,
endDate: endDate || null,
tenureType,
notes: notes.trim() || null,
},
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tenancy', tenancyId] });
qc.invalidateQueries({ queryKey: ['tenancies'] });
toast.success('Tenancy updated');
onOpenChange(false);
},
onError: (err) => toastError(err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit tenancy</DialogTitle>
<DialogDescription>
Adjust dates, tenure, or notes. Status transitions (activate / end / cancel) live in the
detail page actions.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="edit-start-date">Start date</Label>
<DatePicker id="edit-start-date" value={startDate} onChange={setStartDate} />
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-end-date">
End date <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<DatePicker id="edit-end-date" value={endDate} onChange={setEndDate} />
</div>
<div className="space-y-1.5">
<Label>Tenure</Label>
<Select value={tenureType} onValueChange={(v) => setTenureType(v as TenureType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="fixed_term">Fixed term</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-notes">Notes</Label>
<Textarea
id="edit-notes"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,9 +1,14 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
import { PermissionGate } from '@/components/shared/permission-gate';
import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialog';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
@@ -337,6 +342,7 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) {
function YachtTenanciesTab({ yachtId }: { yachtId: string }) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading } = useQuery<{ data: TenancyRow[] }>({
queryKey: ['tenancies', 'by-yacht', yachtId],
@@ -346,12 +352,23 @@ function YachtTenanciesTab({ yachtId }: { yachtId: string }) {
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
return (
<TenancyList
tenancies={data?.data ?? []}
showBerth
portSlug={portSlug}
emptyMessage="No tenancies for this yacht."
/>
<div className="space-y-4">
<div className="flex items-center justify-end">
<PermissionGate resource="tenancies" action="manage">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
Create tenancy
</Button>
</PermissionGate>
</div>
<TenancyList
tenancies={data?.data ?? []}
showBerth
portSlug={portSlug}
emptyMessage="No tenancies for this yacht."
/>
<TenancyCreateDialog open={createOpen} onOpenChange={setCreateOpen} yachtId={yachtId} />
</div>
);
}

View File

@@ -0,0 +1,14 @@
-- 0086_tenancies_self_fks.sql
-- Add self-FKs for renewals + transfers per docs/tenancies-design.md
-- §"Data model > Rename migration". `previous_tenancy_id` chains a new
-- row to the row it succeeds (fixed-term + seasonal renewals mint a new
-- row; permanent renewals mutate in place). `transferred_from_tenancy_id`
-- chains a new row to the predecessor when an active tenancy is
-- handed over to a different client/yacht.
ALTER TABLE berth_tenancies
ADD COLUMN IF NOT EXISTS previous_tenancy_id text REFERENCES berth_tenancies(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS transferred_from_tenancy_id text REFERENCES berth_tenancies(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_bt_previous ON berth_tenancies(previous_tenancy_id) WHERE previous_tenancy_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bt_transferred_from ON berth_tenancies(transferred_from_tenancy_id) WHERE transferred_from_tenancy_id IS NOT NULL;

View File

@@ -1,4 +1,11 @@
import { pgTable, text, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import {
pgTable,
text,
timestamp,
index,
uniqueIndex,
type AnyPgColumn,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
import { berths } from './berths';
@@ -43,6 +50,22 @@ export const berthTenancies = pgTable(
tenureType: text('tenure_type').notNull().default('permanent'),
contractFileId: text('contract_file_id').references(() => files.id, { onDelete: 'set null' }),
notes: text('notes'),
// Renewal + transfer self-FKs (migration 0086). `previousTenancyId`
// chains a successor row to the row it succeeds (fixed-term + seasonal
// renewals mint a new row; permanent renewals mutate in place so the
// FK stays null). `transferredFromTenancyId` chains a new row to the
// predecessor when an active tenancy is handed over to a different
// client / yacht. SET NULL on delete keeps the lineage best-effort.
previousTenancyId: text('previous_tenancy_id').references(
(): AnyPgColumn => berthTenancies.id,
{
onDelete: 'set null',
},
),
transferredFromTenancyId: text('transferred_from_tenancy_id').references(
(): AnyPgColumn => berthTenancies.id,
{ onDelete: 'set null' },
),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -18,6 +18,7 @@ import type {
EndTenancyInput,
CancelInput,
ListTenanciesInput,
UpdateTenancyInput,
} from '@/lib/validators/tenancies';
// Use z.input so callers (including tests) can omit fields with
@@ -354,6 +355,55 @@ export async function cancel(
return updated;
}
// ─── Update (metadata only — non-status fields) ──────────────────────────────
export async function updateTenancy(
tenancyId: string,
portId: string,
data: UpdateTenancyInput,
meta: AuditMeta,
): Promise<BerthTenancy> {
const existing = await loadScoped(tenancyId, portId);
const patch: Partial<typeof berthTenancies.$inferInsert> = { updatedAt: new Date() };
if (data.startDate !== undefined) patch.startDate = data.startDate;
if (data.endDate !== undefined) patch.endDate = data.endDate;
if (data.tenureType !== undefined) patch.tenureType = data.tenureType;
if (data.notes !== undefined) patch.notes = data.notes;
const [updated] = await db
.update(berthTenancies)
.set(patch)
.where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Tenancy');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth_tenancy',
entityId: tenancyId,
oldValue: {
startDate: existing.startDate,
endDate: existing.endDate,
tenureType: existing.tenureType,
notes: existing.notes,
},
newValue: patch,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth_tenancy:activated', {
tenancyId,
berthId: updated.berthId,
});
return updated;
}
// ─── Get ─────────────────────────────────────────────────────────────────────
export async function getById(id: string, portId: string): Promise<BerthTenancy> {

View File

@@ -28,6 +28,20 @@ export const cancelSchema = z.object({
reason: z.string().optional(),
});
/** PATCH body for the "edit metadata" idiom — touches notes / dates /
* tenure type without crossing a status boundary. Status transitions
* flow through activate / endTenancy / cancel; this is non-transition
* metadata only. */
export const updateTenancySchema = z
.object({
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().nullable().optional(),
tenureType: z.enum(TENURE_TYPES).optional(),
notes: z.string().nullable().optional(),
})
.refine((d) => Object.keys(d).length > 0, { message: 'At least one field must be provided' });
export type UpdateTenancyInput = z.infer<typeof updateTenancySchema>;
export const listTenanciesSchema = baseListQuerySchema.extend({
status: z.enum(TENANCY_STATUSES).optional(),
berthId: z.string().optional(),