feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)
73-file atomic rename per docs/tenancies-design.md:
- Migration 0085: rename table + indexes + FK constraints; rename
documents.reservation_id → tenancy_id; migrate jsonb permission maps
(reservations resource → tenancies; collapse create+activate → manage);
rewrite historical audit_logs.entity_type='berth_reservation' →
'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
{ view, manage, cancel }; all 8 default seed bundles + role-form + matrix
updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
/api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
/portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
→ activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
(TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
migrated historical audit rows).
KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
Reservation Agreement doc; only its DB imports were renamed).
Verified: tsc clean, 1480/1480 vitest passing, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
271
src/components/tenancies/berth-reserve-dialog.tsx
Normal file
271
src/components/tenancies/berth-reserve-dialog.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'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,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} 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 { 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 = {
|
||||
clientId: string | null;
|
||||
yachtId: string | null;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
tenureType: TenureType;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
interface BerthReserveDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
berthId: string;
|
||||
}
|
||||
|
||||
export function BerthReserveDialog({ open, onOpenChange, berthId }: BerthReserveDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
clientId: null,
|
||||
yachtId: null,
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
tenureType: 'permanent',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
// Wraps handleSubmit so validation failures scroll the first errored
|
||||
// field into view + focus it; matters in this Dialog because the body
|
||||
// can grow past the viewport when long client/yacht lists render.
|
||||
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormError(null);
|
||||
reset({
|
||||
clientId: null,
|
||||
yachtId: null,
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
tenureType: 'permanent',
|
||||
notes: '',
|
||||
});
|
||||
}
|
||||
}, [open, reset]);
|
||||
|
||||
const clientId = watch('clientId');
|
||||
const yachtId = watch('yachtId');
|
||||
const tenureType = watch('tenureType');
|
||||
|
||||
// When client changes, clear yacht (since yacht-picker is filtered to owner=client)
|
||||
useEffect(() => {
|
||||
setValue('yachtId', null);
|
||||
}, [clientId, setValue]);
|
||||
|
||||
function validate(data: FormValues): string | null {
|
||||
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/berths/${berthId}/tenancies`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
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: ['berths', berthId, 'tenancies'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tenancies'] });
|
||||
toast.success('Tenancy created');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to create reservation';
|
||||
setFormError(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const createAndActivateMutation = useMutation({
|
||||
mutationFn: async (data: FormValues) => {
|
||||
const err = validate(data);
|
||||
if (err) throw new Error(err);
|
||||
const pending = await createPending(data);
|
||||
// Immediately activate
|
||||
await apiFetch(`/api/v1/tenancies/${pending.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { action: 'activate' },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'tenancies'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tenancies'] });
|
||||
toast.success('Tenancy created and activated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to activate';
|
||||
if (/active tenancy|active reservation|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>Reserve this berth</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a pending reservation or activate it immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4">
|
||||
<FormErrorSummary
|
||||
errors={errors}
|
||||
labels={{ clientId: 'Client', yachtId: 'Yacht', startDate: 'Start date' }}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<ClientPicker value={clientId} onChange={(id) => setValue('clientId', id)} />
|
||||
</div>
|
||||
|
||||
<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}
|
||||
placeholder={clientId ? 'Select yacht...' : 'Select a client first'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 reservation
|
||||
</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>
|
||||
);
|
||||
}
|
||||
54
src/components/tenancies/tenancies-list-page.tsx
Normal file
54
src/components/tenancies/tenancies-list-page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface TenanciesApiResponse {
|
||||
data: TenancyRow[];
|
||||
pagination: { total: number; page: number; pageSize: number };
|
||||
}
|
||||
|
||||
export function TenanciesListPage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<TenanciesApiResponse>({
|
||||
queryKey: ['tenancies', 'list'],
|
||||
queryFn: () => apiFetch('/api/v1/tenancies?page=1&limit=100&order=desc'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
eyebrow="Marina"
|
||||
title="Tenancies"
|
||||
description="All tenancies across all berths"
|
||||
actions={
|
||||
<Link
|
||||
href={`/${portSlug}/berths`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View berths
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<TenancyList
|
||||
tenancies={data?.data ?? []}
|
||||
showBerth
|
||||
portSlug={portSlug}
|
||||
emptyMessage="No tenancies found."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
src/components/tenancies/tenancy-detail.tsx
Normal file
360
src/components/tenancies/tenancy-detail.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
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 { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
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';
|
||||
|
||||
interface TenancyDoc {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
documentType: string;
|
||||
signedFileId: string | null;
|
||||
signers: Array<{ id: string; status: string; signerName: string }>;
|
||||
}
|
||||
|
||||
interface TenancyData {
|
||||
id: string;
|
||||
status: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
tenureType: string;
|
||||
contractFileId: string | null;
|
||||
berthId: string;
|
||||
yachtId: string;
|
||||
clientId: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
const TENANCY_PILL: Record<string, StatusPillStatus> = {
|
||||
pending: 'pending',
|
||||
active: 'active',
|
||||
ended: 'archived',
|
||||
cancelled: 'cancelled',
|
||||
};
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
interface EndTenancyDialogProps {
|
||||
tenancyId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function EndTenancyDialog({ tenancyId, open, onOpenChange }: EndTenancyDialogProps) {
|
||||
const qc = useQueryClient();
|
||||
const [endDate, setEndDate] = useState(todayIso);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiFetch(`/api/v1/tenancies/${tenancyId}`, {
|
||||
method: 'PATCH',
|
||||
body: { action: 'end', endDate },
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['tenancy', tenancyId] });
|
||||
toast.success('Tenancy ended');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>End tenancy</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="end-date">End date</Label>
|
||||
<DatePicker id="end-date" value={endDate} onChange={setEndDate} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={submitting}>
|
||||
{submitting ? 'Ending…' : 'End tenancy'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface TenancyDetailProps {
|
||||
tenancyId: string;
|
||||
portSlug: string;
|
||||
}
|
||||
|
||||
export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const tenancy = useQuery<{ data: TenancyData }>({
|
||||
queryKey: ['tenancy', tenancyId],
|
||||
queryFn: () => apiFetch(`/api/v1/tenancies/${tenancyId}`),
|
||||
});
|
||||
|
||||
const documentsForTenancy = useQuery<{ data: TenancyDoc[] }>({
|
||||
queryKey: ['documents', 'by-tenancy', tenancyId],
|
||||
queryFn: () =>
|
||||
apiFetch(
|
||||
`/api/v1/documents?documentType=reservation_agreement&signatureOnly=true&limit=10`,
|
||||
).then((res) => {
|
||||
const r = res as { data: TenancyDoc[] & Array<{ tenancyId?: string }> };
|
||||
return {
|
||||
data: r.data.filter(
|
||||
(d: TenancyDoc & { tenancyId?: string }) => d.tenancyId === tenancyId,
|
||||
),
|
||||
} as { data: TenancyDoc[] };
|
||||
}),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'document:created': [['documents', 'by-tenancy', tenancyId]],
|
||||
'document:completed': [
|
||||
['documents', 'by-tenancy', tenancyId],
|
||||
['tenancy', tenancyId],
|
||||
],
|
||||
'document:cancelled': [['documents', 'by-tenancy', tenancyId]],
|
||||
});
|
||||
|
||||
if (tenancy.isLoading) {
|
||||
return <div className="h-32 animate-pulse rounded-md bg-muted/40" />;
|
||||
}
|
||||
|
||||
if (tenancy.error || !tenancy.data) {
|
||||
return (
|
||||
<PageHeader
|
||||
title="Tenancy not found"
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/berths`}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden /> Back
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const res = tenancy.data.data;
|
||||
const docs = documentsForTenancy.data?.data ?? [];
|
||||
const activeAgreement = docs.find((d) => ['sent', 'partially_signed'].includes(d.status));
|
||||
const completedAgreement = docs.find((d) => ['completed', 'signed'].includes(d.status));
|
||||
|
||||
const renderAgreementCard = (): React.ReactNode => {
|
||||
if (completedAgreement) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-success-bg/50 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Agreement signed</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{completedAgreement.title}</p>
|
||||
</div>
|
||||
<StatusPill status="completed" withDot>
|
||||
Completed
|
||||
</StatusPill>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{completedAgreement.signedFileId ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/api/v1/files/${completedAgreement.signedFileId}/download`}>
|
||||
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/${portSlug}/documents/${completedAgreement.id}`}>
|
||||
<Mail className="mr-1.5 h-4 w-4" aria-hidden /> View document
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Signed contract attached to this tenancy.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeAgreement) {
|
||||
const signedCount = activeAgreement.signers.filter((s) => s.status === 'signed').length;
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-brand-50 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Agreement out for signing</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{signedCount}/{activeAgreement.signers.length} signed · {activeAgreement.title}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill
|
||||
status={activeAgreement.status === 'partially_signed' ? 'partial' : 'sent'}
|
||||
withDot
|
||||
>
|
||||
{activeAgreement.status.replace(/_/g, ' ')}
|
||||
</StatusPill>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/${portSlug}/documents/${activeAgreement.id}`}>
|
||||
<FileSignature className="mr-1.5 h-4 w-4" aria-hidden /> View document
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${activeAgreement.id}/remind`, {
|
||||
method: 'POST',
|
||||
});
|
||||
toast.success('Reminder sent');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bell className="mr-1.5 h-4 w-4" aria-hidden /> Remind signers
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FileSignature className="h-7 w-7" aria-hidden />}
|
||||
title="No tenancy agreement yet"
|
||||
body="Generate an agreement for the parties to sign before activation."
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={
|
||||
`/${portSlug}/documents/new?tenancyId=${tenancyId}&documentType=reservation_agreement` as Route
|
||||
}
|
||||
>
|
||||
Generate agreement
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
eyebrow="Berth tenancy"
|
||||
title={`Tenancy #${res.id.slice(0, 8)}`}
|
||||
description={`${res.tenureType.replace(/_/g, ' ')} · ${new Date(res.startDate).toLocaleDateString(undefined)}${res.endDate ? ` → ${new Date(res.endDate).toLocaleDateString(undefined)}` : ''}`}
|
||||
kpiLine={
|
||||
<>
|
||||
<StatusPill status={TENANCY_PILL[res.status] ?? 'pending'} withDot>
|
||||
{res.status}
|
||||
</StatusPill>
|
||||
{res.contractFileId ? <span>Contract attached</span> : <span>No contract</span>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{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 asChild variant="outline">
|
||||
<Link href={`/${portSlug}/berths`}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden /> Back to berths
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded-md border bg-white p-4">
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Tenancy details
|
||||
</h2>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Berth</dt>
|
||||
<dd className="font-medium">
|
||||
<BerthLink berthId={res.berthId} portSlug={portSlug} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
||||
<dd className="font-medium">
|
||||
<YachtLink yachtId={res.yachtId} portSlug={portSlug} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Client</dt>
|
||||
<dd className="font-medium">
|
||||
<ClientLink clientId={res.clientId} portSlug={portSlug} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Tenure</dt>
|
||||
<dd className="font-medium">{res.tenureType.replace(/_/g, ' ')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{res.notes ? (
|
||||
<div className="mt-3 border-t pt-3 text-sm">
|
||||
<div className="text-xs text-muted-foreground">Notes</div>
|
||||
<p className="mt-1 text-foreground whitespace-pre-wrap">{res.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded-md border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Agreement
|
||||
</h2>
|
||||
{renderAgreementCard()}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EndTenancyDialog
|
||||
tenancyId={tenancyId}
|
||||
open={endDialogOpen}
|
||||
onOpenChange={setEndDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
src/components/tenancies/tenancy-list.tsx
Normal file
218
src/components/tenancies/tenancy-list.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
export interface TenancyRow {
|
||||
id: string;
|
||||
berthId: string;
|
||||
portId: string;
|
||||
clientId: string;
|
||||
yachtId: string;
|
||||
status: 'pending' | 'active' | 'ended' | 'cancelled';
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
tenureType: string;
|
||||
contractFileId: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TenancyListProps {
|
||||
tenancies: TenancyRow[];
|
||||
showBerth?: boolean;
|
||||
portSlug?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a client's name as a link by fetching the client record.
|
||||
* Uses TanStack Query cache for memoization of repeated clientId queries.
|
||||
*/
|
||||
export function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||
const { data } = useQuery<{ fullName: string }>({
|
||||
queryKey: ['clients', clientId, 'name-only'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: { fullName: string } }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${clientId}` as any}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{data?.fullName ?? `Client ${clientId.slice(0, 8)}`}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a yacht's name as a link by fetching the yacht record.
|
||||
*/
|
||||
export function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
||||
const { data } = useQuery<{ name: string }>({
|
||||
queryKey: ['yachts', yachtId, 'name-only'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: { name: string } }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/yachts/${yachtId}` as any}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{data?.name ?? `Yacht ${yachtId.slice(0, 8)}`}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a berth's mooring number as a link by fetching the berth record.
|
||||
*/
|
||||
export function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
||||
const { data } = useQuery<{ mooringNumber: string }>({
|
||||
queryKey: ['berths', berthId, 'name-only'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: { mooringNumber: string } }>(`/api/v1/berths/${berthId}`).then(
|
||||
(r) => r.data,
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/berths/${berthId}` as any}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{data?.mooringNumber ?? `Berth ${berthId.slice(0, 8)}`}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a status badge with appropriate color coding.
|
||||
*/
|
||||
function StatusBadge({ status }: { status: TenancyRow['status'] }) {
|
||||
const colorMap: Record<TenancyRow['status'], string> = {
|
||||
pending: 'bg-gray-100 text-gray-800',
|
||||
active: 'bg-green-100 text-green-800',
|
||||
ended: 'bg-blue-100 text-blue-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const color = colorMap[status];
|
||||
const label = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
return (
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints tenure type for display.
|
||||
*/
|
||||
function prettyTenure(tenureType: string): string {
|
||||
const tenureMap: Record<string, string> = {
|
||||
permanent: 'Permanent',
|
||||
fixed_term: 'Fixed term',
|
||||
seasonal: 'Seasonal',
|
||||
};
|
||||
return tenureMap[tenureType] ?? tenureType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date range as "{startDate} → {endDate or 'ongoing'}".
|
||||
*/
|
||||
function formatDateRange(startDate: string, endDate: string | null): string {
|
||||
const start = new Date(startDate).toLocaleDateString();
|
||||
const end = endDate ? new Date(endDate).toLocaleDateString() : 'ongoing';
|
||||
return `${start} → ${end}`;
|
||||
}
|
||||
|
||||
export function TenancyList({
|
||||
tenancies,
|
||||
showBerth = false,
|
||||
portSlug: portSlugProp,
|
||||
emptyMessage,
|
||||
}: TenancyListProps) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
|
||||
|
||||
if (tenancies.length === 0) {
|
||||
return <EmptyState title="No tenancies" description={emptyMessage ?? 'No tenancies yet.'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{showBerth && <TableHead>Berth</TableHead>}
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Yacht</TableHead>
|
||||
<TableHead>Dates</TableHead>
|
||||
<TableHead>Tenure</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Contract</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tenancies.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
{showBerth && (
|
||||
<TableCell>
|
||||
<BerthLink berthId={r.berthId} portSlug={portSlug} />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<ClientLink clientId={r.clientId} portSlug={portSlug} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<YachtLink yachtId={r.yachtId} portSlug={portSlug} />
|
||||
</TableCell>
|
||||
<TableCell>{formatDateRange(r.startDate, r.endDate)}</TableCell>
|
||||
<TableCell>{prettyTenure(r.tenureType)}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.contractFileId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={async () => {
|
||||
const res = await apiFetch<{ data: { url: string } }>(
|
||||
`/api/v1/files/${r.contractFileId}/download`,
|
||||
);
|
||||
window.open(res.data.url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
View contract
|
||||
</button>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user