diff --git a/src/app/(portal)/portal/dashboard/page.tsx b/src/app/(portal)/portal/dashboard/page.tsx
index 8095a63..c5d8e6e 100644
--- a/src/app/(portal)/portal/dashboard/page.tsx
+++ b/src/app/(portal)/portal/dashboard/page.tsx
@@ -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() {
Welcome back, {dashboard.client.fullName.split(' ')[0]}
- {dashboard.client.companyName && (
- {dashboard.client.companyName}
- )}
- {dashboard.client.yachtName && (
- Vessel: {dashboard.client.yachtName}
+ {dashboard.client.nationality && (
+ {dashboard.client.nationality}
)}
-
+
Need assistance?
- 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.
diff --git a/src/app/(portal)/portal/my-reservations/page.tsx b/src/app/(portal)/portal/my-reservations/page.tsx
new file mode 100644
index 0000000..95db972
--- /dev/null
+++ b/src/app/(portal)/portal/my-reservations/page.tsx
@@ -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 = {
+ pending: 'secondary',
+ active: 'default',
+ ended: 'outline',
+ cancelled: 'destructive',
+};
+
+const TENURE_LABELS: Record = {
+ 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 (
+
+
+
My Reservations
+
Your current and pending berth reservations
+
+
+ {reservations.length === 0 ? (
+
+
+
No active reservations
+
+ Contact your port representative to discuss reservations.
+
+
+ ) : (
+
+ {reservations.map((r) => (
+
+
+
+
+ {r.yachtName ?? 'Yacht'}
+ {r.berthMooringNumber && (
+ — Berth {r.berthMooringNumber}
+ )}
+
+
+ {TENURE_LABELS[r.tenureType] ?? r.tenureType}
+
+
+
+ From {formatDate(r.startDate)}
+ {r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'}
+
+
+
+
{r.status}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(portal)/portal/my-yachts/page.tsx b/src/app/(portal)/portal/my-yachts/page.tsx
new file mode 100644
index 0000000..a96865b
--- /dev/null
+++ b/src/app/(portal)/portal/my-yachts/page.tsx
@@ -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 = {
+ 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 (
+
+
+
My Yachts
+
Vessels you own directly or through a company
+
+
+ {yachts.length === 0 ? (
+
+
+
No yachts on file
+
+ Yachts owned by you or a company you are a member of will appear here.
+
+
+ ) : (
+
+ {yachts.map((y) => (
+
+
+
+
+
+
+
{y.name}
+
+ {y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'}
+ {y.flag ? ` · ${y.flag}` : ''}
+ {y.yearBuilt ? ` · ${y.yearBuilt}` : ''}
+
+ {y.ownerContext === 'company' && y.ownerCompanyName && (
+
Owned by {y.ownerCompanyName}
+ )}
+
+
+ {y.status.replace(/_/g, ' ')}
+
+
+
+ {(y.lengthFt || y.widthFt || y.registration) && (
+
+ {y.registration && Reg: {y.registration}}
+ {y.lengthFt && Length: {y.lengthFt}ft}
+ {y.widthFt && Beam: {y.widthFt}ft}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/portal/portal-nav.tsx b/src/components/portal/portal-nav.tsx
index 0aae37a..a6f156d 100644
--- a/src/components/portal/portal-nav.tsx
+++ b/src/components/portal/portal-nav.tsx
@@ -2,12 +2,14 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { LayoutDashboard, Anchor, FileText, Receipt } from 'lucide-react';
+import { LayoutDashboard, Anchor, FileText, Receipt, Sailboat, CalendarCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
const navItems = [
{ label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard },
{ label: 'Interests', href: '/portal/interests', icon: Anchor },
+ { label: 'My Yachts', href: '/portal/my-yachts', icon: Sailboat },
+ { label: 'Reservations', href: '/portal/my-reservations', icon: CalendarCheck },
{ label: 'Documents', href: '/portal/documents', icon: FileText },
{ label: 'Invoices', href: '/portal/invoices', icon: Receipt },
];
diff --git a/src/lib/services/portal.service.ts b/src/lib/services/portal.service.ts
index 3ae7a36..b925240 100644
--- a/src/lib/services/portal.service.ts
+++ b/src/lib/services/portal.service.ts
@@ -1,4 +1,4 @@
-import { and, eq, count } from 'drizzle-orm';
+import { and, eq, count, inArray, isNull, desc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
@@ -7,6 +7,9 @@ import { documents, files } from '@/lib/db/schema/documents';
import { invoices } from '@/lib/db/schema/financial';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
+import { yachts } from '@/lib/db/schema/yachts';
+import { companies, companyMemberships } from '@/lib/db/schema/companies';
+import { berthReservations } from '@/lib/db/schema/reservations';
import { createPortalToken } from '@/lib/portal/auth';
import { sendEmail } from '@/lib/email';
import { getPresignedUrl } from '@/lib/minio';
@@ -24,10 +27,7 @@ export async function requestMagicLink(email: string): Promise {
// Find client contact with matching email
const contact = await db.query.clientContacts.findFirst({
- where: and(
- eq(clientContacts.channel, 'email'),
- eq(clientContacts.value, normalizedEmail),
- ),
+ where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, normalizedEmail)),
with: {
client: true,
},
@@ -95,11 +95,7 @@ export async function requestMagicLink(email: string): Promise {