Initial commit: Port Nimara CRM (Layers 0-4)
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:
21
src/app/(auth)/layout.tsx
Normal file
21
src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/app/(auth)/login/page.tsx
Normal file
118
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/app/(auth)/reset-password/page.tsx
Normal file
117
src/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
src/app/(auth)/set-password/page.tsx
Normal file
176
src/app/(auth)/set-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/audit/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/backup/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/backup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CustomFieldsManager } from '@/components/admin/custom-fields/custom-fields-manager';
|
||||
|
||||
export default function CustomFieldsPage() {
|
||||
return <CustomFieldsManager />;
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/forms/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/forms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/import/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/admin/monitoring/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/monitoring/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SystemMonitoringDashboard } from '@/components/admin/system-monitoring-dashboard';
|
||||
|
||||
export default function MonitoringPage() {
|
||||
return <SystemMonitoringDashboard />;
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/ports/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/ports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/reports/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/roles/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/roles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/settings/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/admin/tags/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/tags/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TagList } from '@/components/admin/tags/tag-list';
|
||||
|
||||
export default function TagManagementPage() {
|
||||
return <TagList />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/admin/templates/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/templates/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TemplateList } from '@/components/admin/document-templates/template-list';
|
||||
|
||||
export default function DocumentTemplatesPage() {
|
||||
return <TemplateList />;
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/admin/users/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx
Normal file
250
src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx
Normal 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 "{deleteTarget?.name}"? 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(dashboard)/[portSlug]/berths/[berthId]/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/berths/[berthId]/page.tsx
Normal 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} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/berths/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/berths/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BerthList } from '@/components/berths/berth-list';
|
||||
|
||||
export default function BerthsPage() {
|
||||
return <BerthList />;
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/clients/[clientId]/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/clients/[clientId]/page.tsx
Normal 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} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/clients/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/clients/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClientList } from '@/components/clients/client-list';
|
||||
|
||||
export default function ClientsPage() {
|
||||
return <ClientList />;
|
||||
}
|
||||
144
src/app/(dashboard)/[portSlug]/documents/page.tsx
Normal file
144
src/app/(dashboard)/[portSlug]/documents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/email/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/email/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/(dashboard)/[portSlug]/expenses/[id]/page.tsx
Normal file
40
src/app/(dashboard)/[portSlug]/expenses/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
src/app/(dashboard)/[portSlug]/expenses/page.tsx
Normal file
185
src/app/(dashboard)/[portSlug]/expenses/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx
Normal file
252
src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/interests/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/interests/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InterestList } from '@/components/interests/interest-list';
|
||||
|
||||
export default function InterestsPage() {
|
||||
return <InterestList />;
|
||||
}
|
||||
15
src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx
Normal file
15
src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
388
src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
Normal file
388
src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/app/(dashboard)/[portSlug]/invoices/page.tsx
Normal file
190
src/app/(dashboard)/[portSlug]/invoices/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardShell />;
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/reminders/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/reminders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/reports/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/reports/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReportsPageClient } from '@/components/reports/reports-page-client';
|
||||
|
||||
export default function ReportsPage() {
|
||||
return <ReportsPageClient />;
|
||||
}
|
||||
16
src/app/(dashboard)/[portSlug]/settings/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/(dashboard)/layout.tsx
Normal file
46
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/app/(portal)/layout.tsx
Normal file
61
src/app/(portal)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/app/(portal)/portal/dashboard/page.tsx
Normal file
65
src/app/(portal)/portal/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
123
src/app/(portal)/portal/documents/page.tsx
Normal file
123
src/app/(portal)/portal/documents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/app/(portal)/portal/interests/page.tsx
Normal file
111
src/app/(portal)/portal/interests/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/(portal)/portal/invoices/page.tsx
Normal file
103
src/app/(portal)/portal/invoices/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/app/(portal)/portal/login/page.tsx
Normal file
118
src/app/(portal)/portal/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/app/(portal)/portal/verify/page.tsx
Normal file
35
src/app/(portal)/portal/verify/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/app/api/auth/[...all]/route.ts
Normal file
4
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { toNextJsHandler } from 'better-auth/next-js';
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
68
src/app/api/health/route.ts
Normal file
68
src/app/api/health/route.ts
Normal 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 });
|
||||
}
|
||||
12
src/app/api/portal/auth/logout/route.ts
Normal file
12
src/app/api/portal/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
28
src/app/api/portal/auth/request/route.ts
Normal file
28
src/app/api/portal/auth/request/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/portal/auth/verify/route.ts
Normal file
38
src/app/api/portal/auth/verify/route.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
20
src/app/api/portal/dashboard/route.ts
Normal file
20
src/app/api/portal/dashboard/route.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
26
src/app/api/portal/documents/[documentId]/download/route.ts
Normal file
26
src/app/api/portal/documents/[documentId]/download/route.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
15
src/app/api/portal/documents/route.ts
Normal file
15
src/app/api/portal/documents/route.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
15
src/app/api/portal/interests/route.ts
Normal file
15
src/app/api/portal/interests/route.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
15
src/app/api/portal/invoices/route.ts
Normal file
15
src/app/api/portal/invoices/route.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
167
src/app/api/public/interests/route.ts
Normal file
167
src/app/api/public/interests/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/app/api/v1/admin/connections/route.ts
Normal file
18
src/app/api/v1/admin/connections/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
69
src/app/api/v1/admin/custom-fields/[fieldId]/route.ts
Normal file
69
src/app/api/v1/admin/custom-fields/[fieldId]/route.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
43
src/app/api/v1/admin/custom-fields/route.ts
Normal file
43
src/app/api/v1/admin/custom-fields/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
21
src/app/api/v1/admin/errors/route.ts
Normal file
21
src/app/api/v1/admin/errors/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
18
src/app/api/v1/admin/health/route.ts
Normal file
18
src/app/api/v1/admin/health/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
29
src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts
Normal file
29
src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
35
src/app/api/v1/admin/queues/[queueName]/route.ts
Normal file
35
src/app/api/v1/admin/queues/[queueName]/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
18
src/app/api/v1/admin/queues/route.ts
Normal file
18
src/app/api/v1/admin/queues/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
30
src/app/api/v1/admin/roles/[id]/route.ts
Normal file
30
src/app/api/v1/admin/roles/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
19
src/app/api/v1/admin/roles/route.ts
Normal file
19
src/app/api/v1/admin/roles/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
78
src/app/api/v1/admin/templates/[templateId]/route.ts
Normal file
78
src/app/api/v1/admin/templates/[templateId]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
77
src/app/api/v1/admin/templates/preview/route.ts
Normal file
77
src/app/api/v1/admin/templates/preview/route.ts
Normal 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;
|
||||
}
|
||||
50
src/app/api/v1/admin/templates/route.ts
Normal file
50
src/app/api/v1/admin/templates/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
25
src/app/api/v1/admin/users/options/route.ts
Normal file
25
src/app/api/v1/admin/users/options/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
40
src/app/api/v1/admin/users/route.ts
Normal file
40
src/app/api/v1/admin/users/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
64
src/app/api/v1/admin/webhooks/[webhookId]/route.ts
Normal file
64
src/app/api/v1/admin/webhooks/[webhookId]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
27
src/app/api/v1/admin/webhooks/[webhookId]/test/route.ts
Normal file
27
src/app/api/v1/admin/webhooks/[webhookId]/test/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
39
src/app/api/v1/admin/webhooks/route.ts
Normal file
39
src/app/api/v1/admin/webhooks/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
24
src/app/api/v1/ai/email-draft/[jobId]/route.ts
Normal file
24
src/app/api/v1/ai/email-draft/[jobId]/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
38
src/app/api/v1/ai/email-draft/route.ts
Normal file
38
src/app/api/v1/ai/email-draft/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
28
src/app/api/v1/ai/interest-score/bulk/route.ts
Normal file
28
src/app/api/v1/ai/interest-score/bulk/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
32
src/app/api/v1/ai/interest-score/route.ts
Normal file
32
src/app/api/v1/ai/interest-score/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
22
src/app/api/v1/berths/[id]/export-pdf/route.ts
Normal file
22
src/app/api/v1/berths/[id]/export-pdf/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
37
src/app/api/v1/berths/[id]/maintenance/route.ts
Normal file
37
src/app/api/v1/berths/[id]/maintenance/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
37
src/app/api/v1/berths/[id]/route.ts
Normal file
37
src/app/api/v1/berths/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
25
src/app/api/v1/berths/[id]/status/route.ts
Normal file
25
src/app/api/v1/berths/[id]/status/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
29
src/app/api/v1/berths/[id]/tags/route.ts
Normal file
29
src/app/api/v1/berths/[id]/tags/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
90
src/app/api/v1/berths/[id]/waiting-list/route.ts
Normal file
90
src/app/api/v1/berths/[id]/waiting-list/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
15
src/app/api/v1/berths/options/route.ts
Normal file
15
src/app/api/v1/berths/options/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
36
src/app/api/v1/berths/route.ts
Normal file
36
src/app/api/v1/berths/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
54
src/app/api/v1/clients/[id]/contacts/[contactId]/route.ts
Normal file
54
src/app/api/v1/clients/[id]/contacts/[contactId]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
43
src/app/api/v1/clients/[id]/contacts/route.ts
Normal file
43
src/app/api/v1/clients/[id]/contacts/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
22
src/app/api/v1/clients/[id]/export-pdf/route.ts
Normal file
22
src/app/api/v1/clients/[id]/export-pdf/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
63
src/app/api/v1/clients/[id]/notes/[noteId]/route.ts
Normal file
63
src/app/api/v1/clients/[id]/notes/[noteId]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
55
src/app/api/v1/clients/[id]/notes/route.ts
Normal file
55
src/app/api/v1/clients/[id]/notes/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
21
src/app/api/v1/clients/[id]/relationships/[relId]/route.ts
Normal file
21
src/app/api/v1/clients/[id]/relationships/[relId]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
47
src/app/api/v1/clients/[id]/relationships/route.ts
Normal file
47
src/app/api/v1/clients/[id]/relationships/route.ts
Normal 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
Reference in New Issue
Block a user