feat(portal): surface yachts, memberships, reservations for portal users

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 14:43:12 +02:00
parent b75834ab7e
commit a14dc8143c
6 changed files with 746 additions and 53 deletions

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import { Anchor, FileText, Receipt } from 'lucide-react';
import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
@@ -21,15 +21,12 @@ export default async function PortalDashboardPage() {
<h1 className="text-2xl font-semibold text-gray-900">
Welcome back, {dashboard.client.fullName.split(' ')[0]}
</h1>
{dashboard.client.companyName && (
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
)}
{dashboard.client.yachtName && (
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
{dashboard.client.nationality && (
<p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<PortalCard
title="Berth Interests"
value={dashboard.counts.interests}
@@ -51,13 +48,33 @@ export default async function PortalDashboardPage() {
icon={Receipt}
href="/portal/invoices"
/>
<PortalCard
title="My Yachts"
value={dashboard.counts.yachts}
description="Vessels you own directly or through a company"
icon={Sailboat}
href="/portal/my-yachts"
/>
<PortalCard
title="My Memberships"
value={dashboard.counts.memberships}
description="Companies where you hold an active role"
icon={Building2}
/>
<PortalCard
title="My Active Reservations"
value={dashboard.counts.activeReservations}
description="Current and pending berth reservations"
icon={CalendarCheck}
href="/portal/my-reservations"
/>
</div>
<div className="bg-white rounded-lg border p-6">
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
<p className="text-sm text-gray-500">
Contact the {dashboard.port.name} team directly. This portal provides a read-only view
of your account. All changes must be made through your port contact.
Contact the {dashboard.port.name} team directly. This portal provides a read-only view of
your account. All changes must be made through your port contact.
</p>
</div>
</div>

View File

@@ -0,0 +1,83 @@
import { redirect } from 'next/navigation';
import { CalendarCheck } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalUserReservations } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'My Reservations' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
active: 'default',
ended: 'outline',
cancelled: 'destructive',
};
const TENURE_LABELS: Record<string, string> = {
permanent: 'Permanent',
fixed_term: 'Fixed term',
seasonal: 'Seasonal',
};
function formatDate(d: Date | string): string {
return new Date(d).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default async function PortalMyReservationsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const reservations = await getPortalUserReservations(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My Reservations</h1>
<p className="text-sm text-gray-500 mt-1">Your current and pending berth reservations</p>
</div>
{reservations.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No active reservations</p>
<p className="text-sm text-gray-400 mt-1">
Contact your port representative to discuss reservations.
</p>
</div>
) : (
<div className="space-y-3">
{reservations.map((r) => (
<div key={r.id} className="bg-white rounded-lg border p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span>
)}
</div>
<p className="text-sm text-gray-500">
{TENURE_LABELS[r.tenureType] ?? r.tenureType}
</p>
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-400">
<span>
From {formatDate(r.startDate)}
{r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'}
</span>
</div>
</div>
<Badge variant={STATUS_COLORS[r.status] ?? 'default'}>{r.status}</Badge>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { redirect } from 'next/navigation';
import { Sailboat } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalUserYachts } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'My Yachts' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
active: 'default',
retired: 'secondary',
sold_away: 'outline',
};
export default async function PortalMyYachtsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const yachts = await getPortalUserYachts(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My Yachts</h1>
<p className="text-sm text-gray-500 mt-1">Vessels you own directly or through a company</p>
</div>
{yachts.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Sailboat className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No yachts on file</p>
<p className="text-sm text-gray-400 mt-1">
Yachts owned by you or a company you are a member of will appear here.
</p>
</div>
) : (
<div className="space-y-3">
{yachts.map((y) => (
<div key={y.id} className="bg-white rounded-lg border p-5">
<div className="flex items-start gap-4">
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">{y.name}</p>
<p className="text-sm text-gray-500 mt-0.5">
{y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'}
{y.flag ? ` · ${y.flag}` : ''}
{y.yearBuilt ? ` · ${y.yearBuilt}` : ''}
</p>
{y.ownerContext === 'company' && y.ownerCompanyName && (
<p className="text-xs text-[#1e2844] mt-2">Owned by {y.ownerCompanyName}</p>
)}
</div>
<Badge variant={STATUS_COLORS[y.status] ?? 'default'}>
{y.status.replace(/_/g, ' ')}
</Badge>
</div>
{(y.lengthFt || y.widthFt || y.registration) && (
<div className="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
{y.registration && <span>Reg: {y.registration}</span>}
{y.lengthFt && <span>Length: {y.lengthFt}ft</span>}
{y.widthFt && <span>Beam: {y.widthFt}ft</span>}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}