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

21
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'Sign In',
template: '%s | Port Nimara CRM',
},
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div
className="min-h-screen flex items-center justify-center wave-watermark"
style={{ backgroundColor: '#1e2844' }}
>
<div className="w-full max-w-md px-4">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { authClient } from '@/lib/auth/client';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
async function onSubmit(data: LoginFormData) {
setIsLoading(true);
try {
const result = await authClient.signIn.email({
email: data.email,
password: data.password,
});
if (result.error) {
toast.error(result.error.message ?? 'Invalid email or password');
return;
}
router.push('/dashboard');
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Marina CRM</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
disabled={isLoading}
className={cn(
errors.password && 'border-destructive focus-visible:ring-destructive',
)}
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
const resetSchema = z.object({
email: z.string().email('Please enter a valid email address'),
});
type ResetFormData = z.infer<typeof resetSchema>;
export default function ResetPasswordPage() {
const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetFormData>({
resolver: zodResolver(resetSchema),
});
async function onSubmit(data: ResetFormData) {
setIsLoading(true);
try {
// Always show the same success message regardless of whether the email exists.
await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.email }),
});
setSubmitted(true);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Reset your password</p>
</CardHeader>
<CardContent>
{submitted ? (
<div className="space-y-4 text-center">
<div className="space-y-2">
<p className="font-medium text-foreground">Check your email</p>
<p className="text-sm text-muted-foreground">
If an account exists for that email address, we have sent a password reset link.
Please check your inbox and spam folder.
</p>
</div>
<Link
href="/login"
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Back to sign in
</Link>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(
errors.email && 'border-destructive focus-visible:ring-destructive',
)}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Sending…' : 'Send reset link'}
</Button>
<p className="text-center text-sm text-muted-foreground">
Remember your password?{' '}
<Link
href="/login"
className="text-foreground underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { CheckCircle2, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const passwordSchema = z
.object({
password: z
.string()
.min(12, 'Must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[a-z]/, 'Must contain a lowercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SetPasswordFormData = z.infer<typeof passwordSchema>;
type Requirement = {
label: string;
test: (value: string) => boolean;
};
const requirements: Requirement[] = [
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
];
export default function SetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [isLoading, setIsLoading] = useState(false);
const [passwordValue, setPasswordValue] = useState('');
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SetPasswordFormData>({
resolver: zodResolver(passwordSchema),
});
async function onSubmit(data: SetPasswordFormData) {
if (!token) {
toast.error('Invalid or missing reset token. Please request a new password reset link.');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/set-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password: data.password }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
toast.error(body.message ?? 'Failed to set password. Please try again.');
return;
}
toast.success('Password set successfully. You can now sign in.');
router.push('/login');
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Set your password</p>
</CardHeader>
<CardContent>
{!token ? (
<p className="text-center text-sm text-destructive">
Invalid or missing token. Please request a new password reset link.
</p>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(
errors.password && 'border-destructive focus-visible:ring-destructive',
)}
{...register('password', {
onChange: (e) => setPasswordValue(e.target.value),
})}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
<ul className="space-y-1 pt-1">
{requirements.map((req) => {
const met = req.test(passwordValue);
return (
<li
key={req.label}
className={cn(
'flex items-center gap-2 text-xs',
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
)}
>
{met ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0" />
)}
{req.label}
</li>
);
})}
</ul>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(
errors.confirmPassword &&
'border-destructive focus-visible:ring-destructive',
)}
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Setting password…' : 'Set password'}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function AuditLogPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
<p className="text-muted-foreground">Review system activity and changes</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function BackupManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
<p className="text-muted-foreground">Manage system backups and restoration</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { CustomFieldsManager } from '@/components/admin/custom-fields/custom-fields-manager';
export default function CustomFieldsPage() {
return <CustomFieldsManager />;
}

View File

@@ -0,0 +1,16 @@
export default function FormTemplatesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
<p className="text-muted-foreground">Create and manage intake form templates</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function DataImportPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
<p className="text-muted-foreground">Import data from external sources</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { QueueDetailTable } from '@/components/admin/queue-detail-table';
interface QueueDetailPageProps {
params: Promise<{ portSlug: string; queueName: string }>;
}
export default async function QueueDetailPage({ params }: QueueDetailPageProps) {
const { queueName } = await params;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground capitalize">{queueName} Queue</h1>
<p className="text-muted-foreground">Inspect and manage jobs in the {queueName} queue</p>
</div>
<QueueDetailTable queueName={queueName} />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { SystemMonitoringDashboard } from '@/components/admin/system-monitoring-dashboard';
export default function MonitoringPage() {
return <SystemMonitoringDashboard />;
}

View File

@@ -0,0 +1,16 @@
export default function OnboardingPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
<p className="text-muted-foreground">Guided setup for new port configurations</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function PortManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Port Management</h1>
<p className="text-muted-foreground">Manage port locations and configurations</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function ScheduledReportsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1>
<p className="text-muted-foreground">Configure and manage automated report delivery</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function RoleManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Role Management</h1>
<p className="text-muted-foreground">Configure roles and permissions</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function SystemSettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">System Settings</h1>
<p className="text-muted-foreground">Configure system-wide settings</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { TagList } from '@/components/admin/tags/tag-list';
export default function TagManagementPage() {
return <TagList />;
}

View File

@@ -0,0 +1,5 @@
import { TemplateList } from '@/components/admin/document-templates/template-list';
export default function DocumentTemplatesPage() {
return <TemplateList />;
}

View File

@@ -0,0 +1,16 @@
export default function UserManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground">Manage user accounts and access</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { apiFetch } from '@/lib/api/client';
import { WebhookForm } from '@/components/admin/webhooks/webhook-form';
import { WebhookDeliveryLog } from '@/components/admin/webhooks/webhook-delivery-log';
import { WebhookSecretDisplay } from '@/components/admin/webhooks/webhook-secret-display';
interface Webhook {
id: string;
name: string;
url: string;
events: string[];
isActive: boolean;
secretMasked: string;
createdAt: string;
}
export default function WebhooksPage() {
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Webhook | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [regenerating, setRegenerating] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null);
const loadWebhooks = useCallback(async () => {
try {
const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks');
setWebhooks(result.data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadWebhooks();
}, [loadWebhooks]);
async function handleDelete() {
if (!deleteTarget) return;
try {
await apiFetch(`/api/v1/admin/webhooks/${deleteTarget.id}`, { method: 'DELETE' });
setDeleteTarget(null);
void loadWebhooks();
} catch {
// ignore
}
}
async function handleRegenerate(webhookId: string) {
setRegenerating(webhookId);
try {
const result = await apiFetch<{ data: { secret: string; secretMasked: string } }>(
`/api/v1/admin/webhooks/${webhookId}/regenerate-secret`,
{ method: 'POST' },
);
setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked });
void loadWebhooks();
} catch {
// ignore
} finally {
setRegenerating(null);
}
}
async function handleToggleActive(webhook: Webhook) {
try {
await apiFetch(`/api/v1/admin/webhooks/${webhook.id}`, {
method: 'PATCH',
body: { isActive: !webhook.isActive },
});
void loadWebhooks();
} catch {
// ignore
}
}
function toggleExpand(id: string) {
setExpandedId((prev) => (prev === id ? null : id));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1>
<p className="text-muted-foreground">Configure outgoing webhook integrations</p>
</div>
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}>
Add Webhook
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : webhooks.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">No webhooks configured</p>
<p className="text-sm text-muted-foreground mt-1">
Add a webhook to receive real-time notifications of CRM events.
</p>
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}>
Add Webhook
</Button>
</div>
) : (
<div className="space-y-2">
{webhooks.map((webhook) => (
<div key={webhook.id} className="rounded-lg border bg-card">
<div className="flex items-center gap-4 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{webhook.name}</span>
<Badge variant={webhook.isActive ? 'default' : 'secondary'}>
{webhook.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">
{webhook.url}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{webhook.events.length} event{webhook.events.length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleActive(webhook)}
>
{webhook.isActive ? 'Disable' : 'Enable'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditTarget(webhook); setFormOpen(true); }}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => setDeleteTarget(webhook)}
>
Delete
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpand(webhook.id)}
>
{expandedId === webhook.id ? 'Collapse' : 'Details'}
</Button>
</div>
</div>
{expandedId === webhook.id && (
<div className="border-t px-4 py-4 space-y-6">
{/* Events */}
<div>
<h3 className="text-sm font-medium mb-2">Subscribed Events</h3>
<div className="flex flex-wrap gap-1">
{webhook.events.map((e) => (
<Badge key={e} variant="outline" className="font-mono text-xs">
{e}
</Badge>
))}
</div>
</div>
{/* Secret */}
<div>
<h3 className="text-sm font-medium mb-2">Signing Secret</h3>
{newSecret?.webhookId === webhook.id ? (
<WebhookSecretDisplay
plaintext={newSecret.secret}
masked={newSecret.masked}
/>
) : (
<WebhookSecretDisplay masked={webhook.secretMasked} />
)}
<Button
variant="outline"
size="sm"
className="mt-2"
disabled={regenerating === webhook.id}
onClick={() => handleRegenerate(webhook.id)}
>
{regenerating === webhook.id ? 'Regenerating...' : 'Regenerate Secret'}
</Button>
</div>
{/* Delivery Log */}
<div>
<h3 className="text-sm font-medium mb-2">Delivery Log</h3>
<WebhookDeliveryLog webhookId={webhook.id} />
</div>
</div>
)}
</div>
))}
</div>
)}
<WebhookForm
open={formOpen}
onOpenChange={setFormOpen}
webhook={editTarget}
onSuccess={loadWebhooks}
/>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
<AlertDialogDescription>
Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { BerthDetail } from '@/components/berths/berth-detail';
interface BerthPageProps {
params: Promise<{ portSlug: string; berthId: string }>;
}
export default async function BerthPage({ params }: BerthPageProps) {
const { berthId } = await params;
return <BerthDetail berthId={berthId} />;
}

