Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalDashboard } from '@/lib/services/portal.service';
import { PortalHeader } from '@/components/portal/portal-header';
import { PortalNav } from '@/components/portal/portal-nav';
export const metadata: Metadata = {
title: {
default: 'Client Portal',
template: '%s | Client Portal',
},
};
const PUBLIC_PORTAL_PATHS = ['/portal/login', '/portal/verify'];
export default async function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
// This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down — login/verify pages handle their own
// redirect logic independently.
const session = await getPortalSession().catch(() => null);
// For authenticated routes we need client info for the header.
// If session is absent, children (login/verify pages) handle their own redirect.
let clientName = '';
let portName = 'Client Portal';
let portLogoUrl: string | null = null;
if (session) {
const dashboard = await getPortalDashboard(session.clientId, session.portId).catch(() => null);
if (dashboard) {
clientName = dashboard.client.fullName;
portName = dashboard.port.name;
portLogoUrl = dashboard.port.logoUrl;
}
}
return (
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { redirect } from 'next/navigation';
import { Anchor, FileText, Receipt } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalDashboard } from '@/lib/services/portal.service';
import { PortalCard } from '@/components/portal/portal-card';
export const metadata: Metadata = { title: 'Dashboard' };
export default async function PortalDashboardPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const dashboard = await getPortalDashboard(session.clientId, session.portId);
if (!dashboard) redirect('/portal/login');
return (
<div className="space-y-6">
<div>
<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>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<PortalCard
title="Berth Interests"
value={dashboard.counts.interests}
description="Your berth enquiries and applications"
icon={Anchor}
href="/portal/interests"
/>
<PortalCard
title="Documents"
value={dashboard.counts.documents}
description="Contracts, EOIs and signed agreements"
icon={FileText}
href="/portal/documents"
/>
<PortalCard
title="Invoices"
value={dashboard.counts.invoices}
description="Billing statements and payment history"
icon={Receipt}
href="/portal/invoices"
/>
</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.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface DocumentDownloadButtonProps {
documentId: string;
}
export function DocumentDownloadButton({ documentId }: DocumentDownloadButtonProps) {
const [loading, setLoading] = useState(false);
async function handleDownload() {
setLoading(true);
try {
const res = await fetch(`/api/portal/documents/${documentId}/download`);
if (!res.ok) {
alert('Unable to download document. Please try again.');
return;
}
const data = await res.json() as { url: string };
window.open(data.url, '_blank', 'noopener,noreferrer');
} catch {
alert('Unable to download document. Please check your connection.');
} finally {
setLoading(false);
}
}
return (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={loading}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Download className="h-3.5 w-3.5 mr-1.5" />
Download
</>
)}
</Button>
);
}

View File

