feat(marina): end-reservation UI + global list, yacht tabs, dashboard distinct count
- End-reservation: API handler existed but had no UI surface. Adds an
"End reservation" button + date dialog on the reservation detail page,
visible only when status is `active`.
- New port-scoped `GET /api/v1/berth-reservations` list endpoint and
`[portSlug]/berth-reservations` page so users can see all reservations
across all berths from one place (was 404).
- Berths "Edit" menu pushed `/berths/{id}?edit=true` but the detail page
never read the param — it now auto-opens the edit sheet on mount and
strips `edit` from the URL.
- Reservation detail no longer shows raw 8-char UUIDs for Berth / Yacht
/ Client; reuses the lazy-fetching link components from the list view.
- Yacht "Interests" and "Reservations" tabs replaced their "Coming soon"
stubs with real lists fetched from the existing service routes.
- Dashboard "Pipeline Value" KPI used `select(berthId, price)` and
summed per active interest, so a berth with three open interests was
counted three times. Switched to `selectDistinct(berthId, price)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
|
||||||
|
|
||||||
|
export default function BerthReservationsPage() {
|
||||||
|
return <BerthReservationsList />;
|
||||||
|
}
|
||||||
31
src/app/api/v1/berth-reservations/route.ts
Normal file
31
src/app/api/v1/berth-reservations/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listReservations } from '@/lib/services/berth-reservations.service';
|
||||||
|
import { listReservationsSchema } from '@/lib/validators/reservations';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('reservations', 'view', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req, listReservationsSchema);
|
||||||
|
const result = await listReservations(ctx.portId, query);
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
@@ -8,6 +9,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
|||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { BerthDetailHeader } from './berth-detail-header';
|
import { BerthDetailHeader } from './berth-detail-header';
|
||||||
|
import { BerthForm } from './berth-form';
|
||||||
import { buildBerthTabs } from './berth-tabs';
|
import { buildBerthTabs } from './berth-tabs';
|
||||||
|
|
||||||
interface BerthDetailProps {
|
interface BerthDetailProps {
|
||||||
@@ -35,15 +37,38 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
|||||||
return () => setChrome({ title: null, showBackButton: false });
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
}, [titleForChrome, setChrome]);
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
|
// Auto-open edit sheet when ?edit=true is present in the URL
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('edit') === 'true') {
|
||||||
|
setEditOpen(true);
|
||||||
|
// Strip the param without adding a history entry
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('edit');
|
||||||
|
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
|
||||||
|
// typedRoutes can't statically validate this dynamic path; cast is safe
|
||||||
|
// because we're always replacing within the same route segment.
|
||||||
|
router.replace(newUrl as never);
|
||||||
|
}
|
||||||
|
// Only run once on mount / when searchParams changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const berth = data as any;
|
const berth = data as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailLayout
|
<>
|
||||||
isLoading={isLoading}
|
<DetailLayout
|
||||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
isLoading={isLoading}
|
||||||
tabs={berth ? buildBerthTabs(berth) : []}
|
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||||
defaultTab="overview"
|
tabs={berth ? buildBerthTabs(berth) : []}
|
||||||
/>
|
defaultTab="overview"
|
||||||
|
/>
|
||||||
|
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/components/reservations/berth-reservations-list.tsx
Normal file
54
src/components/reservations/berth-reservations-list.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 { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||||
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface ReservationsApiResponse {
|
||||||
|
data: ReservationRow[];
|
||||||
|
pagination: { total: number; page: number; pageSize: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthReservationsList() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ReservationsApiResponse>({
|
||||||
|
queryKey: ['berth-reservations', 'list'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/berth-reservations?page=1&limit=100&order=desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Marina"
|
||||||
|
title="Berth Reservations"
|
||||||
|
description="All reservations across all berths"
|
||||||
|
actions={
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths`}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View berths
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : (
|
||||||
|
<ReservationList
|
||||||
|
reservations={data?.data ?? []}
|
||||||
|
showBerth
|
||||||
|
portSlug={portSlug}
|
||||||
|
emptyMessage="No reservations found."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, Bell, Download, FileSignature, Mail } from 'lucide-react';
|
import { ArrowLeft, Bell, Download, FileSignature, Mail, StopCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
import { EmptyState } from '@/components/ui/empty-state';
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list';
|
||||||
|
|
||||||
interface ReservationDoc {
|
interface ReservationDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,12 +53,77 @@ const RESERVATION_PILL: Record<string, StatusPillStatus> = {
|
|||||||
cancelled: 'cancelled',
|
cancelled: 'cancelled',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndReservationDialogProps {
|
||||||
|
reservationId: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndReservationDialog({ reservationId, open, onOpenChange }: EndReservationDialogProps) {
|
||||||
|
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/berth-reservations/${reservationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { action: 'end', endDate },
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ['reservation', reservationId] });
|
||||||
|
toast.success('Reservation ended');
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to end reservation');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>End reservation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="end-date">End date</Label>
|
||||||
|
<Input
|
||||||
|
id="end-date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="destructive" disabled={submitting}>
|
||||||
|
{submitting ? 'Ending…' : 'End reservation'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ReservationDetailProps {
|
interface ReservationDetailProps {
|
||||||
reservationId: string;
|
reservationId: string;
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
|
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
|
||||||
|
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||||
const reservation = useQuery<{ data: ReservationData }>({
|
const reservation = useQuery<{ data: ReservationData }>({
|
||||||
queryKey: ['reservation', reservationId],
|
queryKey: ['reservation', reservationId],
|
||||||
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
|
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
|
||||||
@@ -215,11 +291,19 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button asChild variant="outline">
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/${portSlug}/berths`}>
|
{res.status === 'active' && (
|
||||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
|
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
|
||||||
</Link>
|
<StopCircle className="mr-1.5 h-4 w-4" />
|
||||||
</Button>
|
End reservation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/${portSlug}/berths`}>
|
||||||
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
@@ -233,35 +317,20 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
|||||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-muted-foreground">Berth</dt>
|
<dt className="text-xs text-muted-foreground">Berth</dt>
|
||||||
<dd>
|
<dd className="font-medium">
|
||||||
<Link
|
<BerthLink berthId={res.berthId} portSlug={portSlug} />
|
||||||
href={`/${portSlug}/berths/${res.berthId}` as Route}
|
|
||||||
className="font-medium text-brand hover:underline"
|
|
||||||
>
|
|
||||||
{res.berthId.slice(0, 8)}…
|
|
||||||
</Link>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
||||||
<dd>
|
<dd className="font-medium">
|
||||||
<Link
|
<YachtLink yachtId={res.yachtId} portSlug={portSlug} />
|
||||||
href={`/${portSlug}/yachts/${res.yachtId}` as Route}
|
|
||||||
className="font-medium text-brand hover:underline"
|
|
||||||
>
|
|
||||||
{res.yachtId.slice(0, 8)}…
|
|
||||||
</Link>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-muted-foreground">Client</dt>
|
<dt className="text-xs text-muted-foreground">Client</dt>
|
||||||
<dd>
|
<dd className="font-medium">
|
||||||
<Link
|
<ClientLink clientId={res.clientId} portSlug={portSlug} />
|
||||||
href={`/${portSlug}/clients/${res.clientId}` as Route}
|
|
||||||
className="font-medium text-brand hover:underline"
|
|
||||||
>
|
|
||||||
{res.clientId.slice(0, 8)}…
|
|
||||||
</Link>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -287,6 +356,12 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EndReservationDialog
|
||||||
|
reservationId={reservationId}
|
||||||
|
open={endDialogOpen}
|
||||||
|
onOpenChange={setEndDialogOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface ReservationListProps {
|
|||||||
* Renders a client's name as a link by fetching the client record.
|
* Renders a client's name as a link by fetching the client record.
|
||||||
* Uses TanStack Query cache for memoization of repeated clientId queries.
|
* Uses TanStack Query cache for memoization of repeated clientId queries.
|
||||||
*/
|
*/
|
||||||
function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
export function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
const { data } = useQuery<{ fullName: string }>({
|
const { data } = useQuery<{ fullName: string }>({
|
||||||
queryKey: ['clients', clientId, 'name-only'],
|
queryKey: ['clients', clientId, 'name-only'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -62,7 +62,7 @@ function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string
|
|||||||
/**
|
/**
|
||||||
* Renders a yacht's name as a link by fetching the yacht record.
|
* Renders a yacht's name as a link by fetching the yacht record.
|
||||||
*/
|
*/
|
||||||
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
export function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
||||||
const { data } = useQuery<{ name: string }>({
|
const { data } = useQuery<{ name: string }>({
|
||||||
queryKey: ['yachts', yachtId, 'name-only'],
|
queryKey: ['yachts', yachtId, 'name-only'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -83,7 +83,7 @@ function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string })
|
|||||||
/**
|
/**
|
||||||
* Renders a berth's mooring number as a link by fetching the berth record.
|
* Renders a berth's mooring number as a link by fetching the berth record.
|
||||||
*/
|
*/
|
||||||
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
export function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
||||||
const { data } = useQuery<{ mooringNumber: string }>({
|
const { data } = useQuery<{ mooringNumber: string }>({
|
||||||
queryKey: ['berths', berthId, 'name-only'],
|
queryKey: ['berths', berthId, 'name-only'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
|
||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
|
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||||
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -206,6 +207,70 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function YachtInterestsTab({ yachtId }: { yachtId: string }) {
|
||||||
|
const { data, isLoading } = useQuery<{
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
clientName: string | null;
|
||||||
|
berthMooringNumber: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
}>({
|
||||||
|
queryKey: ['interests', 'by-yacht', yachtId],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/interests?yachtId=${yachtId}&limit=50&order=desc`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const interests = data?.data ?? [];
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||||
|
if (interests.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No interests linked to this yacht.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{interests.map((i) => (
|
||||||
|
<li
|
||||||
|
key={i.id}
|
||||||
|
className="flex items-center gap-3 rounded-md border bg-muted/30 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="w-36 shrink-0 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{i.pipelineStage.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{i.clientName ?? '—'}</span>
|
||||||
|
{i.berthMooringNumber && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
Berth {i.berthMooringNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YachtReservationsTab({ yachtId }: { yachtId: string }) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: ReservationRow[] }>({
|
||||||
|
queryKey: ['berth-reservations', 'by-yacht', yachtId],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/berth-reservations?yachtId=${yachtId}&limit=50&order=desc`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReservationList
|
||||||
|
reservations={data?.data ?? []}
|
||||||
|
showBerth
|
||||||
|
portSlug={portSlug}
|
||||||
|
emptyMessage="No reservations for this yacht."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -221,12 +286,12 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
|
|||||||
{
|
{
|
||||||
id: 'interests',
|
id: 'interests',
|
||||||
label: 'Interests',
|
label: 'Interests',
|
||||||
content: <EmptyState title="Interests" description="Coming soon" />,
|
content: <YachtInterestsTab yachtId={yachtId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reservations',
|
id: 'reservations',
|
||||||
label: 'Reservations',
|
label: 'Reservations',
|
||||||
content: <EmptyState title="Reservations" description="Coming soon" />,
|
content: <YachtReservationsTab yachtId={yachtId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ export async function getKpis(portId: string) {
|
|||||||
.from(interests)
|
.from(interests)
|
||||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
|
||||||
|
|
||||||
// Pipeline value: SUM berths.price via JOIN from non-archived interests with berthId
|
// Pipeline value: SUM each berth's price ONCE regardless of how many active
|
||||||
|
// interests reference it. A berth with multiple interests would otherwise be
|
||||||
|
// counted multiple times, inflating the total.
|
||||||
const pipelineRows = await db
|
const pipelineRows = await db
|
||||||
.select({ price: berths.price })
|
.selectDistinct({ berthId: interests.berthId, price: berths.price })
|
||||||
.from(interests)
|
.from(interests)
|
||||||
.innerJoin(berths, eq(interests.berthId, berths.id))
|
.innerJoin(berths, eq(interests.berthId, berths.id))
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
Reference in New Issue
Block a user