View File

@@ -0,0 +1,5 @@
import { BerthList } from '@/components/berths/berth-list';
export default function BerthsPage() {
return <BerthList />;
}

View File

@@ -0,0 +1,16 @@
import { ClientDetail } from '@/components/clients/client-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface ClientDetailPageProps {
params: Promise<{ clientId: string }>;
}
export default async function ClientDetailPage({ params }: ClientDetailPageProps) {
const { clientId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <ClientDetail clientId={clientId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { ClientList } from '@/components/clients/client-list';
export default function ClientsPage() {
return <ClientList />;
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Grid, List, Upload } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { FileGrid } from '@/components/files/file-grid';
import { FolderTree } from '@/components/files/folder-tree';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useFileBrowserStore } from '@/stores/file-browser-store';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
export default function DocumentsPage() {
const params = useParams<{ portSlug: string }>();
const queryClient = useQueryClient();
const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore();
const [showUpload, setShowUpload] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const [renameFile, setRenameFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
queryKey: ['files'],
endpoint: '/api/v1/files',
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
});
const filesInFolder = currentFolder
? data.filter((f) => f.storagePath?.includes(currentFolder))
: data;
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files'] });
} catch {
// silent
}
};
return (
<div className="flex h-full flex-col gap-4">
<PageHeader
title="Documents"
description="Store and manage port documents and attachments"
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? (
<List className="h-4 w-4" />
) : (
<Grid className="h-4 w-4" />
)}
</Button>
<PermissionGate resource="files" action="upload">
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
<Upload className="mr-1.5 h-4 w-4" />
Upload
</Button>
</PermissionGate>
</div>
}
/>
{showUpload && (
<PermissionGate resource="files" action="upload">
<FileUploadZone
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files'] });
setShowUpload(false);
}}
/>
</PermissionGate>
)}
<div className="flex flex-1 gap-4 overflow-hidden">
{/* Folder tree sidebar */}
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Folders
</p>
<FolderTree
files={data}
currentFolder={currentFolder}
onFolderSelect={setCurrentFolder}
/>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
<FileGrid
files={filesInFolder}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={setRenameFile}
onDelete={handleDelete}
isLoading={isLoading}
/>
</main>
</div>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function EmailPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Email</h1>
<p className="text-muted-foreground">Send and manage client communications</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ExpenseDetail } from '@/components/expenses/expense-detail';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from '@/components/expenses/expense-columns';
export default function ExpenseDetailPage() {
const params = useParams<{ portSlug: string; id: string }>();
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const { data } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', params.id],
queryFn: () => apiFetch(`/api/v1/expenses/${params.id}`),
enabled: !!params.id,
});
return (
<div className="max-w-3xl mx-auto">
<ExpenseDetail
expenseId={params.id}
onEdit={() => setEditOpen(true)}
onArchived={() => router.push(`/${params.portSlug}/expenses`)}
/>
{data?.data && (
<ExpenseFormDialog
open={editOpen}
onOpenChange={setEditOpen}
expense={data.data}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Plus, Download, FileText, FileSpreadsheet } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export default function ExpensesPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null);
const [archiveExpense, setArchiveExpense] = useState<ExpenseRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<ExpenseRow>({
queryKey: ['expenses'],
endpoint: '/api/v1/expenses',
filterDefinitions: expenseFilterDefinitions,
});
useRealtimeInvalidation({
'expense:created': [['expenses']],
'expense:updated': [['expenses']],
'expense:archived': [['expenses']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveExpense(null);
},
});
async function handleExport(type: 'csv' | 'pdf') {
const res = await fetch(`/api/v1/expenses/export/${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filters),
credentials: 'include',
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `expenses.${type}`;
a.click();
URL.revokeObjectURL(url);
}
const columns = getExpenseColumns({
portSlug,
onEdit: (expense) => setEditExpense(expense),
onArchive: (expense) => setArchiveExpense(expense),
});
return (
<div className="space-y-4">
<PageHeader
title="Expenses"
description="Track and manage port expenses"
actions={
<div className="flex items-center gap-2">
<PermissionGate resource="expenses" action="view">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-4 w-4" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport('csv')}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('pdf')}>
<FileText className="mr-2 h-4 w-4" />
Export PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PermissionGate>
<PermissionGate resource="expenses" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Expense
</Button>
</PermissionGate>
</div>
}
/>
<FilterBar
filters={expenseFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No expenses found"
description="Get started by adding your first expense."
action={{ label: 'New Expense', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<ExpenseFormDialog open={createOpen} onOpenChange={setCreateOpen} />
{editExpense && (
<ExpenseFormDialog
open={!!editExpense}
onOpenChange={(open) => !open && setEditExpense(null)}
expense={editExpense}
/>
)}
<ArchiveConfirmDialog
open={!!archiveExpense}
onOpenChange={(open) => !open && setArchiveExpense(null)}
entityName={archiveExpense?.establishmentName ?? 'this expense'}
entityType="Expense"
isArchived={false}
onConfirm={() => archiveExpense && archiveMutation.mutate(archiveExpense.id)}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import { useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { Upload, Loader2, Camera, ScanLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { EXPENSE_CATEGORIES } from '@/lib/constants';
interface ScanResult {
establishment: string | null;
date: string | null;
amount: number | null;
currency: string | null;
lineItems: Array<{ description: string; amount: number }>;
confidence: number;
}
export default function ScanReceiptPage() {
const params = useParams<{ portSlug: string }>();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// Editable fields from scan
const [establishment, setEstablishment] = useState('');
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState('USD');
const [date, setDate] = useState('');
const [category, setCategory] = useState('');
const scanMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/v1/expenses/scan-receipt', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) throw new Error('Scan failed');
return res.json() as Promise<{ data: ScanResult }>;
},
onSuccess: (response) => {
const result = response.data;
setScanResult(result);
if (result.establishment) setEstablishment(result.establishment);
if (result.amount) setAmount(String(result.amount));
if (result.currency) setCurrency(result.currency);
if (result.date) setDate(result.date.split('T')[0] ?? result.date);
},
});
const saveMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/expenses', {
method: 'POST',
body: {
establishmentName: establishment,
amount: Number(amount),
currency,
category: category || undefined,
expenseDate: date ? new Date(date) : new Date(),
paymentStatus: 'unpaid',
},
}),
onSuccess: () => {
router.push(`/${params.portSlug}/expenses`);
},
});
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
scanMutation.mutate(file);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Scan Receipt</h1>
<p className="text-muted-foreground mt-1">
Upload a receipt image and we will extract the expense details automatically.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ScanLine className="h-4 w-4" />
Upload Receipt
</CardTitle>
</CardHeader>
<CardContent>
<div
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{previewUrl ? (
<img
src={previewUrl}
alt="Receipt preview"
className="max-h-64 mx-auto rounded object-contain"
/>
) : (
<div className="space-y-2">
<Upload className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Click to upload or drag and drop
</p>
<p className="text-xs text-muted-foreground">
JPEG, PNG, WebP up to 10MB
</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{scanMutation.isPending && (
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Scanning receipt...</span>
</div>
)}
</CardContent>
</Card>
{(scanResult || scanMutation.isSuccess) && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Extracted Details
{scanResult && (
<span className="text-sm font-normal text-muted-foreground ml-2">
(confidence: {Math.round((scanResult.confidence ?? 0) * 100)}%)
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="scan-amount">Amount</Label>
<Input
id="scan-amount"
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-currency">Currency</Label>
<Input
id="scan-currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
placeholder="USD"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="scan-establishment">Establishment</Label>
<Input
id="scan-establishment"
value={establishment}
onChange={(e) => setEstablishment(e.target.value)}
placeholder="Establishment name"
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-date">Date</Label>
<Input
id="scan-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="scan-category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{saveMutation.isError && (
<p className="text-sm text-destructive">
{(saveMutation.error as Error).message}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={() => router.push(`/${params.portSlug}/expenses`)}
>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !amount}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save as Expense
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { InterestDetail } from '@/components/interests/interest-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface InterestDetailPageProps {
params: Promise<{ interestId: string }>;
}
export default async function InterestDetailPage({ params }: InterestDetailPageProps) {
const { interestId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <InterestDetail interestId={interestId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { InterestList } from '@/components/interests/interest-list';
export default function InterestsPage() {
return <InterestList />;
}

View File

@@ -0,0 +1,15 @@
import { use } from 'react';
import { InvoiceDetail } from '@/components/invoices/invoice-detail';
interface InvoiceDetailPageProps {
params: Promise<{ portSlug: string; id: string }>;
}
export default function InvoiceDetailPage({ params }: InvoiceDetailPageProps) {
const { id } = use(params);
return (
<div className="max-w-4xl mx-auto space-y-6">
<InvoiceDetail invoiceId={id} />
</div>
);
}

View File

@@ -0,0 +1,388 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
import { apiFetch } from '@/lib/api/client';
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
const PAYMENT_TERMS = [
{ label: 'Immediate', value: 'immediate' },
{ label: 'Net 10', value: 'net10' },
{ label: 'Net 15', value: 'net15' },
{ label: 'Net 30', value: 'net30' },
{ label: 'Net 45', value: 'net45' },
{ label: 'Net 60', value: 'net60' },
];
const STEPS = [
{ id: 1, label: 'Client Info' },
{ id: 2, label: 'Line Items' },
{ id: 3, label: 'Review' },
];
export default function NewInvoicePage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const [step, setStep] = useState(1);
const methods = useForm<CreateInvoiceInput>({
resolver: zodResolver(createInvoiceSchema),
defaultValues: {
paymentTerms: 'net30',
currency: 'USD',
lineItems: [],
expenseIds: [],
},
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
const watchedValues = watch();
const lineItems = watchedValues.lineItems ?? [];
const subtotal = lineItems.reduce(
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
0,
);
const isNet10 = watchedValues.paymentTerms === 'net10';
const discountPct = isNet10 ? 2 : 0;
const discountAmount = (subtotal * discountPct) / 100;
const total = subtotal - discountAmount;
const createMutation = useMutation({
mutationFn: (data: CreateInvoiceInput) =>
apiFetch('/api/v1/invoices', {
method: 'POST',
body: data,
}),
onSuccess: (res: any) => {
const id = res?.data?.id;
if (id) {
router.push(`/${portSlug}/invoices/${id}`);
} else {
router.push(`/${portSlug}/invoices`);
}
},
});
async function goNext() {
if (step === 1) {
const valid = await methods.trigger([
'clientName',
'billingEmail',
'billingAddress',
'dueDate',
'paymentTerms',
'currency',
]);
if (valid) setStep(2);
} else if (step === 2) {
setStep(3);
}
}
function goBack() {
setStep((s) => Math.max(1, s - 1));
}
function onSubmit(data: CreateInvoiceInput) {
createMutation.mutate(data);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/${portSlug}/invoices`)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold">New Invoice</h1>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2">
{STEPS.map((s, idx) => (
<div key={s.id} className="flex items-center gap-2">
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-medium ${
step > s.id
? 'bg-primary text-primary-foreground'
: step === s.id
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
</div>
<span
className={`text-sm ${
step === s.id ? 'font-medium' : 'text-muted-foreground'
}`}
>
{s.label}
</span>
{idx < STEPS.length - 1 && (
<div className="w-8 h-px bg-border mx-1" />
)}
</div>
))}
</div>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Step 1: Client Info */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Client Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="clientName">
Client Name <span className="text-destructive">*</span>
</Label>
<Input
id="clientName"
{...register('clientName')}
placeholder="Client or company name"
/>
{errors.clientName && (
<p className="text-xs text-destructive">{errors.clientName.message}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="billingEmail">Billing Email</Label>
<Input
id="billingEmail"
type="email"
{...register('billingEmail')}
placeholder="billing@example.com"
/>
{errors.billingEmail && (
<p className="text-xs text-destructive">{errors.billingEmail.message}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="billingAddress">Billing Address</Label>
<Textarea
id="billingAddress"
{...register('billingAddress')}
placeholder="Address"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="dueDate">
Due Date <span className="text-destructive">*</span>
</Label>
<Input
id="dueDate"
type="date"
{...register('dueDate')}
/>
{errors.dueDate && (
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Payment Terms</Label>
<Select
defaultValue="net30"
onValueChange={(v) => setValue('paymentTerms', v as any)}
>
<SelectTrigger>
<SelectValue placeholder="Select terms" />
</SelectTrigger>
<SelectContent>
{PAYMENT_TERMS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label>Currency</Label>
<Input
{...register('currency')}
placeholder="USD"
maxLength={3}
className="uppercase w-24"
/>
</div>
<div className="space-y-1">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
{...register('notes')}
placeholder="Payment instructions or notes..."
rows={3}
/>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Line Items */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Line Items</CardTitle>
</CardHeader>
<CardContent>
<InvoiceLineItems name="lineItems" />
{errors.lineItems && !Array.isArray(errors.lineItems) && (
<p className="text-xs text-destructive mt-2">
{(errors.lineItems as any).message}
</p>
)}
{errors.root && (
<p className="text-xs text-destructive mt-2">{errors.root.message}</p>
)}
</CardContent>
</Card>
)}
{/* Step 3: Review */}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Review & Create</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Client</span>
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
<p className="font-medium mt-0.5">{watchedValues.dueDate}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Terms</span>
<p className="font-medium mt-0.5 capitalize">
{watchedValues.paymentTerms}
</p>
</div>
<div>
<span className="text-muted-foreground">Currency</span>
<p className="font-medium mt-0.5">{watchedValues.currency}</p>
</div>
</div>
{lineItems.length > 0 && (
<div className="border rounded-md p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Line Items
</p>
{lineItems.map((li, i) => (
<div key={i} className="flex justify-between text-sm">
<span>{li.description}</span>
<span className="tabular-nums">
{(Number(li.quantity) * Number(li.unitPrice)).toFixed(2)}{' '}
{watchedValues.currency}
</span>
</div>
))}
</div>
)}
<div className="border rounded-md p-3 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">
{subtotal.toFixed(2)} {watchedValues.currency}
</span>
</div>
{isNet10 && (
<div className="flex justify-between text-green-600">
<span>Net 10 Discount (~2%)</span>
<span className="tabular-nums">
-{discountAmount.toFixed(2)} {watchedValues.currency}
</span>
</div>
)}
<div className="flex justify-between font-semibold border-t pt-2 mt-1">
<span>Total</span>
<span className="tabular-nums">
{total.toFixed(2)} {watchedValues.currency}
</span>
</div>
</div>
{createMutation.isError && (
<p className="text-sm text-destructive">
Failed to create invoice. Please try again.
</p>
)}
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={step === 1}
>
<ChevronLeft className="mr-1.5 h-4 w-4" />
Back
</Button>
{step < 3 ? (
<Button type="button" onClick={goNext}>
Next
<ChevronRight className="ml-1.5 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Check className="mr-1.5 h-4 w-4" />
)}
Create Invoice
</Button>
)}
</div>
</form>
</FormProvider>
</div>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { PermissionGate } from '@/components/shared/permission-gate';
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
const STATUS_TABS = [
{ label: 'All', value: '' },
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Paid', value: 'paid' },
{ label: 'Overdue', value: 'overdue' },
] as const;
export default function InvoicesPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<InvoiceRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<InvoiceRow>({
queryKey: ['invoices'],
endpoint: '/api/v1/invoices',
filterDefinitions: invoiceFilterDefinitions,
});
const activeStatus = (filters.status as string) ?? '';
useRealtimeInvalidation({
'invoice:created': [['invoices']],
'invoice:updated': [['invoices']],
'invoice:sent': [['invoices']],
'invoice:paid': [['invoices']],
'invoice:overdue': [['invoices']],
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setDeleteTarget(null);
},
});
const sendMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
},
});
const columns = getInvoiceColumns({
portSlug,
onSend: (invoice) => sendMutation.mutate(invoice.id),
onRecordPayment: (invoice) =>
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
onDelete: (invoice) => setDeleteTarget(invoice),
});
return (
<div className="space-y-4">
<PageHeader
title="Invoices"
description="Create and manage port invoices"
actions={
<PermissionGate resource="invoices" action="create">
<Button size="sm" onClick={() => router.push(`/${portSlug}/invoices/new`)}>
<Plus className="mr-1.5 h-4 w-4" />
New Invoice
</Button>
</PermissionGate>
}
/>
{/* Status quick-filter tabs */}
<div className="flex items-center gap-1 border-b">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setFilter('status', tab.value || undefined)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeStatus === tab.value
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
<FilterBar
filters={invoiceFilterDefinitions.filter((f) => f.key !== 'status')}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No invoices found"
description="Get started by creating your first invoice."
action={{
label: 'New Invoice',
onClick: () => router.push(`/${portSlug}/invoices/new`),
}}
/>
}
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
<h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground">
This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>.
This action cannot be undone.
</p>
<div className="flex items-center gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
export default function DashboardPage() {
return <DashboardShell />;
}

View File

@@ -0,0 +1,16 @@
export default function RemindersPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Reminders</h1>
<p className="text-muted-foreground">Manage tasks and follow-up reminders</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { ReportsPageClient } from '@/components/reports/reports-page-client';
export default function ReportsPage() {
return <ReportsPageClient />;
}

View File

@@ -0,0 +1,16 @@
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage your account and port preferences</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userPortRoles } from '@/lib/db/schema/users';
import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
// Load user's port assignments for PortProvider
const portRoles = await db.query.userPortRoles.findMany({
where: eq(userPortRoles.userId, session.user.id),
with: { port: true, role: true },
});
const ports = portRoles.map((pr) => pr.port);
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}>
<PermissionsProvider>
<SocketProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar portRoles={portRoles} />
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar ports={ports} />
<main className="flex-1 overflow-y-auto bg-background p-6">
{children}
</main>
</div>
</div>
</SocketProvider>
</PermissionsProvider>
</PortProvider>
</QueryProvider>
);
}

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>
);
}