@@ -0,0 +1,123 @@
import { redirect } from 'next/navigation';
import { FileText } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getClientDocuments } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
import { DocumentDownloadButton } from './document-download-button';
export const metadata: Metadata = { title: 'Documents' };
const DOC_TYPE_LABELS: Record<string, string> = {
eoi: 'Expression of Interest',
contract: 'Contract',
nda: 'NDA',
reservation_agreement: 'Reservation Agreement',
other: 'Document',
};
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
sent: 'default',
partially_signed: 'default',
completed: 'outline',
expired: 'destructive',
cancelled: 'destructive',
};
export default async function PortalDocumentsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const documents = await getClientDocuments(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Documents</h1>
<p className="text-sm text-gray-500 mt-1">
Your contracts, EOIs, and signed agreements
</p>
</div>
{documents.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<FileText className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No documents on file</p>
<p className="text-sm text-gray-400 mt-1">
Documents shared with you will appear here.
</p>
</div>
) : (
<div className="space-y-3">
{documents.map((doc) => (
<div
key={doc.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start gap-4">
<FileText 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">{doc.title}</p>
<p className="text-sm text-gray-500 mt-0.5">
{DOC_TYPE_LABELS[doc.documentType] ?? doc.documentType}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>
{doc.status.replace(/_/g, ' ')}
</Badge>
</div>
</div>
{doc.signers.length > 0 && (
<div className="mt-3 space-y-1">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
Signers
</p>
{doc.signers.map((signer, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span
className={
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500'
}
>
{signer.status === 'signed' ? '✓' : signer.status === 'declined' ? '✗' : '○'}
</span>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-between mt-3">
<p className="text-xs text-gray-400">
{new Date(doc.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
{(doc.hasSignedFile || doc.status === 'completed') && (
<DocumentDownloadButton documentId={doc.id} />
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { redirect } from 'next/navigation';
import { Anchor } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getClientInterests } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'Interests' };
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'EOI / NDA Signed',
deposit_10pct: 'Deposit Received',
contract: 'Contract Stage',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'secondary',
details_sent: 'secondary',
in_communication: 'default',
visited: 'default',
signed_eoi_nda: 'default',
deposit_10pct: 'default',
contract: 'default',
completed: 'outline',
};
export default async function PortalInterestsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const interests = await getClientInterests(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
<p className="text-sm text-gray-500 mt-1">
Your berth enquiries and applications
</p>
</div>
{interests.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Anchor className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No interests on file</p>
<p className="text-sm text-gray-400 mt-1">
Contact your port representative to discuss available berths.
</p>
</div>
) : (
<div className="space-y-3">
{interests.map((interest) => (
<div
key={interest.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">
{interest.berthMooringNumber ? (
<span className="font-medium text-gray-900">
Berth {interest.berthMooringNumber}
</span>
) : (
<span className="font-medium text-gray-900">General Interest</span>
)}
{interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span>
)}
</div>
{interest.leadCategory && (
<p className="text-sm text-gray-500 capitalize">
{interest.leadCategory.replace(/_/g, ' ')}
</p>
)}
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
{interest.dateFirstContact && (
<span>
First contact:{' '}
{new Date(interest.dateFirstContact).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
)}
{interest.eoiStatus && (
<span>EOI: {interest.eoiStatus.replace(/_/g, ' ')}</span>
)}
{interest.contractStatus && (
<span>Contract: {interest.contractStatus.replace(/_/g, ' ')}</span>
)}
</div>
</div>
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}>
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
</Badge>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { redirect } from 'next/navigation';
import { Receipt } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getClientInvoices } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'Invoices' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
sent: 'default',
paid: 'outline',
overdue: 'destructive',
cancelled: 'destructive',
};
function formatCurrency(amount: string, currency: string): string {
const num = parseFloat(amount);
if (isNaN(num)) return `${currency} ${amount}`;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(num);
}
export default async function PortalInvoicesPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const invoices = await getClientInvoices(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Invoices</h1>
<p className="text-sm text-gray-500 mt-1">
Your billing statements and payment history
</p>
</div>
{invoices.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Receipt className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No invoices on file</p>
<p className="text-sm text-gray-400 mt-1">
Invoices will appear here once issued by the port.
</p>
</div>
) : (
<div className="space-y-3">
{invoices.map((invoice) => (
<div
key={invoice.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<p className="font-medium text-gray-900">{invoice.invoiceNumber}</p>
<Badge variant={STATUS_COLORS[invoice.status] ?? 'default'}>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</Badge>
</div>
<div className="flex flex-wrap gap-4 mt-2 text-sm text-gray-500">
<span>
Due:{' '}
{new Date(invoice.dueDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
{invoice.paymentDate && (
<span className="text-green-600">
Paid:{' '}
{new Date(invoice.paymentDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
)}
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="text-lg font-semibold text-gray-900">
{formatCurrency(invoice.total, invoice.currency)}
</p>
{invoice.paymentStatus && invoice.paymentStatus !== 'unpaid' && (
<p className="text-sm text-gray-400 capitalize">{invoice.paymentStatus}</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import { Mail, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function PortalLoginPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/portal/auth/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as any).error ?? 'Something went wrong. Please try again.');
return;
}
setSubmitted(true);
} catch {
setError('Unable to connect. Please check your connection and try again.');
} finally {
setLoading(false);
}
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<Mail className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
<p className="text-gray-500 text-sm leading-relaxed">
If <strong>{email}</strong> is associated with a client account, you will receive a
sign-in link shortly. The link expires in 24 hours.
</p>
<button
type="button"
onClick={() => { setSubmitted(false); setEmail(''); }}
className="mt-6 text-sm text-[#1e2844] hover:underline"
>
Try a different email
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8 shadow-sm">
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email to receive a sign-in link
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
disabled={loading}
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<Button
type="submit"
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
disabled={loading || !email}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending link...
</>
) : (
'Send sign-in link'
)}
</Button>
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-4">
This portal is for existing clients only.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
export default function PortalVerifyPage() {
const router = useRouter();
const searchParams = useSearchParams();
const calledRef = useRef(false);
useEffect(() => {
if (calledRef.current) return;
calledRef.current = true;
const token = searchParams.get('token');
if (!token) {
router.replace('/portal/login?error=missing_token' as any);
return;
}
// Redirect to the verify API route which will set the cookie and redirect
window.location.href = `/api/portal/auth/verify?token=${encodeURIComponent(token)}`;
}, [searchParams, router]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-[#1e2844] mx-auto mb-3" />
<p className="text-sm text-gray-500">Verifying your access...</p>
</div>
</div>
);
}