feat(tenancies-renew-transfer): tenure-aware renewal + transfer actions

- renewTenancy service:
  - permanent / fee_simple / strata_lot → mutate-in-place (startDate
    moves forward, endDate may extend or null out)
  - fixed_term / seasonal → end the current row at its existing endDate
    + mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
  transferDate, mint fresh active row with transferredFromTenancyId
  pointing back. New client + yacht cross-validated against port +
  ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
  tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
  TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
  filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
  src/lib/validators/tenancies.ts.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:13:34 +02:00
parent 911b51a669
commit d32e557e56
7 changed files with 573 additions and 5 deletions

View File

@@ -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
</Button>
{res.status === 'active' && (
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
<StopCircle className="mr-1.5 h-4 w-4" aria-hidden />
End tenancy
</Button>
<>
<Button variant="outline" size="sm" onClick={() => setRenewDialogOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden />
Renew
</Button>
<Button variant="outline" size="sm" onClick={() => setTransferDialogOpen(true)}>
<ArrowRightLeft className="mr-1.5 h-4 w-4" aria-hidden />
Transfer
</Button>
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
<StopCircle className="mr-1.5 h-4 w-4" aria-hidden />
End tenancy
</Button>
</>
)}
<Button asChild variant="outline">
<Link href={`/${portSlug}/berths`}>
@@ -370,6 +394,18 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
initialTenureType={res.tenureType}
initialNotes={res.notes}
/>
<TenancyRenewDialog
tenancyId={tenancyId}
open={renewDialogOpen}
onOpenChange={setRenewDialogOpen}
tenureType={res.tenureType}
currentEndDate={res.endDate}
/>
<TenancyTransferDialog
tenancyId={tenancyId}
open={transferDialogOpen}
onOpenChange={setTransferDialogOpen}
/>
</div>
);
}

View File

@@ -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 ? <Inner {...props} key={String(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Renew tenancy</DialogTitle>
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="renew-start">New start date</Label>
<DatePicker id="renew-start" value={newStartDate} onChange={setNewStartDate} />
</div>
<div className="space-y-1.5">
<Label htmlFor="renew-end">
New end date
{isPermanent ? (
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
) : (
<span className="ml-1 text-xs font-normal text-destructive">(required)</span>
)}
</Label>
<DatePicker id="renew-end" value={newEndDate} onChange={setNewEndDate} />
</div>
<div className="space-y-1.5">
<Label htmlFor="renew-notes">Notes (optional)</Label>
<Textarea
id="renew-notes"
rows={2}
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 />}
Renew
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 ? <Inner {...props} key={String(props.open)} /> : null;
}
function Inner({ open, onOpenChange, tenancyId }: TenancyTransferDialogProps) {
const qc = useQueryClient();
const [newClientId, setNewClientId] = useState<string | null>(null);
const [newYachtId, setNewYachtId] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Transfer tenancy</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>New client</Label>
<ClientPicker value={newClientId} onChange={setNewClientId} />
</div>
<div className="space-y-1.5">
<Label>New yacht</Label>
<YachtPicker
value={newYachtId}
onChange={setNewYachtId}
ownerFilter={newClientId ? { type: 'client', id: newClientId } : undefined}
disabled={!newClientId}
placeholder={newClientId ? 'Select yacht...' : 'Pick a client first'}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="transfer-date">Transfer date</Label>
<DatePicker id="transfer-date" value={transferDate} onChange={setTransferDate} />
</div>
<div className="space-y-1.5">
<Label htmlFor="transfer-notes">Notes (optional)</Label>
<Textarea
id="transfer-notes"
rows={2}
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 />}
Transfer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}