View File

@@ -0,0 +1,4 @@
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
import { minioClient } from '@/lib/minio';
import { env } from '@/lib/env';
import { sql } from 'drizzle-orm';
type CheckStatus = 'ok' | 'error';
interface HealthChecks {
postgres: CheckStatus;
redis: CheckStatus;
minio: CheckStatus;
}
interface HealthResponse {
status: 'healthy' | 'degraded';
checks: HealthChecks;
timestamp: string;
}
export async function GET(): Promise<NextResponse<HealthResponse>> {
const checks: HealthChecks = {
postgres: 'error',
redis: 'error',
minio: 'error',
};
await Promise.allSettled([
db
.execute(sql`SELECT 1`)
.then(() => {
checks.postgres = 'ok';
})
.catch(() => {
checks.postgres = 'error';
}),
redis
.ping()
.then(() => {
checks.redis = 'ok';
})
.catch(() => {
checks.redis = 'error';
}),
minioClient
.bucketExists(env.MINIO_BUCKET)
.then(() => {
checks.minio = 'ok';
})
.catch(() => {
checks.minio = 'error';
}),
]);
const allHealthy = Object.values(checks).every((s) => s === 'ok');
const status: HealthResponse['status'] = allHealthy ? 'healthy' : 'degraded';
const body: HealthResponse = {
status,
checks,
timestamp: new Date().toISOString(),
};
return NextResponse.json(body, { status: allHealthy ? 200 : 503 });
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { PORTAL_COOKIE } from '@/lib/portal/auth';
import { env } from '@/lib/env';
export async function POST(): Promise<NextResponse> {
const response = NextResponse.redirect(new URL('/portal/login', env.APP_URL));
response.cookies.delete(PORTAL_COOKIE);
return response;
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { requestMagicLink } from '@/lib/services/portal.service';
import { logger } from '@/lib/logger';
const bodySchema = z.object({
email: z.string().email(),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const body = await req.json();
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
}
await requestMagicLink(parsed.data.email);
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (error) {
logger.error({ error }, 'Portal magic link request failed');
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyPortalToken, PORTAL_COOKIE } from '@/lib/portal/auth';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
export async function GET(req: NextRequest): Promise<NextResponse> {
try {
const token = req.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.redirect(new URL('/portal/login?error=missing_token', env.APP_URL));
}
const session = await verifyPortalToken(token);
if (!session) {
return NextResponse.redirect(new URL('/portal/login?error=invalid_token', env.APP_URL));
}
const response = NextResponse.redirect(new URL('/portal/dashboard', env.APP_URL));
response.cookies.set(PORTAL_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24, // 24 hours
});
logger.info({ clientId: session.clientId }, 'Portal session created');
return response;
} catch (error) {
logger.error({ error }, 'Portal token verification failed');
return NextResponse.redirect(new URL('/portal/login?error=server_error', env.APP_URL));
}
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { withPortalAuth } from '@/lib/portal/helpers';
import { getPortalDashboard } from '@/lib/services/portal.service';
import { logger } from '@/lib/logger';
export const GET = withPortalAuth(async (_req, session) => {
try {
const dashboard = await getPortalDashboard(session.clientId, session.portId);
if (!dashboard) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 });
}
return NextResponse.json({ data: dashboard });
} catch (error) {
logger.error({ error }, 'Portal dashboard fetch failed');
return NextResponse.json({ error: 'Failed to load dashboard' }, { status: 500 });
}
});

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withPortalAuth } from '@/lib/portal/helpers';
import { getDocumentDownloadUrl } from '@/lib/services/portal.service';
import { logger } from '@/lib/logger';
export const GET = withPortalAuth(async (_req, session, params) => {
try {
const documentId = params.documentId;
if (!documentId) {
return NextResponse.json({ error: 'Document ID required' }, { status: 400 });
}
const url = await getDocumentDownloadUrl(session.clientId, documentId, session.portId);
if (!url) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 });
}
return NextResponse.json({ url });
} catch (error) {
logger.error({ error }, 'Portal document download URL fetch failed');
return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 });
}
});

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { withPortalAuth } from '@/lib/portal/helpers';
import { getClientDocuments } from '@/lib/services/portal.service';
import { logger } from '@/lib/logger';
export const GET = withPortalAuth(async (_req, session) => {
try {
const data = await getClientDocuments(session.clientId, session.portId);
return NextResponse.json({ data });
} catch (error) {
logger.error({ error }, 'Portal documents fetch failed');
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
}
});

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { withPortalAuth } from '@/lib/portal/helpers';
import { getClientInterests } from '@/lib/services/portal.service';
import { logger } from '@/lib/logger';
export const GET = withPortalAuth(async (_req, session) => {
try {
const data = await getClientInterests(session.clientId, session.portId);
return NextResponse.json({ data });
} catch (error) {
logger.error({ error }, 'Portal interests fetch failed');
return NextResponse.json({ error: 'Failed to load interests' }, { status: 500 });
}
});

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { withPortalAuth } from '@/lib/portal/helpers';
import { getClientInvoices } from '@/lib/services/portal.service';
import { logger } from '@/lib/logger';
export const GET = withPortalAuth(async (_req, session) => {
try {
const data = await getClientInvoices(session.clientId, session.portId);
return NextResponse.json({ data });
} catch (error) {
logger.error({ error }, 'Portal invoices fetch failed');
return NextResponse.json({ error: 'Failed to load invoices' }, { status: 500 });
}
});

