Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
src/components/berths/berth-columns.tsx
Normal file
162
src/components/berths/berth-columns.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { MoreHorizontal, Pencil, Activity } from 'lucide-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
|
||||
export type BerthRow = {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
lengthM: string | null;
|
||||
widthM: string | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variants: Record<string, string> = {
|
||||
available: 'bg-green-100 text-green-800 border-green-200',
|
||||
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
sold: 'bg-red-100 text-red-800 border-red-200',
|
||||
};
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
available: 'Available',
|
||||
under_offer: 'Under Offer',
|
||||
sold: 'Sold',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${variants[status] ?? 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{labels[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const berth = row.original;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/${params.portSlug}/berths/${berth.id}`);
|
||||
}}
|
||||
>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'mooringNumber',
|
||||
header: 'Mooring #',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.mooringNumber}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'area',
|
||||
header: 'Area',
|
||||
cell: ({ row }) => row.original.area ?? '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'dimensions',
|
||||
header: 'Dimensions',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { lengthM, widthM } = row.original;
|
||||
if (!lengthM && !widthM) return '—';
|
||||
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'Price',
|
||||
cell: ({ row }) => {
|
||||
const { price, priceCurrency } = row.original;
|
||||
if (!price) return '—';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: priceCurrency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(price));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'tenureType',
|
||||
header: 'Tenure',
|
||||
cell: ({ row }) =>
|
||||
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
header: 'Tags',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { tags } = row.original;
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">+{tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableSorting: false,
|
||||
size: 48,
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
},
|
||||
];
|
||||
215
src/components/berths/berth-detail-header.tsx
Normal file
215
src/components/berths/berth-detail-header.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Pencil, RefreshCw } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { BerthForm } from './berth-form';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
|
||||
import { BERTH_STATUSES } from '@/lib/constants';
|
||||
|
||||
type BerthDetailData = {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
portId: string;
|
||||
lengthFt: string | null;
|
||||
lengthM: string | null;
|
||||
widthFt: string | null;
|
||||
widthM: string | null;
|
||||
draftFt: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
tenureYears: number | null;
|
||||
tenureStartDate: string | null;
|
||||
tenureEndDate: string | null;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
access: string | null;
|
||||
berthApproved: boolean | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
|
||||
interface BerthDetailHeaderProps {
|
||||
berth: BerthDetailData;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
available: 'bg-green-100 text-green-800 border-green-300',
|
||||
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
sold: 'bg-red-100 text-red-800 border-red-300',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
available: 'Available',
|
||||
under_offer: 'Under Offer',
|
||||
sold: 'Sold',
|
||||
};
|
||||
|
||||
function StatusChangeDialog({
|
||||
berthId,
|
||||
currentStatus,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
berthId: string;
|
||||
currentStatus: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<UpdateBerthStatusInput>({
|
||||
resolver: zodResolver(updateBerthStatusSchema),
|
||||
defaultValues: { status: currentStatus as typeof BERTH_STATUSES[number], reason: '' },
|
||||
});
|
||||
|
||||
const status = watch('status');
|
||||
|
||||
async function onSubmit(data: UpdateBerthStatusInput) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/berths/${berthId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['berths'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
|
||||
toast.success('Status updated');
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update status';
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Status</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>New Status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setValue('status', v as typeof BERTH_STATUSES[number])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BERTH_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATUS_LABELS[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason *</Label>
|
||||
<Textarea
|
||||
{...register('reason')}
|
||||
placeholder="Reason for status change..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Update Status'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Berth {berth.mooringNumber}
|
||||
</h1>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
|
||||
>
|
||||
{STATUS_LABELS[berth.status] ?? berth.status}
|
||||
</span>
|
||||
</div>
|
||||
{berth.area && (
|
||||
<p className="text-muted-foreground mt-1">{berth.area}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<PermissionGate resource="berths" action="edit">
|
||||
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
Change Status
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="mr-1.5 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
|
||||
|
||||
<StatusChangeDialog
|
||||
berthId={berth.id}
|
||||
currentStatus={berth.status}
|
||||
open={statusOpen}
|
||||
onOpenChange={setStatusOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
src/components/berths/berth-detail.tsx
Normal file
37
src/components/berths/berth-detail.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { BerthDetailHeader } from './berth-detail-header';
|
||||
import { buildBerthTabs } from './berth-tabs';
|
||||
|
||||
interface BerthDetailProps {
|
||||
berthId: string;
|
||||
}
|
||||
|
||||
export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['berth', berthId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: Record<string, unknown> }>(`/api/v1/berths/${berthId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'berth:updated': [['berth', berthId]],
|
||||
'berth:statusChanged': [['berth', berthId]],
|
||||
});
|
||||
|
||||
const berth = data as any;
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
isLoading={isLoading}
|
||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||
tabs={berth ? buildBerthTabs(berth) : []}
|
||||
defaultTab="overview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
src/components/berths/berth-filters.tsx
Normal file
41
src/components/berths/berth-filters.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||
import { BERTH_STATUSES } from '@/lib/constants';
|
||||
|
||||
export const berthFilterDefinitions: FilterDefinition[] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: 'Search',
|
||||
type: 'text',
|
||||
placeholder: 'Search mooring number or area...',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'multi-select',
|
||||
options: BERTH_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: s === 'available' ? 'Available' : s === 'under_offer' ? 'Under Offer' : 'Sold',
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'area',
|
||||
label: 'Area',
|
||||
type: 'text',
|
||||
placeholder: 'Filter by area...',
|
||||
},
|
||||
{
|
||||
key: 'tenureType',
|
||||
label: 'Tenure Type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'permanent', label: 'Permanent' },
|
||||
{ value: 'fixed_term', label: 'Fixed Term' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tagIds',
|
||||
label: 'Tags',
|
||||
type: 'multi-select',
|
||||
options: [], // populated dynamically via TagPicker in the list component
|
||||
},
|
||||
];
|
||||
305
src/components/berths/berth-form.tsx
Normal file
305
src/components/berths/berth-form.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
|
||||
|
||||
interface BerthFormProps {
|
||||
berth: {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
lengthFt: string | null;
|
||||
lengthM: string | null;
|
||||
widthFt: string | null;
|
||||
widthM: string | null;
|
||||
draftFt: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
tenureYears: number | null;
|
||||
tenureStartDate: string | null;
|
||||
tenureEndDate: string | null;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
access: string | null;
|
||||
berthApproved: boolean | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<UpdateBerthInput>({
|
||||
resolver: zodResolver(updateBerthSchema),
|
||||
defaultValues: {
|
||||
area: berth.area ?? undefined,
|
||||
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined,
|
||||
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined,
|
||||
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined,
|
||||
widthM: berth.widthM ? Number(berth.widthM) : undefined,
|
||||
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined,
|
||||
draftM: berth.draftM ? Number(berth.draftM) : undefined,
|
||||
widthIsMinimum: berth.widthIsMinimum ?? false,
|
||||
price: berth.price ? Number(berth.price) : undefined,
|
||||
priceCurrency: berth.priceCurrency,
|
||||
tenureType: berth.tenureType as 'permanent' | 'fixed_term',
|
||||
tenureYears: berth.tenureYears ?? undefined,
|
||||
tenureStartDate: berth.tenureStartDate ?? undefined,
|
||||
tenureEndDate: berth.tenureEndDate ?? undefined,
|
||||
powerCapacity: berth.powerCapacity ?? undefined,
|
||||
voltage: berth.voltage ?? undefined,
|
||||
mooringType: berth.mooringType ?? undefined,
|
||||
access: berth.access ?? undefined,
|
||||
berthApproved: berth.berthApproved ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const tagMutation = useMutation({
|
||||
mutationFn: (ids: string[]) =>
|
||||
apiFetch(`/api/v1/berths/${berth.id}/tags`, {
|
||||
method: 'PUT',
|
||||
body: { tagIds: ids },
|
||||
}),
|
||||
});
|
||||
|
||||
async function onSubmit(data: UpdateBerthInput) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/berths/${berth.id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
});
|
||||
await tagMutation.mutateAsync(tagIds);
|
||||
queryClient.invalidateQueries({ queryKey: ['berths'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['berth', berth.id] });
|
||||
toast.success('Berth updated');
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update berth';
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const tenureType = watch('tenureType');
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-[480px] sm:w-[540px] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 py-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Basic Info
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area">Area</Label>
|
||||
<Input id="area" {...register('area')} placeholder="e.g. Marina A" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mooringType">Mooring Type</Label>
|
||||
<Input id="mooringType" {...register('mooringType')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access">Access</Label>
|
||||
<Input id="access" {...register('access')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="berthApproved"
|
||||
checked={watch('berthApproved') ?? false}
|
||||
onCheckedChange={(v) => setValue('berthApproved', v)}
|
||||
/>
|
||||
<Label htmlFor="berthApproved">Berth Approved</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Dimensions
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Length (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Length (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftM')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="widthIsMinimum"
|
||||
checked={watch('widthIsMinimum') ?? false}
|
||||
onCheckedChange={(v) => setValue('widthIsMinimum', v)}
|
||||
/>
|
||||
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Price */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Price
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Price</Label>
|
||||
<Input type="number" step="0.01" {...register('price')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Input {...register('priceCurrency')} placeholder="USD" maxLength={3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tenure */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Tenure
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>Tenure Type</Label>
|
||||
<Select
|
||||
value={tenureType}
|
||||
onValueChange={(v) => setValue('tenureType', v as 'permanent' | 'fixed_term')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="permanent">Permanent</SelectItem>
|
||||
<SelectItem value="fixed_term">Fixed Term</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{tenureType === 'fixed_term' && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Years</Label>
|
||||
<Input type="number" {...register('tenureYears')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<Input type="date" {...register('tenureStartDate')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<Input type="date" {...register('tenureEndDate')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Infrastructure */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Infrastructure
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Power Capacity</Label>
|
||||
<Input {...register('powerCapacity')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Voltage</Label>
|
||||
<Input {...register('voltage')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Tags
|
||||
</h3>
|
||||
<TagPicker selectedIds={tagIds} onChange={setTagIds} />
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
94
src/components/berths/berth-list.tsx
Normal file
94
src/components/berths/berth-list.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { berthColumns, type BerthRow } from './berth-columns';
|
||||
import { berthFilterDefinitions } from './berth-filters';
|
||||
import { Anchor } from 'lucide-react';
|
||||
|
||||
export function BerthList() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
isLoading,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
setPage,
|
||||
} = usePaginatedQuery<BerthRow>({
|
||||
queryKey: ['berths'],
|
||||
endpoint: '/api/v1/berths',
|
||||
filterDefinitions: berthFilterDefinitions,
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'berth:updated': [['berths']],
|
||||
'berth:statusChanged': [['berths']],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berths"
|
||||
description="View and manage berth allocations"
|
||||
// No "New" button — berths are import-only
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<FilterBar
|
||||
filters={berthFilterDefinitions}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<div className="ml-auto">
|
||||
<SavedViewsDropdown
|
||||
entityType="berths"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters, savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable<BerthRow>
|
||||
columns={berthColumns}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
totalPages: pagination.totalPages,
|
||||
}}
|
||||
onPaginationChange={(page) => setPage(page)}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Anchor}
|
||||
title="No berths found"
|
||||
description="Berths are imported from external sources. Adjust your filters to find what you're looking for."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/berths/berth-status-suggestion-dialog.tsx
Normal file
91
src/components/berths/berth-status-suggestion-dialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface BerthStatusSuggestionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
berthId: string;
|
||||
currentStatus: string;
|
||||
suggestedStatus: string;
|
||||
reason: string;
|
||||
onApplied: () => void;
|
||||
}
|
||||
|
||||
export function BerthStatusSuggestionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
berthId,
|
||||
currentStatus,
|
||||
suggestedStatus,
|
||||
reason,
|
||||
onApplied,
|
||||
}: BerthStatusSuggestionDialogProps) {
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/berths/${berthId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: suggestedStatus, reason }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onApplied();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Suggested Status Change</DialogTitle>
|
||||
<DialogDescription>
|
||||
Based on recent activity, a berth status update is recommended.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 py-4">
|
||||
<Badge variant="outline" className="text-base px-4 py-1.5">
|
||||
{currentStatus.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground" />
|
||||
<Badge variant="default" className="text-base px-4 py-1.5">
|
||||
{suggestedStatus.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{reason && (
|
||||
<p className="text-sm text-muted-foreground text-center px-4">{reason}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => applyMutation.mutate()}
|
||||
disabled={applyMutation.isPending}
|
||||
>
|
||||
{applyMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Apply Change
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
200
src/components/berths/berth-tabs.tsx
Normal file
200
src/components/berths/berth-tabs.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { type DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
|
||||
type BerthData = {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
lengthFt: string | null;
|
||||
lengthM: string | null;
|
||||
widthFt: string | null;
|
||||
widthM: string | null;
|
||||
draftFt: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
nominalBoatSize: string | null;
|
||||
nominalBoatSizeM: string | null;
|
||||
waterDepth: string | null;
|
||||
waterDepthM: string | null;
|
||||
waterDepthIsMinimum: boolean | null;
|
||||
sidePontoon: string | null;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
access: string | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
bowFacing: string | null;
|
||||
berthApproved: boolean | null;
|
||||
tenureType: string;
|
||||
tenureYears: number | null;
|
||||
tenureStartDate: string | null;
|
||||
tenureEndDate: string | null;
|
||||
statusLastChangedReason: string | null;
|
||||
statusLastModified: string | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
|
||||
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
if (!value && value !== 0 && value !== false) return null;
|
||||
return (
|
||||
<div className="flex justify-between py-2 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium text-right max-w-[60%]">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
const formatDim = (ft: string | null, m: string | null) => {
|
||||
const parts = [];
|
||||
if (ft) parts.push(`${ft} ft`);
|
||||
if (m) parts.push(`${m} m`);
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
|
||||
const price = berth.price
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: berth.priceCurrency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(berth.price))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Specifications */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
|
||||
<SpecRow
|
||||
label="Width"
|
||||
value={
|
||||
formatDim(berth.widthFt, berth.widthM)
|
||||
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||
<SpecRow label="Nominal Boat Size" value={berth.nominalBoatSize || berth.nominalBoatSizeM} />
|
||||
<SpecRow
|
||||
label="Water Depth"
|
||||
value={
|
||||
berth.waterDepth || berth.waterDepthM
|
||||
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<SpecRow label="Mooring Type" value={berth.mooringType} />
|
||||
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
|
||||
<SpecRow label="Bow Facing" value={berth.bowFacing} />
|
||||
<SpecRow label="Access" value={berth.access} />
|
||||
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Infrastructure & Pricing */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
||||
<SpecRow label="Voltage" value={berth.voltage} />
|
||||
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
||||
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
||||
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
||||
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<SpecRow
|
||||
label="Tenure Type"
|
||||
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
|
||||
/>
|
||||
{berth.tenureType === 'fixed_term' && (
|
||||
<>
|
||||
<SpecRow label="Years" value={berth.tenureYears} />
|
||||
<SpecRow label="Start Date" value={berth.tenureStartDate} />
|
||||
<SpecRow label="End Date" value={berth.tenureEndDate} />
|
||||
</>
|
||||
)}
|
||||
<SpecRow label="Price" value={price} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{berth.tags.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{berth.tags.map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StubTab({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<p className="text-muted-foreground">{label} coming soon</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildBerthTabs(berth: BerthData): DetailTab[] {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab berth={berth} />,
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
content: <StubTab label="Interests" />,
|
||||
},
|
||||
{
|
||||
id: 'waiting-list',
|
||||
label: 'Waiting List',
|
||||
content: <StubTab label="Waiting List" />,
|
||||
},
|
||||
{
|
||||
id: 'maintenance',
|
||||
label: 'Maintenance Log',
|
||||
content: <StubTab label="Maintenance Log" />,
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
content: <StubTab label="Activity" />,
|
||||
},
|
||||
];
|
||||
}
|
||||
269
src/components/berths/waiting-list-manager.tsx
Normal file
269
src/components/berths/waiting-list-manager.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Plus, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface WaitingListEntry {
|
||||
id: string;
|
||||
clientId: string;
|
||||
position: number;
|
||||
priority: string;
|
||||
notifyPref: string;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface WaitingListManagerProps {
|
||||
berthId: string;
|
||||
}
|
||||
|
||||
function SortableEntry({
|
||||
entry,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: WaitingListEntry;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: entry.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-3 border rounded-md p-3 bg-card"
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-mono w-6 text-center text-muted-foreground">
|
||||
{entry.position}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{entry.clientId}</p>
|
||||
{entry.notes && (
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant={entry.priority === 'high' ? 'destructive' : 'secondary'}>
|
||||
{entry.priority}
|
||||
</Badge>
|
||||
|
||||
<button
|
||||
onClick={() => onRemove(entry.id)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WaitingListManager({ berthId }: WaitingListManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newClientId, setNewClientId] = useState('');
|
||||
const [newPriority, setNewPriority] = useState<'normal' | 'high'>('normal');
|
||||
const [newNotes, setNewNotes] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: WaitingListEntry[] }>({
|
||||
queryKey: ['berth-waiting-list', berthId],
|
||||
queryFn: () => apiFetch(`/api/v1/berths/${berthId}/waiting-list`),
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (body: { entryId: string; newPosition: number }) =>
|
||||
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
||||
},
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (entries: WaitingListEntry[]) =>
|
||||
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ entries }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
|
||||
setShowAddForm(false);
|
||||
setNewClientId('');
|
||||
setNewNotes('');
|
||||
},
|
||||
});
|
||||
|
||||
const entries = data?.data ?? [];
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const overId = over.id as string;
|
||||
const overEntry = entries.find((e) => e.id === overId);
|
||||
if (!overEntry) return;
|
||||
|
||||
reorderMutation.mutate({
|
||||
entryId: active.id as string,
|
||||
newPosition: overEntry.position,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!newClientId.trim()) return;
|
||||
const newEntry = {
|
||||
clientId: newClientId.trim(),
|
||||
position: entries.length + 1,
|
||||
priority: newPriority,
|
||||
notifyPref: 'email' as const,
|
||||
notes: newNotes || undefined,
|
||||
};
|
||||
addMutation.mutate([
|
||||
...entries.map((e) => ({
|
||||
...e,
|
||||
notifyPref: e.notifyPref as 'email' | 'in_app' | 'both',
|
||||
priority: e.priority as 'normal' | 'high',
|
||||
})),
|
||||
newEntry as WaitingListEntry,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleRemove(entryId: string) {
|
||||
const remaining = entries
|
||||
.filter((e) => e.id !== entryId)
|
||||
.map((e, i) => ({
|
||||
...e,
|
||||
position: i + 1,
|
||||
notifyPref: e.notifyPref as 'email' | 'in_app' | 'both',
|
||||
priority: e.priority as 'normal' | 'high',
|
||||
}));
|
||||
addMutation.mutate(remaining);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-24 bg-muted animate-pulse rounded" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Waiting List ({entries.length})</span>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowAddForm((v) => !v)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="border rounded-md p-3 space-y-3 bg-muted/30">
|
||||
<Input
|
||||
placeholder="Client ID"
|
||||
value={newClientId}
|
||||
onChange={(e) => setNewClientId(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={newPriority}
|
||||
onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">Normal priority</SelectItem>
|
||||
<SelectItem value="high">High priority</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Notes (optional)"
|
||||
value={newNotes}
|
||||
onChange={(e) => setNewNotes(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
|
||||
{addMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Add to List
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No entries on waiting list.
|
||||
</p>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={entries.map((e) => e.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<SortableEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user