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

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