View File

@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, RateLimitError } from '@/lib/errors';
import { publicInterestSchema } from '@/lib/validators/interests';
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
// Max 5 requests per hour per IP
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
const MAX_HITS = 5;
function checkRateLimit(ip: string): void {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now > entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return;
}
if (entry.count >= MAX_HITS) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
throw new RateLimitError(retryAfter);
}
entry.count += 1;
}
// POST /api/public/interests — unauthenticated public interest registration
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
checkRateLimit(ip);
const body = await req.json();
const data = publicInterestSchema.parse(body);
// Resolve portId from query param or header (public endpoints need explicit port)
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
if (!portId) {
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
}
// Find or create client by email
let clientId: string;
const existingContact = await db.query.clientContacts.findFirst({
where: and(
eq(clientContacts.channel, 'email'),
eq(clientContacts.value, data.email),
),
});
if (existingContact) {
// Find the client associated with this contact
const existingClient = await db.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId),
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
} else {
// Create new client for this port
const [newClient] = await db
.insert(clients)
.values({
portId,
fullName: data.fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
source: 'website',
})
.returning();
clientId = newClient!.id;
await db.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
if (data.phone) {
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
}
}
} else {
// Create brand-new client
const [newClient] = await db
.insert(clients)
.values({
portId,
fullName: data.fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
source: 'website',
})
.returning();
clientId = newClient!.id;
await db.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
if (data.phone) {
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
}
}
// Create the interest
const [interest] = await db
.insert(interests)
.values({
portId,
clientId,
source: 'website',
pipelineStage: 'open',
notes: data.notes,
})
.returning();
void createAuditLog({
userId: null as unknown as string,
portId,
action: 'create',
entityType: 'interest',
entityId: interest!.id,
newValue: { clientId, source: 'website', pipelineStage: 'open' },
metadata: { type: 'public_registration', ip },
ipAddress: ip,
userAgent: req.headers.get('user-agent') ?? 'unknown',
});
return NextResponse.json(
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getActiveConnections } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const connections = await getActiveConnections();
return NextResponse.json({ data: connections });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { updateFieldSchema } from '@/lib/validators/custom-fields';
import {
updateDefinition,
deleteDefinition,
} from '@/lib/services/custom-fields.service';
export const PATCH = withAuth(
withPermission(
'admin',
'manage_custom_fields',
async (req: NextRequest, ctx, params) => {
try {
const { fieldId } = params;
if (!fieldId) throw new NotFoundError('Custom field');
const body = await req.json();
// Parse only allowed fields; if fieldType sneaks in, the service will catch it
const data = updateFieldSchema.parse(body);
// Pass raw body too so service can detect fieldType mutation attempts
const updated = await updateDefinition(
ctx.portId,
fieldId,
ctx.userId,
{ ...data, ...(body.fieldType !== undefined && { fieldType: body.fieldType }) },
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
},
),
);
export const DELETE = withAuth(
withPermission(
'admin',
'manage_custom_fields',
async (_req: NextRequest, ctx, params) => {
try {
const { fieldId } = params;
if (!fieldId) throw new NotFoundError('Custom field');
const result = await deleteDefinition(ctx.portId, fieldId, ctx.userId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
},
),
);

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { createFieldSchema } from '@/lib/validators/custom-fields';
import {
listDefinitions,
createDefinition,
} from '@/lib/services/custom-fields.service';
export const GET = withAuth(
withPermission('admin', 'manage_custom_fields', async (req: NextRequest, ctx) => {
try {
const { searchParams } = new URL(req.url);
const entityType = searchParams.get('entityType') ?? undefined;
const data = await listDefinitions(ctx.portId, entityType);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_custom_fields', async (req: NextRequest, ctx) => {
try {
const body = await req.json();
const data = createFieldSchema.parse(body);
const definition = await createDefinition(ctx.portId, ctx.userId, data, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: definition }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getRecentErrors } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (req: NextRequest, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const url = new URL(req.url);
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10)));
const errors = await getRecentErrors(limit);
return NextResponse.json({ data: errors });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { healthCheck } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const status = await healthCheck();
return NextResponse.json({ data: status });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { retryJob } from '@/lib/services/system-monitoring.service';
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
export const POST = withAuth(async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const queueName = params['queueName'];
const jobId = params['jobId'];
if (!queueName || !jobId) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
}
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
if (!validQueues.includes(queueName as QueueName)) {
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
}
await retryJob(queueName as QueueName, jobId, ctx.userId);
return NextResponse.json({ data: { success: true } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { deleteJob } from '@/lib/services/system-monitoring.service';
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const queueName = params['queueName'];
const jobId = params['jobId'];
if (!queueName || !jobId) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
}
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
if (!validQueues.includes(queueName as QueueName)) {
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
}
await deleteJob(queueName as QueueName, jobId, ctx.userId);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getQueueJobs } from '@/lib/services/system-monitoring.service';
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
export const GET = withAuth(async (req: NextRequest, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { queueName } = params;
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
if (!validQueues.includes(queueName as QueueName)) {
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
}
const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'failed') as
| 'waiting'
| 'active'
| 'completed'
| 'failed'
| 'delayed';
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10)));
const result = await getQueueJobs(queueName as QueueName, status, page, limit);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getQueueDashboard } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const queues = await getQueueDashboard();
return NextResponse.json({ data: queues });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { roles } from '@/lib/db/schema';
import { errorResponse, NotFoundError } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, _ctx, params) => {
try {
const { id } = params;
if (!id) {
throw new NotFoundError('Role');
}
const role = await db.query.roles.findFirst({
where: eq(roles.id, id),
});
if (!role) {
throw new NotFoundError('Role');
}
return NextResponse.json({ data: role });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, _ctx) => {
try {
const data = await db.query.roles.findMany({
orderBy: (roles, { asc }) => [asc(roles.name)],
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { rollbackAdminTemplate } from '@/lib/services/document-templates.service';
import { rollbackAdminTemplateSchema } from '@/lib/validators/document-templates';
/**
* POST /api/v1/admin/templates/[templateId]/rollback
* Rolls the template back to a previously saved version.
* Creates a new version number (current + 1) with the restored content.
*
* Body: { version: number }
*/
export const POST = withAuth(
withPermission('documents', 'manage', async (req, ctx, params) => {
try {
const body = await parseBody(req, rollbackAdminTemplateSchema);
const result = await rollbackAdminTemplate(
ctx.portId,
params.templateId!,
body.version,
ctx.userId,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
getAdminTemplate,
updateAdminTemplate,
deleteAdminTemplate,
} from '@/lib/services/document-templates.service';
import { updateAdminTemplateSchema } from '@/lib/validators/document-templates';
/**
* GET /api/v1/admin/templates/[templateId]
* Retrieve a single TipTap-based document template.
*/
export const GET = withAuth(
withPermission('documents', 'manage', async (req, ctx, params) => {
try {
const template = await getAdminTemplate(ctx.portId, params.templateId!);
return NextResponse.json({ data: template });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* PATCH /api/v1/admin/templates/[templateId]
* Update a TipTap-based document template. Increments version if content changes.
*/
export const PATCH = withAuth(
withPermission('documents', 'manage', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateAdminTemplateSchema);
const updated = await updateAdminTemplate(
ctx.portId,
params.templateId!,
ctx.userId,
body,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* DELETE /api/v1/admin/templates/[templateId]
* Delete a TipTap-based document template.
*/
export const DELETE = withAuth(
withPermission('documents', 'manage', async (req, ctx, params) => {
try {
await deleteAdminTemplate(
ctx.portId,
params.templateId!,
ctx.userId,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getAdminTemplateVersions } from '@/lib/services/document-templates.service';
/**
* GET /api/v1/admin/templates/[templateId]/versions
* Returns version history for a template, sourced from audit_logs.
*/
export const GET = withAuth(
withPermission('documents', 'manage', async (req, ctx, params) => {
try {
const versions = await getAdminTemplateVersions(
ctx.portId,
params.templateId!,
);
return NextResponse.json({ data: versions });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { generatePdf } from '@/lib/pdf/generate';
import {
validateTipTapDocument,
tipTapToPdfmeTemplate,
buildContentInputsFromDoc,
substituteVariables,
type TipTapNode,
} from '@/lib/pdf/tiptap-to-pdfme';
import { previewAdminTemplateSchema } from '@/lib/validators/document-templates';
/**
* POST /api/v1/admin/templates/preview
*
* Generates a preview PDF from a TipTap JSON content block.
* Returns { data: { pdfBase64: string } } — the client can render this
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
*
* Body:
* content: TipTap JSON document
* sampleData?: Record<string, string> — variable substitutions
*/
export const POST = withAuth(
withPermission('documents', 'manage', async (req, _ctx) => {
try {
const body = await parseBody(req, previewAdminTemplateSchema);
const doc = body.content as unknown as TipTapNode;
const sampleData = body.sampleData ?? {};
// Validate content nodes
const unsupported = validateTipTapDocument(doc);
if (unsupported.length > 0) {
throw new ValidationError(
`Content contains unsupported node types: ${unsupported.join(', ')}`,
);
}
// Substitute variables in text nodes
const substitutedDoc = substituteInDoc(doc, sampleData);
// Convert to pdfme template + inputs
const template = tipTapToPdfmeTemplate(substitutedDoc);
const inputs = buildContentInputsFromDoc(substitutedDoc, template);
const pdfBytes = await generatePdf(template, inputs);
const pdfBase64 = Buffer.from(pdfBytes).toString('base64');
return NextResponse.json({ data: { pdfBase64 } });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
*/
function substituteInDoc(
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) };
}
if (node.content) {
return {
...node,
content: node.content.map((child) => substituteInDoc(child, data)),
};
}
return node;
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
listAdminTemplates,
createAdminTemplate,
} from '@/lib/services/document-templates.service';
import {
listAdminTemplatesSchema,
createAdminTemplateSchema,
} from '@/lib/validators/document-templates';
/**
* GET /api/v1/admin/templates
* List TipTap-based document templates for the port.
*/
export const GET = withAuth(
withPermission('documents', 'manage', async (req, ctx) => {
try {
const query = parseQuery(req, listAdminTemplatesSchema);
const data = await listAdminTemplates(ctx.portId, query);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* POST /api/v1/admin/templates
* Create a new TipTap-based document template.
*/
export const POST = withAuth(
withPermission('documents', 'manage', async (req, ctx) => {
try {
const body = await parseBody(req, createAdminTemplateSchema);
const template = await createAdminTemplate(ctx.portId, ctx.userId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: template }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { userPortRoles, userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(async (_req, ctx) => {
try {
const rows = await db
.select({
id: userPortRoles.userId,
displayName: userProfiles.displayName,
})
.from(userPortRoles)
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.where(eq(userPortRoles.portId, ctx.portId))
.orderBy(userProfiles.displayName);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { userPortRoles, userProfiles, roles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => {
try {
const rows = await db
.select({
userId: userPortRoles.userId,
displayName: userProfiles.displayName,
isActive: userProfiles.isActive,
lastLoginAt: userProfiles.lastLoginAt,
roleId: roles.id,
roleName: roles.name,
})
.from(userPortRoles)
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(eq(userPortRoles.portId, ctx.portId))
.orderBy(userProfiles.displayName);
const data = rows.map((row) => ({
userId: row.userId,
displayName: row.displayName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt,
role: { id: row.roleId, name: row.roleName },
}));
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listDeliveriesSchema } from '@/lib/validators/webhooks';
import { listDeliveries } from '@/lib/services/webhooks.service';
// ─── GET /api/v1/admin/webhooks/[webhookId]/deliveries ────────────────────────
export const GET = withAuth(
withPermission('admin', 'manage_webhooks', async (req: NextRequest, ctx, params) => {
try {
const { webhookId } = params;
const query = parseQuery(req, listDeliveriesSchema);
const result = await listDeliveries(ctx.portId, webhookId!, query);
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { regenerateSecret } from '@/lib/services/webhooks.service';
// ─── POST /api/v1/admin/webhooks/[webhookId]/regenerate-secret ────────────────
export const POST = withAuth(
withPermission('admin', 'manage_webhooks', async (_req: NextRequest, ctx, params) => {
try {
const { webhookId } = params;
const data = await regenerateSecret(ctx.portId, webhookId!, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { updateWebhookSchema } from '@/lib/validators/webhooks';
import {
getWebhook,
updateWebhook,
deleteWebhook,
} from '@/lib/services/webhooks.service';
// ─── GET /api/v1/admin/webhooks/[webhookId] ───────────────────────────────────
export const GET = withAuth(
withPermission('admin', 'manage_webhooks', async (_req: NextRequest, ctx, params) => {
try {
const { webhookId } = params;
const data = await getWebhook(ctx.portId, webhookId!);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
// ─── PATCH /api/v1/admin/webhooks/[webhookId] ─────────────────────────────────
export const PATCH = withAuth(
withPermission('admin', 'manage_webhooks', async (req: NextRequest, ctx, params) => {
try {
const { webhookId } = params;
const body = await parseBody(req, updateWebhookSchema);
const data = await updateWebhook(ctx.portId, webhookId!, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
// ─── DELETE /api/v1/admin/webhooks/[webhookId] ────────────────────────────────
export const DELETE = withAuth(
withPermission('admin', 'manage_webhooks', async (_req: NextRequest, ctx, params) => {
try {
const { webhookId } = params;
await deleteWebhook(ctx.portId, webhookId!, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { WEBHOOK_EVENTS } from '@/lib/services/webhook-event-map';
import { sendTestWebhook } from '@/lib/services/webhooks.service';
const testWebhookSchema = z.object({
eventType: z.enum(WEBHOOK_EVENTS).default('client.created'),
});
// ─── POST /api/v1/admin/webhooks/[webhookId]/test ─────────────────────────────
export const POST = withAuth(
withPermission('admin', 'manage_webhooks', async (req: NextRequest, ctx, params) => {
try {
const { webhookId } = params;
const body = await parseBody(req, testWebhookSchema);
const result = await sendTestWebhook(ctx.portId, webhookId!, body.eventType);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createWebhookSchema } from '@/lib/validators/webhooks';
import { listWebhooks, createWebhook } from '@/lib/services/webhooks.service';
// ─── GET /api/v1/admin/webhooks ───────────────────────────────────────────────
export const GET = withAuth(
withPermission('admin', 'manage_webhooks', async (_req: NextRequest, ctx) => {
try {
const data = await listWebhooks(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
// ─── POST /api/v1/admin/webhooks ──────────────────────────────────────────────
export const POST = withAuth(
withPermission('admin', 'manage_webhooks', async (req: NextRequest, ctx) => {
try {
const body = await parseBody(req, createWebhookSchema);
const webhook = await createWebhook(ctx.portId, ctx.userId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: webhook }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(async (_req, _ctx, params) => {
try {
const { jobId } = params;
if (!jobId) {
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
}
const result = await getEmailDraftResult(jobId);
if (result === null) {
return NextResponse.json({ status: 'processing' });
}
return NextResponse.json({ status: 'complete', data: result });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { requestEmailDraft } from '@/lib/services/email-draft.service';
import { parseBody } from '@/lib/api/route-helpers';
import { requestDraftSchema } from '@/lib/validators/ai';
import { errorResponse } from '@/lib/errors';
export const POST = withAuth(async (req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'ai_email_drafts'),
eq(systemSettings.portId, ctx.portId),
),
});
if (flag?.value !== true) {
return NextResponse.json({ error: 'Feature not available' }, { status: 404 });
}
const body = await parseBody(req, requestDraftSchema);
const { jobId } = await requestEmailDraft(ctx.userId, {
interestId: body.interestId,
clientId: body.clientId,
portId: ctx.portId,
context: body.context,
additionalInstructions: body.additionalInstructions,
});
return NextResponse.json({ jobId }, { status: 202 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { calculateBulkScores } from '@/lib/services/interest-scoring.service';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(async (_req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'ai_interest_scoring'),
eq(systemSettings.portId, ctx.portId),
),
});
if (flag?.value !== true) {
return NextResponse.json({ error: 'Feature not available' }, { status: 404 });
}
const scores = await calculateBulkScores(ctx.portId);
return NextResponse.json({ data: scores });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { calculateInterestScore } from '@/lib/services/interest-scoring.service';
import { parseQuery } from '@/lib/api/route-helpers';
import { requestScoreSchema } from '@/lib/validators/ai';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(async (req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'ai_interest_scoring'),
eq(systemSettings.portId, ctx.portId),
),
});
if (flag?.value !== true) {
return NextResponse.json({ error: 'Feature not available' }, { status: 404 });
}
const { interestId } = parseQuery(req, requestScoreSchema);
const score = await calculateInterestScore(interestId, ctx.portId);
return NextResponse.json({ data: score });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { exportBerthPdf } from '@/lib/services/record-export';
export const POST = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const pdfBytes = await exportBerthPdf(params.id!, ctx.portId);
return new NextResponse(Buffer.from(pdfBytes), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="berth-spec.pdf"',
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { addMaintenanceLogSchema } from '@/lib/validators/berths';
import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]/maintenance
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const logs = await getMaintenanceLogs(params.id!, ctx.portId);
return NextResponse.json({ data: logs });
} catch (error) {
return errorResponse(error);
}
}),
);
// POST /api/v1/berths/[id]/maintenance
export const POST = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, addMaintenanceLogSchema);
const log = await addMaintenanceLog(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: log }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthSchema } from '@/lib/validators/berths';
import { getBerthById, updateBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const berth = await getBerthById(params.id!, ctx.portId);
return NextResponse.json({ data: berth });
} catch (error) {
return errorResponse(error);
}
}),
);
// PATCH /api/v1/berths/[id] — update berth fields (no DELETE — import-only)
export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateBerthSchema);
const updated = await updateBerth(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthStatusSchema } from '@/lib/validators/berths';
import { updateBerthStatus } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// PATCH /api/v1/berths/[id]/status
export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateBerthStatusSchema);
const updated = await updateBerthStatus(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { setBerthTags } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
const setTagsSchema = z.object({
tagIds: z.array(z.string()),
});
// PUT /api/v1/berths/[id]/tags
export const PUT = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, setTagsSchema);
const result = await setBerthTags(params.id!, ctx.portId, body.tagIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateWaitingListSchema } from '@/lib/validators/berths';
import { reorderWaitingListSchema } from '@/lib/validators/interests';
import { getWaitingList, updateWaitingList } from '@/lib/services/berths.service';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { db } from '@/lib/db';
import { berthWaitingList } from '@/lib/db/schema/berths';
// GET /api/v1/berths/[id]/waiting-list
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const entries = await getWaitingList(params.id!, ctx.portId);
return NextResponse.json({ data: entries });
} catch (error) {
return errorResponse(error);
}
}),
);
// PUT /api/v1/berths/[id]/waiting-list
export const PUT = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateWaitingListSchema);
const entries = await updateWaitingList(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: entries });
} catch (error) {
return errorResponse(error);
}
}),
);
// PATCH /api/v1/berths/[id]/waiting-list — reorder a single entry
export const PATCH = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try {
const body = await parseBody(req, reorderWaitingListSchema);
const berthId = params.id!;
const entry = await db.query.berthWaitingList.findFirst({
where: and(
eq(berthWaitingList.id, body.entryId),
eq(berthWaitingList.berthId, berthId),
),
});
if (!entry) throw new NotFoundError('Waiting list entry');
if (entry.position !== body.newPosition) {
// Fetch all entries sorted by position
const allEntries = await db
.select()
.from(berthWaitingList)
.where(eq(berthWaitingList.berthId, berthId))
.orderBy(berthWaitingList.position);
// Remove the moved entry then insert at newPosition (1-indexed)
const others = allEntries.filter((e) => e.id !== body.entryId);
others.splice(body.newPosition - 1, 0, entry);
// Update all positions in sequence
for (let i = 0; i < others.length; i++) {
await db
.update(berthWaitingList)
.set({ position: i + 1 })
.where(eq(berthWaitingList.id, others[i]!.id));
}
}
const entries = await db
.select()
.from(berthWaitingList)
.where(eq(berthWaitingList.berthId, berthId))
.orderBy(berthWaitingList.position);
return NextResponse.json({ data: entries });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
export const GET = withAuth(async (req, ctx) => {
try {
const options = await getBerthOptions(ctx.portId);
return NextResponse.json({ data: options });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { listBerthsSchema } from '@/lib/validators/berths';
import { listBerths } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths — list berths for the current port (no POST — import-only)
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listBerthsSchema);
const result = await listBerths(ctx.portId, query);
const page = query.page;
const pageSize = query.limit;
const total = result.total;
const totalPages = Math.ceil(total / pageSize);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { updateContact, removeContact } from '@/lib/services/clients.service';
const updateContactSchema = z.object({
channel: z.enum(['email', 'phone', 'whatsapp', 'other']).optional(),
value: z.string().min(1).optional(),
label: z.string().optional(),
isPrimary: z.boolean().optional(),
notes: z.string().optional(),
});
export const PATCH = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateContactSchema);
const contact = await updateContact(
params.contactId!,
params.id!,
ctx.portId,
body,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: contact });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
await removeContact(params.contactId!, params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listContacts, addContact } from '@/lib/services/clients.service';
const addContactSchema = z.object({
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
value: z.string().min(1),
label: z.string().optional(),
isPrimary: z.boolean().optional().default(false),
notes: z.string().optional(),
});
export const GET = withAuth(
withPermission('clients', 'view', async (req, ctx, params) => {
try {
const contacts = await listContacts(params.id!, ctx.portId);
return NextResponse.json({ data: contacts });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, addContactSchema);
const contact = await addContact(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: contact }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { exportClientPdf } from '@/lib/services/record-export';
export const POST = withAuth(
withPermission('clients', 'view', async (req, ctx, params) => {
try {
const pdfBytes = await exportClientPdf(params.id!, ctx.portId);
return new NextResponse(Buffer.from(pdfBytes), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="client-summary.pdf"',
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { updateNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const PATCH = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const clientId = params.id;
const noteId = params.noteId;
if (!clientId) throw new NotFoundError('Client');
if (!noteId) throw new NotFoundError('Note');
const body = await parseBody(req, updateNoteSchema);
const note = await notesService.update(ctx.portId, 'clients', clientId, noteId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'client_note',
entityId: noteId,
metadata: { clientId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('clients', 'edit', async (_req, ctx, params) => {
try {
const clientId = params.id;
const noteId = params.noteId;
if (!clientId) throw new NotFoundError('Client');
if (!noteId) throw new NotFoundError('Note');
await notesService.deleteNote(ctx.portId, 'clients', clientId, noteId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'client_note',
entityId: noteId,
metadata: { clientId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
withPermission('clients', 'view', async (_req, ctx, params) => {
try {
const clientId = params.id;
if (!clientId) throw new NotFoundError('Client');
const notes = await notesService.listForEntity(ctx.portId, 'clients', clientId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const clientId = params.id;
if (!clientId) throw new NotFoundError('Client');
const body = await parseBody(req, createNoteSchema);
const note = await notesService.create(ctx.portId, 'clients', clientId, ctx.userId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'client_note',
entityId: note.id,
metadata: { clientId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
emitToRoom(`client:${clientId}`, 'client:noteAdded', {
clientId,
noteId: note.id,
authorName: note.authorName ?? ctx.user.name,
preview: note.content.slice(0, 100),
});
return NextResponse.json({ data: note }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { deleteRelationship } from '@/lib/services/clients.service';
export const DELETE = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
await deleteRelationship(params.relId!, params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listRelationships, createRelationship } from '@/lib/services/clients.service';
const createRelationshipSchema = z.object({
clientBId: z.string().min(1),
relationshipType: z.enum([
'referred_by',
'broker_for',
'family_member',
'same_vessel',
'custom',
]),
description: z.string().optional(),
});
export const GET = withAuth(
withPermission('clients', 'view', async (req, ctx, params) => {
try {
const relationships = await listRelationships(params.id!, ctx.portId);
return NextResponse.json({ data: relationships });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, createRelationshipSchema);
const rel = await createRelationship(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: rel }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

Some files were not shown because too many files have changed in this diff Show More