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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user