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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
322
src/components/tenancies/tenancy-create-dialog.tsx
Normal file
322
src/components/tenancies/tenancy-create-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
155
src/components/tenancies/tenancy-edit-dialog.tsx
Normal file
155
src/components/tenancies/tenancy-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
14
src/lib/db/migrations/0086_tenancies_self_fks.sql
Normal file
14
src/lib/db/migrations/0086_tenancies_self_fks.sql
Normal 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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user