feat(platform): residential module + admin UI + reliability fixes
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped

Residential platform
- New schema: residentialClients, residentialInterests (separate from
  marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint

Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)

Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
  handlers.ts files (Next.js 15 route.ts only allows specific exports)

Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
  (apiFetch already JSON.stringifies its body; passing a stringified
  body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
  to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
  Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md

Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-27 21:54:32 +02:00
parent fac8021156
commit e8d61c91c4
121 changed files with 34105 additions and 1016 deletions

View File

@@ -8,14 +8,5 @@ export const metadata: Metadata = {
};
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>
);
return <>{children}</>;
}

View File

@@ -10,9 +10,9 @@ 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';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
@@ -55,64 +55,53 @@ export default function LoginPage() {
}
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>
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to continue</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>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-1.5">
<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 ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
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 bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</BrandedAuthShell>
);
}

View File

@@ -7,9 +7,9 @@ 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 { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { cn } from '@/lib/utils';
const resetSchema = z.object({
@@ -49,69 +49,55 @@ export default function ResetPasswordPage() {
}
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>
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">We&apos;ll email you a link</p>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Sending…' : 'Send reset link'}
</Button>
{submitted ? (
<div className="space-y-4 text-center">
<p className="font-medium text-gray-900">Check your email</p>
<p className="text-sm text-gray-500">
If an account exists for that email address, we have sent a password reset link. Please
check your inbox and spam folder.
</p>
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
Back to sign in
</Link>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-1.5">
<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>
<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>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Sending…' : 'Send reset link'}
</Button>
<p className="text-center text-sm text-gray-500">
Remember your password?{' '}
<Link href="/login" className="text-[#007bff] hover:underline">
Sign in
</Link>
</p>
</form>
)}
</BrandedAuthShell>
);
}

View File

@@ -1,27 +1,23 @@
'use client';
import { Suspense, useState } from 'react';
import Link from 'next/link';
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';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
const MIN_LENGTH = 9;
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'),
password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
@@ -31,25 +27,11 @@ const passwordSchema = z
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) },
];
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [isLoading, setIsLoading] = useState(false);
const [passwordValue, setPasswordValue] = useState('');
const {
register,
@@ -61,7 +43,7 @@ function SetPasswordInner() {
async function onSubmit(data: SetPasswordFormData) {
if (!token) {
toast.error('Invalid or missing reset token. Please request a new password reset link.');
toast.error('Invalid or missing reset token. Please request a new link.');
return;
}
@@ -75,7 +57,7 @@ function SetPasswordInner() {
if (!response.ok) {
const body = await response.json().catch(() => ({}));
toast.error(body.message ?? 'Failed to set password. Please try again.');
toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
return;
}
@@ -88,102 +70,77 @@ function SetPasswordInner() {
}
}
if (!token) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3">
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
<p className="text-sm text-gray-500">
Please use the link from the email we sent you. If the link is broken, ask your
administrator for a new one.
</p>
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
Back to sign in
</Link>
</div>
</BrandedAuthShell>
);
}
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>
)}
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Set your password</h1>
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
</div>
<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>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-1.5">
<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')}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</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>
<div className="space-y-1.5">
<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>
)}
</CardContent>
</Card>
</div>
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Setting password…' : 'Set password'}
</Button>
</form>
</BrandedAuthShell>
);
}
export default function SetPasswordPage() {
return (
<Suspense
fallback={
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
/>
}
>
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
<SetPasswordInner />
</Suspense>
);

View File

@@ -1,16 +1,5 @@
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
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>
);
return <FormTemplateList />;
}

View File

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

View File

@@ -1,16 +1,47 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { EmailAccountsList } from '@/components/email/email-accounts-list';
import { EmailThreadsList } from '@/components/email/email-threads-list';
import { ComposeDialog } from '@/components/email/compose-dialog';
export default function EmailPage() {
const [tab, setTab] = useState('threads');
const [composeOpen, setComposeOpen] = useState(false);
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 className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground">Email</h1>
<p className="text-muted-foreground">Send and manage client communications</p>
</div>
<Button onClick={() => setComposeOpen(true)}>
<Send className="h-4 w-4 mr-1.5" />
Compose
</Button>
</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="threads">Inbox</TabsTrigger>
<TabsTrigger value="accounts">Accounts</TabsTrigger>
</TabsList>
<TabsContent value="threads" className="pt-4">
<EmailThreadsList />
</TabsContent>
<TabsContent value="accounts" className="pt-4">
<EmailAccountsList />
</TabsContent>
</Tabs>
<ComposeDialog open={composeOpen} onOpenChange={setComposeOpen} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
export default function NotificationPreferencesPage() {
return (
<div className="max-w-2xl mx-auto py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold">Notification Preferences</h1>
<p className="text-sm text-muted-foreground">
Choose which notifications you receive and how.
</p>
</div>
<NotificationPreferencesForm />
</div>
);
}

View File

@@ -1,5 +1,7 @@
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
import { redirect } from 'next/navigation';
export default function DashboardPage() {
return <DashboardShell />;
export default async function PortIndexPage({ params }: { params: Promise<{ portSlug: string }> }) {
const { portSlug } = await params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/${portSlug}/dashboard` as any);
}

View File

@@ -0,0 +1,10 @@
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
interface Props {
params: Promise<{ id: string }>;
}
export default async function ResidentialClientDetailPage({ params }: Props) {
const { id } = await params;
return <ResidentialClientDetail clientId={id} />;
}

View File

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

View File

@@ -0,0 +1,10 @@
import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
interface Props {
params: Promise<{ id: string }>;
}
export default async function ResidentialInterestDetailPage({ params }: Props) {
const { id } = await params;
return <ResidentialInterestDetail interestId={id} />;
}

View File

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

View File

@@ -4,7 +4,8 @@ import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userPortRoles } from '@/lib/db/schema/users';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
@@ -16,26 +17,31 @@ export default async function DashboardLayout({ children }: { children: React.Re
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
// Load user's port assignments for PortProvider
// Super admins have implicit access to every port; everyone else only sees
// ports they have an explicit user_port_roles row for.
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
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);
const ports = profile?.isSuperAdmin
? await db.query.ports.findMany({ orderBy: portsTable.name })
: portRoles.map((pr) => pr.port);
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}>
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
<PermissionsProvider>
<SocketProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar portRoles={portRoles} />
<Sidebar portRoles={portRoles} isSuperAdmin={profile?.isSuperAdmin ?? false} />
<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>
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
</div>
</div>
</SocketProvider>

View File

@@ -2,11 +2,12 @@
import Link from 'next/link';
import { useState } from 'react';
import { Loader2, Mail } from 'lucide-react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
export default function PortalForgotPasswordPage() {
const [email, setEmail] = useState('');
@@ -31,77 +32,74 @@ export default function PortalForgotPasswordPage() {
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">
<BrandedAuthShell>
<div className="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" />
<CheckCircle2 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">
<p className="text-sm text-gray-500 leading-relaxed">
If <strong>{email}</strong> matches a portal account, we&apos;ve sent a reset link. The
link expires in 30 minutes.
</p>
<Link
href="/portal/login"
className="mt-6 inline-block text-sm text-[#1e2844] hover:underline"
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
>
Back to sign in
</Link>
</div>
</div>
</BrandedAuthShell>
);
}
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">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email and we&apos;ll send you a reset 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>
<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
</>
) : (
'Send reset link'
)}
</Button>
</form>
<Link
href="/portal/login"
className="block mt-4 text-center text-xs text-gray-500 hover:underline"
>
Back to sign in
</Link>
</div>
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email and we&apos;ll send you a reset link.
</p>
</div>
</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
autoComplete="email"
disabled={loading}
/>
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={loading || !email}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
'Send reset link'
)}
</Button>
<p className="text-center text-sm text-gray-500">
Remember your password?{' '}
<Link href="/portal/login" className="text-[#007bff] hover:underline">
Sign in
</Link>
</p>
</form>
</BrandedAuthShell>
);
}

View File

@@ -8,7 +8,7 @@ import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
export default function PortalLoginPage() {
const router = useRouter();
@@ -49,7 +49,7 @@ export default function PortalLoginPage() {
}
return (
<PortalAuthShell>
<BrandedAuthShell>
<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">Sign in to your account</p>
@@ -110,6 +110,6 @@ export default function PortalLoginPage() {
<p className="text-center text-xs text-gray-400 mt-6">
This portal is for existing clients only.
</p>
</PortalAuthShell>
</BrandedAuthShell>
);
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
const bodySchema = z.object({
token: z.string().min(1),
password: z.string().min(9),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ message: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ message: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
try {
const result = await consumeCrmInvite({
token: parsed.data.token,
password: parsed.data.password,
});
return NextResponse.json({ success: true, email: result.email });
} catch (err) {
return errorResponse(err);
}
}

View File

@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { withTransaction } from '@/lib/db/utils';
import { ports } from '@/lib/db/schema/ports';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { systemSettings } from '@/lib/db/schema/system';
import { sendEmail } from '@/lib/email';
import {
residentialClientConfirmation,
residentialSalesAlert,
} from '@/lib/email/templates/residential-inquiry';
import { env } from '@/lib/env';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
import { emitToRoom } from '@/lib/socket/server';
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000;
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) {
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
}
entry.count += 1;
}
/**
* POST /api/public/residential-inquiries — unauthenticated entry point for
* the public website's residential interest form. Creates a
* `residential_clients` row and an opening `residential_interests` row in a
* single transaction.
*
* Required: `portId` query param or `X-Port-Id` header.
*/
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 = publicResidentialInquirySchema.parse(body);
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
if (!portId) {
throw new ValidationError('portId is required');
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) {
throw new ValidationError('Unknown port');
}
const result = await withTransaction(async (tx) => {
const [client] = await tx
.insert(residentialClients)
.values({
portId,
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
email: data.email,
phone: data.phone,
placeOfResidence: data.placeOfResidence,
preferredContactMethod: data.preferredContactMethod,
source: 'website',
status: 'prospect',
notes: data.notes,
})
.returning();
if (!client) throw new Error('Failed to create residential client');
const [interest] = await tx
.insert(residentialInterests)
.values({
portId,
residentialClientId: client.id,
pipelineStage: 'new',
source: 'website',
notes: data.notes,
preferences: data.preferences,
})
.returning();
if (!interest) throw new Error('Failed to create residential interest');
return { clientId: client.id, interestId: interest.id };
});
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
// Send notification emails (non-blocking — failures shouldn't 500 the
// public form).
void sendResidentialNotifications({
portId,
data,
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
return NextResponse.json({ success: true, ...result }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
async function sendResidentialNotifications(args: {
portId: string;
data: {
firstName: string;
lastName: string;
email: string;
phone: string;
placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone';
notes?: string;
preferences?: string;
};
crmDeepLink: string;
}): Promise<void> {
const { portId, data, crmDeepLink } = args;
// Client confirmation
const confirmation = residentialClientConfirmation({
firstName: data.firstName,
contactEmail: 'sales@portnimara.com',
});
await sendEmail(data.email, confirmation.subject, confirmation.html);
// Sales-team alert — pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available.
const recipientsRow = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'residential_notification_recipients'),
eq(systemSettings.portId, portId),
),
});
const fallbackRow = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'inquiry_contact_email'), eq(systemSettings.portId, portId)),
});
const configured = Array.isArray(recipientsRow?.value) ? (recipientsRow!.value as string[]) : [];
const fallback =
typeof fallbackRow?.value === 'string' && fallbackRow.value.length > 0
? [fallbackRow.value]
: [];
const recipients = configured.length > 0 ? configured : fallback;
if (recipients.length === 0) {
logger.warn(
{ portId },
'No residential_notification_recipients or inquiry_contact_email configured; skipping sales alert',
);
return;
}
const alert = residentialSalesAlert({
fullName: `${data.firstName} ${data.lastName}`.trim(),
email: data.email,
phone: data.phone,
placeOfResidence: data.placeOfResidence,
preferredContactMethod: data.preferredContactMethod,
notes: data.notes,
preferences: data.preferences,
crmDeepLink,
});
await sendEmail(recipients, alert.subject, alert.html);
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
deleteFormTemplate,
getFormTemplateById,
updateFormTemplate,
} from '@/lib/services/form-templates.service';
import { updateFormTemplateSchema } from '@/lib/validators/form-templates';
export const GET = withAuth(
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
try {
if (!params.id) throw new NotFoundError('Form template');
const tpl = await getFormTemplateById(params.id, ctx.portId);
return NextResponse.json({ data: tpl });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('admin', 'manage_forms', async (req, ctx, params) => {
try {
if (!params.id) throw new NotFoundError('Form template');
const body = await parseBody(req, updateFormTemplateSchema);
const tpl = await updateFormTemplate(params.id, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: tpl });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
try {
if (!params.id) throw new NotFoundError('Form template');
await deleteFormTemplate(params.id, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,35 @@
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 { createFormTemplate, listFormTemplates } from '@/lib/services/form-templates.service';
import { createFormTemplateSchema } from '@/lib/validators/form-templates';
export const GET = withAuth(
withPermission('admin', 'manage_forms', async (_req, ctx) => {
try {
const data = await listFormTemplates(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_forms', async (req, ctx) => {
try {
const body = await parseBody(req, createFormTemplateSchema);
const tpl = await createFormTemplate(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: tpl }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,65 @@
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { NotFoundError, errorResponse } from '@/lib/errors';
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
// URL berthId is authoritative; make body berthId optional (ignored anyway).
const createPendingBodySchema = createPendingSchema
.omit({ berthId: true })
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
}
export const listHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const query = parseQuery(req, listReservationsSchema);
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const body = await parseBody(req, createPendingBodySchema);
const reservation = await createPending(
ctx.portId,
{ ...body, berthId: params.id! },
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: reservation }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -1,72 +1,6 @@
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { NotFoundError, errorResponse } from '@/lib/errors';
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
// URL berthId is authoritative; make body berthId optional (ignored anyway).
const createPendingBodySchema = createPendingSchema
.omit({ berthId: true })
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
}
export const listHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const query = parseQuery(req, listReservationsSchema);
// URL berthId is authoritative; override any client-supplied value.
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const body = await parseBody(req, createPendingBodySchema);
// URL berthId is authoritative; any body-supplied berthId is ignored.
const reservation = await createPending(
ctx.portId,
{ ...body, berthId: params.id! },
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: reservation }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
export const POST = withAuth(withPermission('reservations', 'create', createHandler));

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
import { updateCompanySchema } from '@/lib/validators/companies';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const company = await getCompanyById(params.id!, ctx.portId);
return NextResponse.json({ data: company });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateCompanySchema);
const updated = await updateCompany(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);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveCompany(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,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('companies', 'edit', async (req, ctx, params) => {
try {
const companyId = params.id;
const noteId = params.noteId;
if (!companyId) throw new NotFoundError('Company');
if (!noteId) throw new NotFoundError('Note');
const body = await parseBody(req, updateNoteSchema);
const note = await notesService.update(ctx.portId, 'companies', companyId, noteId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'company_note',
entityId: noteId,
metadata: { companyId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('companies', 'edit', async (_req, ctx, params) => {
try {
const companyId = params.id;
const noteId = params.noteId;
if (!companyId) throw new NotFoundError('Company');
if (!noteId) throw new NotFoundError('Note');
await notesService.deleteNote(ctx.portId, 'companies', companyId, noteId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'company_note',
entityId: noteId,
metadata: { companyId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { 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 { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
withPermission('companies', 'view', async (_req, ctx, params) => {
try {
const companyId = params.id;
if (!companyId) throw new NotFoundError('Company');
const notes = await notesService.listForEntity(ctx.portId, 'companies', companyId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
const companyId = params.id;
if (!companyId) throw new NotFoundError('Company');
const body = await parseBody(req, createNoteSchema);
const note = await notesService.create(ctx.portId, 'companies', companyId, ctx.userId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'company_note',
entityId: note.id,
metadata: { companyId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,48 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
import { updateCompanySchema } from '@/lib/validators/companies';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const company = await getCompanyById(params.id!, ctx.portId);
return NextResponse.json({ data: company });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateCompanySchema);
const updated = await updateCompany(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);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveCompany(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);
}
};
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('companies', 'view', getHandler));
export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler));

View File

@@ -0,0 +1,28 @@
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 { setCompanyTags } from '@/lib/services/companies.service';
const setTagsSchema = z.object({
tagIds: z.array(z.string()),
});
export const PUT = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
const { tagIds } = await parseBody(req, setTagsSchema);
await setCompanyTags(params.id!, ctx.portId, tagIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listCompanies, createCompany } from '@/lib/services/companies.service';
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listCompaniesSchema);
const result = await listCompanies(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createCompanySchema);
const company = await createCompany(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: company }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -1,47 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listCompanies, createCompany } from '@/lib/services/companies.service';
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listCompaniesSchema);
const result = await listCompanies(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createCompanySchema);
const company = await createCompany(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: company }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('companies', 'view', listHandler));
export const POST = withAuth(withPermission('companies', 'create', createHandler));

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveResidentialClient,
getResidentialClientById,
updateResidentialClient,
} from '@/lib/services/residential.service';
import { updateResidentialClientSchema } from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_clients', 'view', async (req, ctx, params) => {
try {
const client = await getResidentialClientById(params.id!, ctx.portId);
return NextResponse.json({ data: client });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('residential_clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateResidentialClientSchema);
const updated = await updateResidentialClient(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);
}
}),
);
export const DELETE = withAuth(
withPermission('residential_clients', 'delete', async (req, ctx, params) => {
try {
await archiveResidentialClient(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
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 {
createResidentialClient,
listResidentialClients,
} from '@/lib/services/residential.service';
import {
createResidentialClientSchema,
listResidentialClientsSchema,
} from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_clients', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listResidentialClientsSchema);
const result = await listResidentialClients(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('residential_clients', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, createResidentialClientSchema);
const client = await createResidentialClient(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: client }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveResidentialInterest,
getResidentialInterestById,
updateResidentialInterest,
} from '@/lib/services/residential.service';
import { updateResidentialInterestSchema } from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_interests', 'view', async (req, ctx, params) => {
try {
const interest = await getResidentialInterestById(params.id!, ctx.portId);
return NextResponse.json({ data: interest });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('residential_interests', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateResidentialInterestSchema);
const updated = await updateResidentialInterest(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);
}
}),
);
export const DELETE = withAuth(
withPermission('residential_interests', 'delete', async (req, ctx, params) => {
try {
await archiveResidentialInterest(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
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 {
createResidentialInterest,
listResidentialInterests,
} from '@/lib/services/residential.service';
import {
createResidentialInterestSchema,
listResidentialInterestsSchema,
} from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_interests', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listResidentialInterestsSchema);
const result = await listResidentialInterests(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('residential_interests', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, createResidentialInterestSchema);
const interest = await createResidentialInterest(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: interest }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
import { updateYachtSchema } from '@/lib/validators/yachts';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const yacht = await getYachtById(params.id!, ctx.portId);
return NextResponse.json({ data: yacht });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateYachtSchema);
const updated = await updateYacht(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);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveYacht(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,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('yachts', 'edit', async (req, ctx, params) => {
try {
const yachtId = params.id;
const noteId = params.noteId;
if (!yachtId) throw new NotFoundError('Yacht');
if (!noteId) throw new NotFoundError('Note');
const body = await parseBody(req, updateNoteSchema);
const note = await notesService.update(ctx.portId, 'yachts', yachtId, noteId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'yacht_note',
entityId: noteId,
metadata: { yachtId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('yachts', 'edit', async (_req, ctx, params) => {
try {
const yachtId = params.id;
const noteId = params.noteId;
if (!yachtId) throw new NotFoundError('Yacht');
if (!noteId) throw new NotFoundError('Note');
await notesService.deleteNote(ctx.portId, 'yachts', yachtId, noteId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'yacht_note',
entityId: noteId,
metadata: { yachtId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { 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 { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
withPermission('yachts', 'view', async (_req, ctx, params) => {
try {
const yachtId = params.id;
if (!yachtId) throw new NotFoundError('Yacht');
const notes = await notesService.listForEntity(ctx.portId, 'yachts', yachtId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('yachts', 'edit', async (req, ctx, params) => {
try {
const yachtId = params.id;
if (!yachtId) throw new NotFoundError('Yacht');
const body = await parseBody(req, createNoteSchema);
const note = await notesService.create(ctx.portId, 'yachts', yachtId, ctx.userId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'yacht_note',
entityId: note.id,
metadata: { yachtId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,48 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
import { updateYachtSchema } from '@/lib/validators/yachts';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const yacht = await getYachtById(params.id!, ctx.portId);
return NextResponse.json({ data: yacht });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateYachtSchema);
const updated = await updateYacht(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);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveYacht(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);
}
};
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler));

View File

@@ -0,0 +1,28 @@
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 { setYachtTags } from '@/lib/services/yachts.service';
const setTagsSchema = z.object({
tagIds: z.array(z.string()),
});
export const PUT = withAuth(
withPermission('yachts', 'edit', async (req, ctx, params) => {
try {
const { tagIds } = await parseBody(req, setTagsSchema);
await setYachtTags(params.id!, ctx.portId, tagIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listYachts, createYacht } from '@/lib/services/yachts.service';
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listYachtsSchema);
const result = await listYachts(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createYachtSchema);
const yacht = await createYacht(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: yacht }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -1,47 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listYachts, createYacht } from '@/lib/services/yachts.service';
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listYachtsSchema);
const result = await listYachts(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createYachtSchema);
const yacht = await createYacht(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: yacht }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('yachts', 'view', listHandler));
export const POST = withAuth(withPermission('yachts', 'create', createHandler));

View File

@@ -0,0 +1,38 @@
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 { ports as portsTable } from '@/lib/db/schema/ports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
/**
* Plain `/dashboard` lands users into their default port's dashboard. Used
* by post-login redirects and any code that doesn't yet know the active
* port slug.
*/
export default async function DashboardRedirectPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
let slug: string | undefined;
if (profile?.isSuperAdmin) {
const first = await db.query.ports.findFirst({ orderBy: portsTable.name });
slug = first?.slug;
} else {
const role = await db.query.userPortRoles.findFirst({
where: eq(userPortRoles.userId, session.user.id),
with: { port: true },
});
slug = role?.port.slug;
}
if (!slug) redirect('/login?error=no-port-access');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/${slug}/dashboard` as any);
}

View File

@@ -0,0 +1,243 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import type { FormField } from '@/lib/validators/form-templates';
interface FormTemplate {
id: string;
name: string;
description: string | null;
fields: FormField[];
isActive: boolean;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
template: FormTemplate | null;
onSaved: () => void;
}
const DEFAULT_FIELD: FormField = {
key: '',
label: '',
type: 'text',
required: false,
};
const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [
{ value: 'text', label: 'Text' },
{ value: 'textarea', label: 'Long text' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'number', label: 'Number' },
{ value: 'select', label: 'Select' },
{ value: 'checkbox', label: 'Checkbox' },
];
export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Props) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isActive, setIsActive] = useState(true);
const [fields, setFields] = useState<FormField[]>([{ ...DEFAULT_FIELD }]);
useEffect(() => {
if (template) {
setName(template.name);
setDescription(template.description ?? '');
setIsActive(template.isActive);
setFields(template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }]);
} else {
setName('');
setDescription('');
setIsActive(true);
setFields([{ ...DEFAULT_FIELD }]);
}
}, [template, open]);
const saveMutation = useMutation({
mutationFn: () => {
const payload = {
name,
description: description || undefined,
fields,
isActive,
};
if (template) {
return apiFetch(`/api/v1/admin/form-templates/${template.id}`, {
method: 'PATCH',
body: payload,
});
}
return apiFetch('/api/v1/admin/form-templates', {
method: 'POST',
body: payload,
});
},
onSuccess: () => {
toast.success(template ? 'Template saved' : 'Template created');
onSaved();
onOpenChange(false);
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'),
});
function updateField(idx: number, patch: Partial<FormField>) {
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
}
function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
}
function removeField(idx: number) {
setFields((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)));
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{template ? 'Edit form template' : 'New form template'}</SheetTitle>
</SheetHeader>
<div className="space-y-4 py-4">
<div className="space-y-1">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Description</Label>
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={isActive} onCheckedChange={setIsActive} />
<Label>Active</Label>
</div>
<div className="border-t pt-3 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Fields</Label>
<Button variant="outline" size="sm" onClick={addField}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add field
</Button>
</div>
{fields.map((f, i) => (
<div key={i} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Field {i + 1}</span>
{fields.length > 1 && (
<Button
variant="ghost"
size="icon"
className="text-destructive h-7 w-7"
onClick={() => removeField(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Key (no spaces)</Label>
<Input
value={f.key}
onChange={(e) => updateField(i, { key: e.target.value })}
placeholder="email"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Label</Label>
<Input
value={f.label}
onChange={(e) => updateField(i, { label: e.target.value })}
placeholder="Email address"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Type</Label>
<Select
value={f.type}
onValueChange={(v) => updateField(i, { type: v as FormField['type'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
{ft.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1 flex items-end gap-2 pb-1">
<Switch
checked={!!f.required}
onCheckedChange={(v) => updateField(i, { required: v })}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
{f.type === 'select' && (
<div className="space-y-1">
<Label className="text-xs">Options (comma-separated)</Label>
<Input
value={(f.options ?? []).join(', ')}
onChange={(e) =>
updateField(i, {
options: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</div>
)}
</div>
))}
</div>
</div>
<SheetFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={
saveMutation.isPending ||
!name.trim() ||
fields.some((f) => !f.key.trim() || !f.label.trim())
}
>
{saveMutation.isPending ? 'Saving…' : 'Save template'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import type { FormField } from '@/lib/validators/form-templates';
import { FormTemplateForm } from './form-template-form';
export interface FormTemplate {
id: string;
name: string;
description: string | null;
fields: FormField[];
isActive: boolean;
updatedAt: string;
}
export function FormTemplateList() {
const qc = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FormTemplate | null>(null);
const { data: templates = [], isLoading } = useQuery<FormTemplate[]>({
queryKey: ['admin', 'form-templates'],
queryFn: () =>
apiFetch<{ data: FormTemplate[] }>('/api/v1/admin/form-templates').then((r) => r.data),
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/admin/form-templates/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Template deleted');
qc.invalidateQueries({ queryKey: ['admin', 'form-templates'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Delete failed'),
});
return (
<div>
<PageHeader
title="Form Templates"
description="Public intake forms for clients (residential inquiries, EOI supplements, etc.)"
actions={
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1.5" />
New template
</Button>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : templates.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<p className="text-sm">No form templates yet.</p>
</div>
) : (
<div className="rounded-lg border divide-y">
{templates.map((t) => (
<div key={t.id} className="flex items-center gap-3 p-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t.name}</span>
{!t.isActive && (
<Badge variant="outline" className="text-xs">
Inactive
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
{t.fields.length} field{t.fields.length === 1 ? '' : 's'} · updated{' '}
{format(new Date(t.updatedAt), 'MMM d, yyyy')}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditing(t);
setFormOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
}
title="Delete form template"
description={`Delete "${t.name}"? Existing submissions will be preserved.`}
confirmLabel="Delete"
onConfirm={() => deleteMutation.mutate(t.id)}
/>
</div>
))}
</div>
)}
<FormTemplateForm
open={formOpen}
onOpenChange={setFormOpen}
template={editing}
onSaved={() => qc.invalidateQueries({ queryKey: ['admin', 'form-templates'] })}
/>
</div>
);
}

View File

@@ -77,6 +77,14 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
manage_tags: false,
system_backup: false,
},
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
const GROUP_LABELS: Record<string, string> = {

View File

@@ -30,6 +30,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean' | 'number' | 'json' | 'string';
defaultValue: unknown;
}> = [
{
key: 'client_portal_enabled',
label: 'Client Portal',
description:
'Allow clients of this port to sign in and manage their account through the client portal.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_interest_scoring',
label: 'AI Interest Scoring',
@@ -89,6 +97,14 @@ const KNOWN_SETTINGS: Array<{
type: 'json',
defaultValue: [],
},
{
key: 'residential_notification_recipients',
label: 'Residential Notification Recipients',
description:
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
type: 'json',
defaultValue: [],
},
];
export function SettingsManager() {

View File

@@ -30,6 +30,7 @@ interface UserFormProps {
phone: string | null;
isActive: boolean;
role: { id: string; name: string };
residentialAccess?: boolean;
} | null;
onSuccess: () => void;
}
@@ -43,6 +44,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
const [phone, setPhone] = useState('');
const [roleId, setRoleId] = useState('');
const [isActive, setIsActive] = useState(true);
const [residentialAccess, setResidentialAccess] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -63,6 +65,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
setPhone(user.phone ?? '');
setRoleId(user.role.id);
setIsActive(user.isActive);
setResidentialAccess(user.residentialAccess ?? false);
setPassword('');
} else {
setName('');
@@ -71,6 +74,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
setPhone('');
setRoleId('');
setIsActive(true);
setResidentialAccess(false);
setPassword('');
}
setError(null);
@@ -91,6 +95,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
phone: phone || null,
roleId,
isActive,
residentialAccess,
},
});
} else {
@@ -103,6 +108,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
displayName,
phone: phone || undefined,
roleId,
residentialAccess,
},
});
}
@@ -190,6 +196,21 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-residential">Residential access</Label>
<p className="text-xs text-muted-foreground">
Grant this user access to residential clients and interests in addition to their
primary role.
</p>
</div>
<Switch
id="user-residential"
checked={residentialAccess}
onCheckedChange={setResidentialAccess}
/>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>

View File

@@ -38,7 +38,7 @@ export function BerthStatusSuggestionDialog({
mutationFn: () =>
apiFetch(`/api/v1/berths/${berthId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: suggestedStatus, reason }),
body: { status: suggestedStatus, reason },
}),
onSuccess: () => {
onApplied();
@@ -66,21 +66,14 @@ export function BerthStatusSuggestionDialog({
</Badge>
</div>
{reason && (
<p className="text-sm text-muted-foreground text-center px-4">{reason}</p>
)}
{reason && <p className="text-sm text-muted-foreground text-center px-4">{reason}</p>}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
<Button
onClick={() => applyMutation.mutate()}
disabled={applyMutation.isPending}
>
{applyMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
<Button onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}>
{applyMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
Apply Change
</Button>
</DialogFooter>

View File

@@ -10,11 +10,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Plus, Loader2, Trash2 } from 'lucide-react';
@@ -81,9 +77,7 @@ function SortableEntry({
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{entry.clientId}</p>
{entry.notes && (
<p className="text-xs text-muted-foreground truncate">{entry.notes}</p>
)}
{entry.notes && <p className="text-xs text-muted-foreground truncate">{entry.notes}</p>}
</div>
<Badge variant={entry.priority === 'high' ? 'destructive' : 'secondary'}>
@@ -118,7 +112,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
mutationFn: (body: { entryId: string; newPosition: number }) =>
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
method: 'PATCH',
body: JSON.stringify(body),
body,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
@@ -129,7 +123,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
mutationFn: (entries: WaitingListEntry[]) =>
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
method: 'PUT',
body: JSON.stringify({ entries }),
body: { entries },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
@@ -207,10 +201,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
/>
<Select
value={newPriority}
onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}
>
<Select value={newPriority} onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -226,9 +217,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
{addMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
Add to List
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
@@ -243,22 +232,11 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
No entries on waiting list.
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={entries.map((e) => e.id)}
strategy={verticalListSortingStrategy}
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{entries.map((entry) => (
<SortableEntry
key={entry.id}
entry={entry}
onRemove={handleRemove}
/>
<SortableEntry key={entry.id} entry={entry} onRemove={handleRemove} />
))}
</div>
</SortableContext>

View File

@@ -22,6 +22,8 @@ export interface ClientRow {
source: string | null;
archivedAt: string | null;
createdAt: string;
yachtCount?: number;
companyCount?: number;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
tags?: Array<{ id: string; name: string; color: string }>;
}
@@ -39,10 +41,6 @@ interface GetColumnsOptions {
onArchive: (client: ClientRow) => void;
}
// TODO: Add "Yachts" (count) and "Primary company" columns once the
// GET /api/v1/clients list endpoint joins owned-yachts and primary-company
// data into the row shape. Until then, the columns are omitted rather than
// shown as empty placeholders.
export function getClientColumns({
portSlug,
onEdit,
@@ -100,6 +98,36 @@ export function getClientColumns({
);
},
},
{
id: 'yachtCount',
header: 'Yachts',
enableSorting: false,
cell: ({ row }) => {
const c = row.original.yachtCount ?? 0;
return c === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<Badge variant="secondary" className="text-xs">
{c}
</Badge>
);
},
},
{
id: 'companyCount',
header: 'Companies',
enableSorting: false,
cell: ({ row }) => {
const c = row.original.companyCount ?? 0;
return c === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<Badge variant="secondary" className="text-xs">
{c}
</Badge>
);
},
},
{
id: 'tags',
header: 'Tags',

View File

@@ -2,13 +2,12 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, Mail, Phone } from 'lucide-react';
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { ClientForm } from '@/components/clients/client-form';
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { apiFetch } from '@/lib/api/client';
@@ -25,22 +24,10 @@ interface ClientDetailHeaderProps {
archivedAt?: string | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
tags?: Array<{ id: string; name: string; color: string }>;
clientPortalEnabled?: boolean;
};
}
type ClientFormClient = {
id: string;
fullName: string;
nationality?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
contacts?: Array<{ channel: string; value: string; label?: string | null; isPrimary?: boolean }>;
tags?: Array<{ id: string }>;
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
@@ -50,7 +37,6 @@ const SOURCE_LABELS: Record<string, string> = {
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const isArchived = !!client.archivedAt;
@@ -128,17 +114,13 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
{/* Actions */}
<div className="flex items-center gap-2">
{!isArchived && (
{!isArchived && client.clientPortalEnabled !== false && (
<PortalInviteButton
clientId={client.id}
clientName={client.fullName}
defaultEmail={primaryEmail?.value}
/>
)}
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant={isArchived ? 'outline' : 'outline'}
size="sm"
@@ -160,12 +142,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</div>
</div>
<ClientForm
open={editOpen}
onOpenChange={setEditOpen}
client={client as unknown as ClientFormClient}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}

View File

@@ -19,6 +19,7 @@ interface ClientData {
source: string | null;
sourceDetails: string | null;
archivedAt: string | null;
clientPortalEnabled: boolean;
createdAt: string;
updatedAt: string;
contacts: Array<{

View File

@@ -1,10 +1,62 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
import { ContactsEditor } from '@/components/clients/contacts-editor';
import { apiFetch } from '@/lib/api/client';
type ClientPatchField =
| 'fullName'
| 'nationality'
| 'preferredContactMethod'
| 'preferredLanguage'
| 'timezone'
| 'source'
| 'sourceDetails';
const SOURCE_OPTIONS = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
];
const CONTACT_METHOD_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'whatsapp', label: 'WhatsApp' },
];
function useClientPatch(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<ClientPatchField, string | null>>) => {
return apiFetch(`/api/v1/clients/${clientId}`, {
method: 'PATCH',
body: patch,
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['clients', clientId] });
},
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
interface ClientTabsOptions {
clientId: string;
@@ -57,83 +109,83 @@ interface ClientTabsOptions {
};
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
</div>
);
}
function OverviewTab({
clientId,
client,
}: {
clientId: string;
client: ClientTabsOptions['client'];
}) {
const mutation = useClientPatch(clientId);
const save = (field: ClientPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<InfoRow label="Full Name" value={client.fullName} />
<InfoRow label="Nationality" value={client.nationality} />
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
<InfoRow label="Timezone" value={client.timezone} />
<InfoRow label="Preferred Contact" value={client.preferredContactMethod} />
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<InlineEditableField value={client.nationality} onSave={save('nationality')} />
</EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
value={client.preferredLanguage}
onSave={save('preferredLanguage')}
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineEditableField value={client.timezone} onSave={save('timezone')} />
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</div>
{/* Contacts */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
{client.contacts && client.contacts.length > 0 ? (
<div className="space-y-2">
{client.contacts.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
>
<span className="capitalize text-muted-foreground w-20 shrink-0">{c.channel}</span>
<span className="flex-1">{c.value}</span>
{c.label && (
<span className="text-xs text-muted-foreground capitalize">{c.label}</span>
)}
{c.isPrimary && <span className="text-xs font-medium text-primary">Primary</span>}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No contacts added</p>
)}
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
</div>
{/* Source */}
{(client.source || client.sourceDetails) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3>
<dl>
<InfoRow label="Source" value={client.source} />
<InfoRow label="Source Details" value={client.sourceDetails} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3>
<dl>
<EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
{/* Tags */}
{client.tags && client.tags.length > 0 && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<div className="flex flex-wrap gap-1">
{client.tags.map((tag) => (
<span
key={tag.id}
className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
>
{tag.name}
</span>
))}
</div>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
</div>
</div>
);
}
@@ -143,7 +195,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
{
id: 'overview',
label: 'Overview',
content: <OverviewTab client={client} />,
content: <OverviewTab clientId={clientId} client={client} />,
},
{
id: 'yachts',

View File

@@ -0,0 +1,329 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Loader2,
Mail,
MessageSquare,
MoreHorizontal,
Phone,
Plus,
Star,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface Contact {
id: string;
channel: string;
value: string;
label?: string | null;
isPrimary: boolean;
}
const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'other', label: 'Other' },
];
const CHANNEL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
email: Mail,
phone: Phone,
whatsapp: MessageSquare,
other: MoreHorizontal,
};
export function ContactsEditor({ clientId, contacts }: { clientId: string; contacts: Contact[] }) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
function invalidate() {
qc.invalidateQueries({ queryKey: ['clients', clientId] });
}
const updateMutation = useMutation({
mutationFn: async ({
contactId,
patch,
}: {
contactId: string;
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>;
}) =>
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: invalidate,
});
const addMutation = useMutation({
mutationFn: async (data: { channel: string; value: string; label?: string }) =>
apiFetch(`/api/v1/clients/${clientId}/contacts`, {
method: 'POST',
body: { ...data, isPrimary: false },
}),
onSuccess: invalidate,
});
const removeMutation = useMutation({
mutationFn: async (contactId: string) =>
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
return (
<div className="space-y-2">
{contacts.length === 0 && !adding && (
<p className="text-sm text-muted-foreground">No contacts yet</p>
)}
{contacts.map((c) => (
<ContactRow
key={c.id}
contact={c}
onUpdate={(patch) => updateMutation.mutateAsync({ contactId: c.id, patch })}
onRemove={async () => {
if (!confirm('Remove this contact?')) return;
await removeMutation.mutateAsync(c.id);
}}
/>
))}
{adding ? (
<NewContactForm
onCancel={() => setAdding(false)}
onSave={async (data) => {
await addMutation.mutateAsync(data);
setAdding(false);
}}
/>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdding(true)}
className="w-full justify-center"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add contact
</Button>
)}
</div>
);
}
function ContactRow({
contact,
onUpdate,
onRemove,
}: {
contact: Contact;
onUpdate: (
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>,
) => Promise<unknown>;
onRemove: () => void;
}) {
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
async function togglePrimary() {
try {
await onUpdate({ isPrimary: !contact.isPrimary });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
}
}
async function changeChannel(next: string) {
if (next === contact.channel) return;
try {
await onUpdate({ channel: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
}
}
return (
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
{/* Left: channel + value */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</ChannelPicker>
<div className="min-w-0">
<InlineEditableField
value={contact.value}
onSave={async (v) => {
if (!v) {
toast.error('Value is required');
return;
}
await onUpdate({ value: v });
}}
/>
</div>
</div>
{/* Right: tag + actions */}
<div className="flex items-center gap-2 shrink-0">
<div className="w-28 text-xs text-muted-foreground text-right">
<InlineEditableField
value={
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
}
emptyText="Add tag"
placeholder="work, home…"
onSave={async (v) => {
await onUpdate({ label: v });
}}
/>
</div>
<button
type="button"
onClick={togglePrimary}
title={contact.isPrimary ? 'Primary' : 'Make primary'}
className={cn(
'p-1 rounded hover:bg-background/60 transition-colors',
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)}
>
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
</button>
<button
type="button"
onClick={onRemove}
title="Remove"
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
function ChannelPicker({
value,
onChange,
children,
}: {
value: string;
onChange: (next: string) => void;
children: React.ReactNode;
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
className="h-7 w-7 p-0 border-none bg-transparent hover:bg-background/60 [&>svg]:hidden justify-center"
aria-label="Channel"
>
<SelectValue>{children}</SelectValue>
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function NewContactForm({
onSave,
onCancel,
}: {
onSave: (data: { channel: string; value: string; label?: string }) => Promise<void>;
onCancel: () => void;
}) {
const [channel, setChannel] = useState('email');
const [value, setValue] = useState('');
const [label, setLabel] = useState('');
const [saving, setSaving] = useState(false);
async function submit() {
if (!value.trim()) return;
setSaving(true);
try {
await onSave({ channel, value: value.trim(), label: label.trim() || undefined });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add contact');
} finally {
setSaving(false);
}
}
return (
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
<Select value={channel} onValueChange={setChannel}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 0100'}
className="h-7 text-sm flex-1 min-w-0"
autoFocus
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
if (e.key === 'Escape') onCancel();
}}
/>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="tag (optional)"
className="h-7 text-xs w-28"
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
if (e.key === 'Escape') onCancel();
}}
/>
<Button type="button" size="sm" onClick={submit} disabled={!value.trim() || saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
Cancel
</Button>
</div>
);
}

View File

@@ -1,9 +1,31 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
import { apiFetch } from '@/lib/api/client';
type CompanyPatchField =
| 'name'
| 'legalName'
| 'taxId'
| 'registrationNumber'
| 'incorporationCountry'
| 'incorporationDate'
| 'status'
| 'billingEmail'
| 'notes';
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'dissolved', label: 'Dissolved' },
];
interface CompanyTabsCompany {
id: string;
@@ -16,6 +38,7 @@ interface CompanyTabsCompany {
status: string;
billingEmail: string | null;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
}
interface CompanyTabsOptions {
@@ -25,30 +48,34 @@ interface CompanyTabsOptions {
company: CompanyTabsCompany;
}
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
dissolved: 'Dissolved',
};
function useCompanyPatch(companyId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<CompanyPatchField, string | null>>) =>
apiFetch(`/api/v1/companies/${companyId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['companies', companyId] });
},
});
}
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
if (value === null || value === undefined || value === '') return null;
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
function formatDate(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}
function OverviewTab({ company }: { company: CompanyTabsCompany }) {
const incorporationDate = formatDate(company.incorporationDate);
function OverviewTab({ companyId, company }: { companyId: string; company: CompanyTabsCompany }) {
const mutation = useCompanyPatch(companyId);
const save = (field: CompanyPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -56,47 +83,82 @@ function OverviewTab({ company }: { company: CompanyTabsCompany }) {
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>
<dl>
<InfoRow label="Name" value={company.name} />
<InfoRow label="Legal Name" value={company.legalName} />
<InfoRow label="Status" value={STATUS_LABELS[company.status] ?? company.status} />
<EditableRow label="Name">
<InlineEditableField value={company.name} onSave={save('name')} />
</EditableRow>
<EditableRow label="Legal Name">
<InlineEditableField value={company.legalName} onSave={save('legalName')} />
</EditableRow>
<EditableRow label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={company.status}
onSave={save('status')}
/>
</EditableRow>
</dl>
</div>
{/* Registration */}
{(company.taxId ||
company.registrationNumber ||
company.incorporationCountry ||
incorporationDate) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Registration</h3>
<dl>
<InfoRow label="Tax ID" value={company.taxId} />
<InfoRow label="Registration Number" value={company.registrationNumber} />
<InfoRow label="Incorporation Country" value={company.incorporationCountry} />
<InfoRow label="Incorporation Date" value={incorporationDate} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Registration</h3>
<dl>
<EditableRow label="Tax ID">
<InlineEditableField value={company.taxId} onSave={save('taxId')} />
</EditableRow>
<EditableRow label="Registration Number">
<InlineEditableField
value={company.registrationNumber}
onSave={save('registrationNumber')}
/>
</EditableRow>
<EditableRow label="Incorporation Country">
<InlineEditableField
value={company.incorporationCountry}
onSave={save('incorporationCountry')}
/>
</EditableRow>
<EditableRow label="Incorporation Date">
<InlineEditableField
value={company.incorporationDate}
placeholder="YYYY-MM-DD"
onSave={save('incorporationDate')}
/>
</EditableRow>
</dl>
</div>
{/* Contact */}
{company.billingEmail && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<InfoRow label="Billing Email" value={company.billingEmail} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<EditableRow label="Billing Email">
<InlineEditableField value={company.billingEmail} onSave={save('billingEmail')} />
</EditableRow>
</dl>
</div>
{/* Notes */}
{company.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
{company.notes}
</p>
</div>
)}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={company.notes}
onSave={save('notes')}
emptyText="No notes — click to add"
/>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/companies/${companyId}/tags`}
currentTags={company.tags ?? []}
invalidateKey={['companies', companyId]}
/>
</div>
</div>
);
}
@@ -104,17 +166,14 @@ function OverviewTab({ company }: { company: CompanyTabsCompany }) {
export function getCompanyTabs({
companyId,
portSlug,
// currentUserId reserved for when NotesList supports entityType='companies'.
currentUserId: _currentUserId,
currentUserId,
company,
}: CompanyTabsOptions): DetailTab[] {
void _currentUserId;
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab company={company} />,
content: <OverviewTab companyId={companyId} company={company} />,
},
{
id: 'members',
@@ -129,7 +188,6 @@ export function getCompanyTabs({
{
id: 'addresses',
label: 'Addresses',
// TODO: wire to future company-addresses endpoint (see company-addresses schema).
content: (
<EmptyState
title="Addresses"
@@ -145,22 +203,8 @@ export function getCompanyTabs({
{
id: 'notes',
label: 'Notes',
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
// Extend NotesList (or swap to a company-notes endpoint) in a follow-up.
content: (
<EmptyState
title="Notes"
description="Company notes coming soon — the notes endpoint is pending wiring."
/>
),
},
{
id: 'tags',
label: 'Tags',
// TODO: replace with an inline tag editor once one exists; company tags
// can be edited via the Edit form in the meantime.
content: (
<EmptyState title="Tags" description="Manage tags from the Edit company form for now." />
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
),
},
];

View File

@@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
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 {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
interface Account {
id: string;
emailAddress: string;
isActive: boolean;
}
interface ComposeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultTo?: string;
defaultSubject?: string;
}
export function ComposeDialog({
open,
onOpenChange,
defaultTo = '',
defaultSubject = '',
}: ComposeDialogProps) {
const qc = useQueryClient();
const [accountId, setAccountId] = useState('');
const [to, setTo] = useState(defaultTo);
const [cc, setCc] = useState('');
const [subject, setSubject] = useState(defaultSubject);
const [body, setBody] = useState('');
const { data: accounts = [] } = useQuery<Account[]>({
queryKey: ['email', 'accounts'],
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
});
const activeAccounts = accounts.filter((a) => a.isActive);
const sendMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/email/compose', {
method: 'POST',
body: {
accountId,
to: to
.split(',')
.map((s) => s.trim())
.filter(Boolean),
cc: cc
? cc
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined,
subject,
bodyHtml: body.replace(/\n/g, '<br>'),
},
}),
onSuccess: () => {
toast.success('Email sent');
qc.invalidateQueries({ queryKey: ['email', 'threads'] });
onOpenChange(false);
setTo('');
setCc('');
setSubject('');
setBody('');
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Send failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Compose email</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label>From</Label>
<Select value={accountId} onValueChange={setAccountId}>
<SelectTrigger>
<SelectValue placeholder="Choose account" />
</SelectTrigger>
<SelectContent>
{activeAccounts.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No active accounts
</div>
) : (
activeAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.emailAddress}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>To (comma-separated)</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} />
</div>
<div className="space-y-1">
<Label>CC</Label>
<Input value={cc} onChange={(e) => setCc(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Message</Label>
<Textarea rows={10} value={body} onChange={(e) => setBody(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => sendMutation.mutate()}
disabled={
sendMutation.isPending || !accountId || !to.trim() || !subject.trim() || !body.trim()
}
>
{sendMutation.isPending ? 'Sending…' : 'Send'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail, Plus, Trash2, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { apiFetch } from '@/lib/api/client';
interface Account {
id: string;
provider: 'google' | 'outlook' | 'custom';
emailAddress: string;
smtpHost: string;
smtpPort: number;
imapHost: string;
imapPort: number;
username: string;
isActive: boolean;
lastSyncAt: string | null;
}
const PROVIDER_DEFAULTS: Record<
string,
{ smtpHost: string; smtpPort: number; imapHost: string; imapPort: number }
> = {
google: { smtpHost: 'smtp.gmail.com', smtpPort: 587, imapHost: 'imap.gmail.com', imapPort: 993 },
outlook: {
smtpHost: 'smtp.office365.com',
smtpPort: 587,
imapHost: 'outlook.office365.com',
imapPort: 993,
},
custom: { smtpHost: '', smtpPort: 587, imapHost: '', imapPort: 993 },
};
export function EmailAccountsList() {
const qc = useQueryClient();
const [sheetOpen, setSheetOpen] = useState(false);
const [form, setForm] = useState({
provider: 'google' as 'google' | 'outlook' | 'custom',
emailAddress: '',
smtpHost: PROVIDER_DEFAULTS.google!.smtpHost,
smtpPort: PROVIDER_DEFAULTS.google!.smtpPort,
imapHost: PROVIDER_DEFAULTS.google!.imapHost,
imapPort: PROVIDER_DEFAULTS.google!.imapPort,
username: '',
password: '',
});
const { data: accounts = [], isLoading } = useQuery<Account[]>({
queryKey: ['email', 'accounts'],
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: () => apiFetch('/api/v1/email/accounts', { method: 'POST', body: form }),
onSuccess: () => {
toast.success('Account connected');
setSheetOpen(false);
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to connect account'),
});
const toggleMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
apiFetch(`/api/v1/email/accounts/${id}`, {
method: 'PATCH',
body: { isActive },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['email', 'accounts'] }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/email/accounts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Account removed');
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
},
});
const syncMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/email/accounts/${id}/sync`, { method: 'POST' }),
onSuccess: () => {
toast.success('Sync started');
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
},
});
function setProvider(provider: 'google' | 'outlook' | 'custom') {
const defaults = PROVIDER_DEFAULTS[provider]!;
setForm({ ...form, provider, ...defaults });
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold">Connected Accounts</h2>
<p className="text-sm text-muted-foreground">
IMAP/SMTP accounts used for sending and receiving client emails.
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Add account
</Button>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : accounts.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<Mail className="mx-auto h-6 w-6 mb-2" />
<p className="text-sm">No email accounts connected.</p>
</div>
) : (
<div className="rounded-lg border divide-y">
{accounts.map((a) => (
<div key={a.id} className="flex items-center gap-3 p-3">
<Mail className="h-5 w-5 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{a.emailAddress}</div>
<div className="text-xs text-muted-foreground">
{a.provider} · {a.imapHost}
{a.lastSyncAt && ` · last sync ${new Date(a.lastSyncAt).toLocaleString()}`}
</div>
</div>
<Switch
checked={a.isActive}
onCheckedChange={(v) => toggleMutation.mutate({ id: a.id, isActive: v })}
/>
<Button
variant="ghost"
size="icon"
onClick={() => syncMutation.mutate(a.id)}
disabled={syncMutation.isPending}
title="Sync now"
>
<RefreshCw className="h-4 w-4" />
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
}
title="Remove account"
description={`Disconnect ${a.emailAddress}?`}
confirmLabel="Remove"
onConfirm={() => deleteMutation.mutate(a.id)}
/>
</div>
))}
</div>
)}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Connect email account</SheetTitle>
</SheetHeader>
<div className="space-y-3 py-4">
<div className="space-y-1">
<Label>Provider</Label>
<Select
value={form.provider}
onValueChange={(v) => setProvider(v as 'google' | 'outlook' | 'custom')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google">Google</SelectItem>
<SelectItem value="outlook">Outlook / Office 365</SelectItem>
<SelectItem value="custom">Custom IMAP/SMTP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Email address</Label>
<Input
type="email"
value={form.emailAddress}
onChange={(e) =>
setForm({ ...form, emailAddress: e.target.value, username: e.target.value })
}
/>
</div>
<div className="space-y-1">
<Label>Username</Label>
<Input
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>Password / App password</Label>
<Input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label>SMTP host</Label>
<Input
value={form.smtpHost}
onChange={(e) => setForm({ ...form, smtpHost: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>SMTP port</Label>
<Input
type="number"
value={form.smtpPort}
onChange={(e) => setForm({ ...form, smtpPort: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<Label>IMAP host</Label>
<Input
value={form.imapHost}
onChange={(e) => setForm({ ...form, imapHost: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>IMAP port</Label>
<Input
type="number"
value={form.imapPort}
onChange={(e) => setForm({ ...form, imapPort: Number(e.target.value) })}
/>
</div>
</div>
</div>
<SheetFooter>
<Button variant="ghost" onClick={() => setSheetOpen(false)}>
Cancel
</Button>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Connecting…' : 'Connect'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { Mail } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface Thread {
id: string;
subject: string;
snippet: string | null;
lastMessageAt: string;
participants: string[];
unreadCount: number;
}
interface ThreadsResponse {
data: Thread[];
total: number;
}
export function EmailThreadsList() {
const { data, isLoading } = useQuery<ThreadsResponse>({
queryKey: ['email', 'threads'],
queryFn: () => apiFetch<ThreadsResponse>('/api/v1/email/threads'),
});
if (isLoading) {
return <p className="text-sm text-muted-foreground">Loading threads</p>;
}
const threads = data?.data ?? [];
if (threads.length === 0) {
return (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<Mail className="mx-auto h-6 w-6 mb-2" />
<p className="text-sm">No email threads yet.</p>
<p className="text-xs">
Connect an account and trigger a sync to see incoming threads here.
</p>
</div>
);
}
return (
<div className="rounded-lg border divide-y">
{threads.map((t) => (
<div key={t.id} className="p-3 hover:bg-muted/40">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium truncate">{t.subject || '(no subject)'}</div>
<div className="text-xs text-muted-foreground shrink-0">
{formatDistanceToNow(new Date(t.lastMessageAt), { addSuffix: true })}
</div>
</div>
<div className="text-xs text-muted-foreground truncate">{t.participants.join(', ')}</div>
{t.snippet && (
<div className="text-xs text-muted-foreground mt-1 line-clamp-1">{t.snippet}</div>
)}
{t.unreadCount > 0 && (
<span className="inline-block mt-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{t.unreadCount} unread
</span>
)}
</div>
))}
</div>
);
}

View File

@@ -1,16 +1,30 @@
'use client';
import { format } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RecommendationList } from '@/components/interests/recommendation-list';
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { LEAD_CATEGORIES } from '@/lib/constants';
import { apiFetch } from '@/lib/api/client';
type InterestPatchField = 'leadCategory' | 'source' | 'notes';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c,
label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
}));
interface InterestTabsOptions {
interestId: string;
currentUserId?: string;
interest: {
leadCategory: string | null;
source: string | null;
eoiStatus: string | null;
contractStatus: string | null;
depositStatus: string | null;
@@ -26,9 +40,33 @@ interface InterestTabsOptions {
reminderDays: number | null;
reminderLastFired: string | null;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
function useInterestPatch(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<InterestPatchField, string | null>>) =>
apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interests', interestId] });
},
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
@@ -44,10 +82,39 @@ function formatDate(date: string | null) {
return format(new Date(date), 'MMM d, yyyy');
}
function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }) {
function OverviewTab({
interestId,
interest,
}: {
interestId: string;
interest: InterestTabsOptions['interest'];
}) {
const mutation = useInterestPatch(interestId);
const save = (field: InterestPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* EOI & Contract Status */}
{/* Lead & Source (editable) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Lead</h3>
<dl>
<EditableRow label="Lead Category">
<InlineEditableField
variant="select"
options={LEAD_CATEGORY_OPTIONS}
value={interest.leadCategory}
onSave={save('leadCategory')}
/>
</EditableRow>
<EditableRow label="Source">
<InlineEditableField value={interest.source} onSave={save('source')} />
</EditableRow>
</dl>
</div>
{/* EOI & Contract Status (read-only — derived) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<dl>
@@ -58,8 +125,8 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
</dl>
</div>
{/* Key Dates */}
<div className="space-y-1">
{/* Key Dates (read-only — set by workflow events) */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Key Dates</h3>
<dl>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
@@ -81,23 +148,31 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
label="Reminder Days"
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
/>
<InfoRow
label="Last Fired"
value={formatDate(interest.reminderLastFired)}
/>
<InfoRow label="Last Fired" value={formatDate(interest.reminderLastFired)} />
</dl>
</div>
)}
{/* Notes */}
{interest.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{interest.notes}
</p>
</div>
)}
{/* Notes (editable, multiline) */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={interest.notes}
onSave={save('notes')}
emptyText="No notes — click to add"
/>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
</div>
</div>
);
}
@@ -111,17 +186,13 @@ export function getInterestTabs({
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interest={interest} />,
content: <OverviewTab interestId={interestId} interest={interest} />,
},
{
id: 'notes',
label: 'Notes',
content: (
<NotesList
entityType="interests"
entityId={interestId}
currentUserId={currentUserId}
/>
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
),
},
{

View File

@@ -74,7 +74,7 @@ export function PipelineBoard() {
let newStage = over.id as string;
// If dropped on a card (not a stage), find which stage that card belongs to
if (!PIPELINE_STAGES.includes(newStage as typeof PIPELINE_STAGES[number])) {
if (!PIPELINE_STAGES.includes(newStage as (typeof PIPELINE_STAGES)[number])) {
const targetInterest = interests.find((i) => i.id === newStage);
if (!targetInterest) return;
newStage = targetInterest.pipelineStage;
@@ -85,23 +85,18 @@ export function PipelineBoard() {
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
// Optimistic update
queryClient.setQueryData<{ data: InterestRow[] }>(
['interests-board', portSlug],
(old) => {
if (!old) return old;
return {
...old,
data: old.data.map((i) =>
i.id === interestId ? { ...i, pipelineStage: newStage } : i,
),
};
},
);
queryClient.setQueryData<{ data: InterestRow[] }>(['interests-board', portSlug], (old) => {
if (!old) return old;
return {
...old,
data: old.data.map((i) => (i.id === interestId ? { ...i, pipelineStage: newStage } : i)),
};
});
try {
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: JSON.stringify({ pipelineStage: newStage }),
body: { pipelineStage: newStage },
});
queryClient.invalidateQueries({ queryKey: ['interests'] });
} catch {

View File

@@ -41,8 +41,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
});
const sendMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
mutationFn: () => apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
@@ -58,7 +57,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
mutationFn: (values: RecordPaymentInput) =>
apiFetch(`/api/v1/invoices/${invoiceId}/payment`, {
method: 'PATCH',
body: JSON.stringify(values),
body: values,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
@@ -76,9 +75,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
if (error || !data?.data) {
return (
<div className="p-6 text-center text-muted-foreground">
Failed to load invoice details.
</div>
<div className="p-6 text-center text-muted-foreground">Failed to load invoice details.</div>
);
}
@@ -230,9 +227,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<CardTitle className="text-sm font-medium">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{invoice.notes}</p>
</CardContent>
</Card>
)}
@@ -249,9 +244,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
className="flex items-center justify-between p-3 border rounded-md text-sm"
>
<div>
<p className="font-medium">
{exp.establishmentName ?? 'Unnamed Expense'}
</p>
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
<p className="text-muted-foreground text-xs">
{exp.category ?? '—'} &middot; {exp.expenseDate}
</p>
@@ -271,10 +264,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
{/* PDF Preview */}
<TabsContent value="pdf" className="pt-4">
<InvoicePdfPreview
invoiceId={invoiceId}
pdfFileId={invoice.pdfFileId}
/>
<InvoicePdfPreview invoiceId={invoiceId} pdfFileId={invoice.pdfFileId} />
</TabsContent>
{/* Payment */}
@@ -283,10 +273,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<Card>
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-green-100 text-green-700 border-green-200"
>
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
Paid
</Badge>
</div>
@@ -297,9 +284,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</div>
<div>
<span className="text-muted-foreground">Method</span>
<p className="mt-0.5 capitalize">
{invoice.paymentMethod ?? '—'}
</p>
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Reference</span>
@@ -315,18 +300,12 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</CardHeader>
<CardContent>
<form
onSubmit={paymentForm.handleSubmit((values) =>
paymentMutation.mutate(values),
)}
onSubmit={paymentForm.handleSubmit((values) => paymentMutation.mutate(values))}
className="space-y-4"
>
<div className="space-y-1">
<Label htmlFor="paymentDate">Payment Date</Label>
<Input
id="paymentDate"
type="date"
{...paymentForm.register('paymentDate')}
/>
<Input id="paymentDate" type="date" {...paymentForm.register('paymentDate')} />
{paymentForm.formState.errors.paymentDate && (
<p className="text-xs text-destructive">
{paymentForm.formState.errors.paymentDate.message}
@@ -349,10 +328,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
{...paymentForm.register('paymentReference')}
/>
</div>
<Button
type="submit"
disabled={paymentMutation.isPending}
>
<Button type="submit" disabled={paymentMutation.isPending}>
{paymentMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (

View File

@@ -35,8 +35,18 @@ const SEGMENT_LABELS: Record<string, string> = {
profile: 'Profile',
};
// UUID v4-ish (or any 36-char hex+dash) — used to skip entity-id segments
// from the breadcrumbs since the page H1 already shows the entity name.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isIdSegment(segment: string): boolean {
return UUID_RE.test(segment);
}
function formatSegment(segment: string): string {
return SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
);
}
export function Breadcrumbs() {
@@ -46,10 +56,11 @@ export function Breadcrumbs() {
// Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean);
// Remove the portSlug segment from display
const segments = currentPortSlug
? rawSegments.filter((seg) => seg !== currentPortSlug)
: rawSegments;
// Remove the portSlug segment and any UUID-ish entity-id segments — the
// page H1 already shows the entity name, no need to leak the raw id.
const segments = (
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
).filter((seg) => !isIdSegment(seg));
if (segments.length === 0) {
return (

View File

@@ -17,6 +17,7 @@ import {
Bell,
Settings,
Shield,
Home,
ChevronLeft,
ChevronRight,
Menu,
@@ -38,6 +39,7 @@ import type { Role } from '@/lib/db/schema/users';
interface SidebarProps {
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
isSuperAdmin?: boolean;
}
interface NavItem {
@@ -51,6 +53,10 @@ interface NavSection {
title: string;
items: NavItem[];
adminRequired?: boolean;
/** When true, only render if the user has marina-side access. */
marinaRequired?: boolean;
/** When true, only render if the user has residential-side access. */
residentialRequired?: boolean;
}
function buildNavSections(portSlug: string | undefined): NavSection[] {
@@ -59,6 +65,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
return [
{
title: 'Main',
marinaRequired: true,
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
@@ -68,8 +75,25 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
],
},
{
title: 'Residential',
residentialRequired: true,
items: [
{
href: `${base}/residential/clients`,
label: 'Residential Clients',
icon: Home,
},
{
href: `${base}/residential/interests`,
label: 'Residential Interests',
icon: Bookmark,
},
],
},
{
title: 'Documents',
marinaRequired: true,
items: [
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
@@ -77,6 +101,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
{
title: 'Financial',
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
@@ -84,6 +109,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
{
title: 'Communication',
marinaRequired: true,
items: [
{ href: `${base}/email`, label: 'Email', icon: Mail },
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
@@ -150,11 +176,15 @@ function SidebarContent({
portSlug,
portRoles,
hasAdminAccess,
hasMarinaAccess,
hasResidentialAccess,
}: {
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
hasAdminAccess: boolean;
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
}) {
const pathname = usePathname();
const [adminExpanded, setAdminExpanded] = useState(false);
@@ -191,6 +221,8 @@ function SidebarContent({
<nav className="px-2 space-y-4">
{sections.map((section) => {
if (section.adminRequired && !hasAdminAccess) return null;
if (section.marinaRequired && !hasMarinaAccess) return null;
if (section.residentialRequired && !hasResidentialAccess) return null;
return (
<div key={section.title}>
@@ -272,16 +304,25 @@ function SidebarContent({
);
}
export function Sidebar({ portRoles }: SidebarProps) {
export function Sidebar({ portRoles, isSuperAdmin = false }: SidebarProps) {
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
// Check for admin access based on role permissions
const hasAdminAccess = portRoles.some(
(pr) =>
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
// Super admins see every section regardless of role rows.
const hasAdminAccess =
isSuperAdmin ||
portRoles.some(
(pr) =>
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
const hasMarinaAccess =
isSuperAdmin || portRoles.some((pr) => pr.role?.permissions?.clients?.view);
const hasResidentialAccess =
isSuperAdmin ||
portRoles.some((pr) => pr.residentialAccess || pr.role?.permissions?.residential_clients?.view);
return (
<>
@@ -298,6 +339,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
/>
{/* Collapse toggle */}
@@ -337,6 +380,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
/>
</SheetContent>
</Sheet>

View File

@@ -1,6 +1,6 @@
'use client';
import { Plus, Moon, Sun, LogOut, User, Settings } from 'lucide-react';
import { Plus, Moon, Sun, LogOut, User, Settings, Bell } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store';
@@ -113,6 +113,13 @@ export function Topbar({ ports }: TopbarProps) {
<Settings className="w-4 h-4 mr-2" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={() => router.push(`${base}/notifications/preferences` as any)}
>
<Bell className="w-4 h-4 mr-2" />
Notification preferences
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleToggleDarkMode}>
{darkMode ? (

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { apiFetch } from '@/lib/api/client';
interface Pref {
notificationType: string;
inApp: boolean;
email: boolean;
}
const KNOWN_TYPES: Array<{ key: string; label: string; description: string }> = [
{
key: 'mention',
label: 'Mentions',
description: 'When someone @-mentions you in a note.',
},
{
key: 'reminder_overdue',
label: 'Overdue Reminders',
description: 'When an interest reminder you own becomes overdue.',
},
{
key: 'interest_stage_changed',
label: 'Interest Stage Changes',
description: 'When a pipeline stage changes on an interest assigned to you.',
},
{
key: 'system_alert',
label: 'System Alerts',
description: 'Background job failures and maintenance notices.',
},
];
export function NotificationPreferencesForm() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<Pref[]>({
queryKey: ['notifications', 'preferences'],
queryFn: () =>
apiFetch<{ data: Pref[] }>('/api/v1/notifications/preferences').then((r) => r.data),
});
const [prefs, setPrefs] = useState<Map<string, Pref>>(new Map());
useEffect(() => {
const map = new Map<string, Pref>();
for (const t of KNOWN_TYPES) {
map.set(t.key, { notificationType: t.key, inApp: true, email: true });
}
if (data) {
for (const p of data) {
map.set(p.notificationType, p);
}
}
setPrefs(map);
}, [data]);
const mutation = useMutation({
mutationFn: async () => {
const payload = { preferences: Array.from(prefs.values()) };
return apiFetch('/api/v1/notifications/preferences', {
method: 'PUT',
body: payload,
});
},
onSuccess: () => {
toast.success('Preferences saved');
qc.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'),
});
function update(type: string, field: 'inApp' | 'email', value: boolean) {
setPrefs((prev) => {
const next = new Map(prev);
const existing = next.get(type) ?? { notificationType: type, inApp: true, email: true };
next.set(type, { ...existing, [field]: value });
return next;
});
}
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading preferences</div>;
}
return (
<div className="space-y-4">
<div className="rounded-lg border divide-y">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-2 text-xs font-medium uppercase text-muted-foreground">
<div>Type</div>
<div className="w-16 text-center">In-app</div>
<div className="w-16 text-center">Email</div>
</div>
{KNOWN_TYPES.map((t) => {
const pref = prefs.get(t.key) ?? {
notificationType: t.key,
inApp: true,
email: true,
};
return (
<div
key={t.key}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 items-center"
>
<div>
<div className="text-sm font-medium">{t.label}</div>
<div className="text-xs text-muted-foreground">{t.description}</div>
</div>
<div className="w-16 flex justify-center">
<Switch checked={pref.inApp} onCheckedChange={(v) => update(t.key, 'inApp', v)} />
</div>
<div className="w-16 flex justify-center">
<Switch checked={pref.email} onCheckedChange={(v) => update(t.key, 'email', v)} />
</div>
</div>
);
})}
</div>
<div className="flex justify-end">
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Saving…' : 'Save preferences'}
</Button>
</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
interface PasswordSetFormProps {
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
@@ -75,7 +75,7 @@ export function PasswordSetForm({
if (!token) {
return (
<PortalAuthShell>
<BrandedAuthShell>
<div className="text-center space-y-3">
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
<p className="text-sm text-gray-500">
@@ -89,13 +89,13 @@ export function PasswordSetForm({
Request a new link
</Link>
</div>
</PortalAuthShell>
</BrandedAuthShell>
);
}
if (done) {
return (
<PortalAuthShell>
<BrandedAuthShell>
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<CheckCircle2 className="h-7 w-7 text-green-600" />
@@ -109,12 +109,12 @@ export function PasswordSetForm({
Sign in
</Link>
</div>
</PortalAuthShell>
</BrandedAuthShell>
);
}
return (
<PortalAuthShell>
<BrandedAuthShell>
<div className="mb-6">
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
<p className="text-sm text-gray-500 mt-1">{description}</p>
@@ -173,6 +173,6 @@ export function PasswordSetForm({
)}
</Button>
</form>
</PortalAuthShell>
</BrandedAuthShell>
);
}

View File

@@ -195,13 +195,18 @@ export function ReservationList({
</TableCell>
<TableCell>
{r.contractFileId ? (
// TODO: Confirm final file-download endpoint URL when available
<a
href={`/api/v1/files/${r.contractFileId}/download`}
<button
type="button"
className="text-primary hover:underline"
onClick={async () => {
const res = await apiFetch<{ data: { url: string } }>(
`/api/v1/files/${r.contractFileId}/download`,
);
window.open(res.data.url, '_blank', 'noopener,noreferrer');
}}
>
View contract
</a>
</button>
) : (
'—'
)}

View File

@@ -0,0 +1,299 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface ResidentialInterestSummary {
id: string;
pipelineStage: string;
source: string | null;
notes: string | null;
preferences: string | null;
updatedAt: string;
}
interface ResidentialClientDetail {
id: string;
fullName: string;
email: string | null;
phone: string | null;
placeOfResidence: string | null;
preferredContactMethod: string | null;
status: string;
source: string | null;
notes: string | null;
interests: ResidentialInterestSummary[];
}
const STATUS_OPTIONS = [
{ value: 'prospect', label: 'Prospect' },
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
];
const CONTACT_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
];
const SOURCE_OPTIONS = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
];
const STAGE_LABELS: Record<string, string> = {
new: 'New',
contacted: 'Contacted',
viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made',
offer_accepted: 'Offer accepted',
closed_won: 'Closed — won',
closed_lost: 'Closed — lost',
};
export function ResidentialClientDetail({ clientId }: { clientId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const qc = useQueryClient();
const [newInterestOpen, setNewInterestOpen] = useState(false);
const { data, isLoading } = useQuery<{ data: ResidentialClientDetail }>({
queryKey: ['residential-client', clientId],
queryFn: () => apiFetch(`/api/v1/residential/clients/${clientId}`),
});
useRealtimeInvalidation({
'residential_client:updated': [['residential-client', clientId]],
'residential_interest:created': [['residential-client', clientId]],
'residential_interest:updated': [['residential-client', clientId]],
'residential_interest:archived': [['residential-client', clientId]],
});
const update = useMutation({
mutationFn: (patch: Record<string, unknown>) =>
apiFetch(`/api/v1/residential/clients/${clientId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['residential-client', clientId] }),
});
const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next });
};
if (isLoading || !data) {
return <div className="text-sm text-muted-foreground">Loading</div>;
}
const client = data.data;
return (
<div className="space-y-6">
<div>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients` as any}
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ArrowLeft className="h-3 w-3" /> All residential clients
</Link>
</div>
<div className="rounded-lg border bg-card p-6 space-y-6">
<div>
<h1 className="text-2xl font-semibold">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<Row label="Email">
<InlineEditableField value={client.email} onSave={save('email')} />
</Row>
<Row label="Phone">
<InlineEditableField value={client.phone} onSave={save('phone')} />
</Row>
<Row label="Preferred contact">
<InlineEditableField
variant="select"
options={CONTACT_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</Row>
<Row label="Place of residence">
<InlineEditableField
value={client.placeOfResidence}
onSave={save('placeOfResidence')}
/>
</Row>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<Row label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={client.status}
onSave={save('status')}
/>
</Row>
<Row label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</Row>
<Row label="Notes">
<InlineEditableField value={client.notes} onSave={save('notes')} />
</Row>
</div>
</div>
</div>
<div className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Interests</h2>
<Button size="sm" onClick={() => setNewInterestOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New interest
</Button>
</div>
{client.interests.length === 0 ? (
<p className="text-sm text-muted-foreground">No interests yet.</p>
) : (
<ul className="space-y-2">
{client.interests.map((i) => (
<li key={i.id} className="flex items-center gap-3 p-3 rounded-md border bg-muted/30">
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</span>
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || '—'}</span>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests/${i.id}` as any}
className="text-xs text-primary hover:underline"
>
View
</Link>
</li>
))}
</ul>
)}
</div>
<NewInterestSheet
clientId={clientId}
open={newInterestOpen}
onOpenChange={setNewInterestOpen}
/>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
function NewInterestSheet({
clientId,
open,
onOpenChange,
}: {
clientId: string;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const qc = useQueryClient();
const [preferences, setPreferences] = useState('');
const [notes, setNotes] = useState('');
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/residential/interests', {
method: 'POST',
body: {
residentialClientId: clientId,
preferences: preferences || undefined,
notes: notes || undefined,
source: 'manual',
},
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['residential-client', clientId] });
onOpenChange(false);
setPreferences('');
setNotes('');
toast.success('Interest added');
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to add'),
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>New interest</SheetTitle>
</SheetHeader>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="ri-prefs">Preferences</Label>
<Input
id="ri-prefs"
value={preferences}
onChange={(e) => setPreferences(e.target.value)}
placeholder="Unit type, size, budget…"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ri-notes">Notes</Label>
<Input id="ri-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={create.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Saving…' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { PageHeader } from '@/components/shared/page-header';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface ResidentialClientRow {
id: string;
fullName: string;
email: string | null;
phone: string | null;
placeOfResidence: string | null;
status: string;
source: string | null;
updatedAt: string;
}
interface ListResponse {
data: ResidentialClientRow[];
pagination: { total: number; page: number; pageSize: number };
}
const STATUS_LABELS: Record<string, string> = {
prospect: 'Prospect',
active: 'Active',
inactive: 'Inactive',
};
export function ResidentialClientsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
const [search, setSearch] = useState('');
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['residential-clients', { search }],
queryFn: () => {
const qs = new URLSearchParams({ search, limit: '50' });
return apiFetch(`/api/v1/residential/clients?${qs.toString()}`);
},
});
useRealtimeInvalidation({
'residential_client:created': [['residential-clients']],
'residential_client:updated': [['residential-clients']],
'residential_client:archived': [['residential-clients']],
'residential_client:restored': [['residential-clients']],
});
return (
<div className="space-y-4">
<PageHeader
title="Residential Clients"
description="Inquiries and clients for the residential side"
actions={
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New
</Button>
}
/>
<div className="flex items-center gap-2">
<Input
placeholder="Search by name, email, phone, residence…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
</div>
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-2">Name</th>
<th className="text-left font-medium px-3 py-2">Email</th>
<th className="text-left font-medium px-3 py-2">Phone</th>
<th className="text-left font-medium px-3 py-2">Residence</th>
<th className="text-left font-medium px-3 py-2">Status</th>
<th className="text-left font-medium px-3 py-2">Source</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
Loading
</td>
</tr>
)}
{!isLoading && data?.data.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
No residential clients yet.
</td>
</tr>
)}
{data?.data.map((c) => (
<tr
key={c.id}
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
>
<td className="px-3 py-2">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="font-medium hover:underline"
>
{c.fullName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">{c.email ?? '—'}</td>
<td className="px-3 py-2 text-muted-foreground">{c.phone ?? '—'}</td>
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? '—'}</td>
<td className="px-3 py-2">{STATUS_LABELS[c.status] ?? c.status}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}
function NewResidentialClientSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const qc = useQueryClient();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [placeOfResidence, setPlaceOfResidence] = useState('');
const [notes, setNotes] = useState('');
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/residential/clients', {
method: 'POST',
body: {
fullName,
email: email || undefined,
phone: phone || undefined,
placeOfResidence: placeOfResidence || undefined,
notes: notes || undefined,
source: 'manual',
},
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['residential-clients'] });
onOpenChange(false);
setFullName('');
setEmail('');
setPhone('');
setPlaceOfResidence('');
setNotes('');
toast.success('Residential client added');
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Failed to create');
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>New residential client</SheetTitle>
</SheetHeader>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="rc-name">Full name *</Label>
<Input
id="rc-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-email">Email</Label>
<Input
id="rc-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-phone">Phone</Label>
<Input id="rc-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence">Place of residence</Label>
<Input
id="rc-residence"
value={placeOfResidence}
onChange={(e) => setPlaceOfResidence(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-notes">Notes</Label>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={create.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={!fullName.trim() || create.isPending}>
{create.isPending ? 'Saving…' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft } from 'lucide-react';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES } from '@/lib/validators/residential';
interface ResidentialInterestDetail {
id: string;
residentialClientId: string;
pipelineStage: string;
source: string | null;
notes: string | null;
preferences: string | null;
assignedTo: string | null;
client: { id: string; fullName: string } | null;
}
const STAGE_LABELS: Record<string, string> = {
new: 'New',
contacted: 'Contacted',
viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made',
offer_accepted: 'Offer accepted',
closed_won: 'Closed — won',
closed_lost: 'Closed — lost',
};
const STAGE_OPTIONS = PIPELINE_STAGES.map((s) => ({
value: s,
label: STAGE_LABELS[s] ?? s,
}));
const SOURCE_OPTIONS = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
];
export function ResidentialInterestDetail({ interestId }: { interestId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ data: ResidentialInterestDetail }>({
queryKey: ['residential-interest', interestId],
queryFn: () => apiFetch(`/api/v1/residential/interests/${interestId}`),
});
useRealtimeInvalidation({
'residential_interest:updated': [['residential-interest', interestId]],
});
const update = useMutation({
mutationFn: (patch: Record<string, unknown>) =>
apiFetch(`/api/v1/residential/interests/${interestId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['residential-interest', interestId] }),
});
const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next });
};
if (isLoading || !data) {
return <div className="text-sm text-muted-foreground">Loading</div>;
}
const interest = data.data;
return (
<div className="space-y-6">
<div>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests` as any}
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ArrowLeft className="h-3 w-3" /> All residential interests
</Link>
</div>
<div className="rounded-lg border bg-card p-6 space-y-6">
<div>
<p className="text-xs uppercase text-muted-foreground tracking-wider mb-1">
Residential interest
</p>
{interest.client && (
<h1 className="text-2xl font-semibold">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients/${interest.client.id}` as any}
className="hover:underline"
>
{interest.client.fullName}
</Link>
</h1>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Pipeline</h3>
<Row label="Stage">
<InlineEditableField
variant="select"
options={STAGE_OPTIONS}
value={interest.pipelineStage}
onSave={save('pipelineStage')}
/>
</Row>
<Row label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={interest.source}
onSave={save('source')}
/>
</Row>
<Row label="Assigned to">
<InlineEditableField
value={interest.assignedTo}
onSave={save('assignedTo')}
placeholder="user id"
/>
</Row>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Details</h3>
<Row label="Preferences">
<InlineEditableField value={interest.preferences} onSave={save('preferences')} />
</Row>
<Row label="Notes">
<InlineEditableField value={interest.notes} onSave={save('notes')} />
</Row>
</div>
</div>
</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageHeader } from '@/components/shared/page-header';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES } from '@/lib/validators/residential';
interface ResidentialInterestRow {
id: string;
residentialClientId: string;
pipelineStage: string;
source: string | null;
notes: string | null;
preferences: string | null;
assignedTo: string | null;
updatedAt: string;
}
interface ListResponse {
data: ResidentialInterestRow[];
pagination: { total: number };
}
const STAGE_LABELS: Record<string, string> = {
new: 'New',
contacted: 'Contacted',
viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made',
offer_accepted: 'Offer accepted',
closed_won: 'Closed — won',
closed_lost: 'Closed — lost',
};
export function ResidentialInterestsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [search, setSearch] = useState('');
const [stage, setStage] = useState<string>('all');
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['residential-interests', { search, stage }],
queryFn: () => {
const qs = new URLSearchParams({ search, limit: '50' });
if (stage !== 'all') qs.set('pipelineStage', stage);
return apiFetch(`/api/v1/residential/interests?${qs.toString()}`);
},
});
useRealtimeInvalidation({
'residential_interest:created': [['residential-interests']],
'residential_interest:updated': [['residential-interests']],
'residential_interest:archived': [['residential-interests']],
});
return (
<div className="space-y-4">
<PageHeader
title="Residential Interests"
description="Inquiries flowing through the residential pipeline"
/>
<div className="flex items-center gap-2">
<Input
placeholder="Search notes / preferences…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<Select value={stage} onValueChange={setStage}>
<SelectTrigger className="w-52">
<SelectValue placeholder="All stages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
{PIPELINE_STAGES.map((s) => (
<SelectItem key={s} value={s}>
{STAGE_LABELS[s] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-2">Stage</th>
<th className="text-left font-medium px-3 py-2">Preferences</th>
<th className="text-left font-medium px-3 py-2">Notes</th>
<th className="text-left font-medium px-3 py-2">Source</th>
<th className="text-left font-medium px-3 py-2">Updated</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="px-3 py-8 text-center text-muted-foreground">
Loading
</td>
</tr>
)}
{!isLoading && data?.data.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-8 text-center text-muted-foreground">
No interests match.
</td>
</tr>
)}
{data?.data.map((i) => (
<tr
key={i.id}
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
>
<td className="px-3 py-2">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests/${i.id}` as any}
className="font-medium hover:underline"
>
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
{i.preferences ?? '—'}
</td>
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
{i.notes ?? '—'}
</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{i.source ?? '—'}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{new Date(i.updatedAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -2,7 +2,13 @@ const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
export function PortalAuthShell({ children }: { children: React.ReactNode }) {
/**
* Branded shell shared by every auth/form surface CRM login, portal login,
* password set/reset/activate, forgot-password. Renders the blurred Port
* Nimara overhead background, the circular logo, and a centered white card
* that consumers populate with their own form/content.
*/
export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-8"

View File

@@ -0,0 +1,272 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
interface SelectOption {
value: string;
label: string;
}
interface BaseProps {
value: string | null | undefined;
onSave: (next: string | null) => Promise<void>;
placeholder?: string;
emptyText?: string;
className?: string;
disabled?: boolean;
}
interface TextProps extends BaseProps {
variant?: 'text';
}
interface SelectFieldProps extends BaseProps {
variant: 'select';
options: SelectOption[];
}
interface TextareaProps extends BaseProps {
variant: 'textarea';
rows?: number;
}
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
/**
* Click-to-edit field used in detail panels. Shows the value as plain text
* with a pencil affordance on hover; clicking swaps to an input that saves on
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
const { value, onSave, placeholder, emptyText = '—', className, disabled } = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setDraft(value ?? '');
}, [value]);
useEffect(() => {
if (editing) {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
} else if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}
}, [editing]);
async function commit(nextRaw: string) {
const trimmed = nextRaw.trim();
if (trimmed === (value ?? '')) {
setEditing(false);
return;
}
setSaving(true);
try {
await onSave(trimmed === '' ? null : trimmed);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save');
setDraft(value ?? '');
} finally {
setSaving(false);
}
}
function cancel() {
setDraft(value ?? '');
setEditing(false);
}
if (props.variant === 'select') {
const labelFor = (v: string | null | undefined) =>
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
if (!editing) {
return (
<ReadButton
value={labelFor(value)}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
className={className}
/>
);
}
return (
<div className={cn('flex items-center gap-1', className)}>
<Select
value={draft}
onValueChange={(v) => void commit(v)}
open
onOpenChange={(open) => {
if (!open && !saving) setEditing(false);
}}
>
<SelectTrigger className="h-7 text-sm w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{props.options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (props.variant === 'textarea') {
if (!editing) {
return (
<ReadButton
value={value || null}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
multiline
className={className}
/>
);
}
return (
<div className={cn('flex flex-col gap-1', className)}>
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit(draft);
}
}}
onBlur={() => {
if (!saving) void commit(draft);
}}
placeholder={placeholder}
disabled={saving}
rows={props.rows ?? 4}
className="text-sm"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (!editing) {
return (
<ReadButton
value={value || null}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
className={className}
/>
);
}
return (
<div className={cn('flex items-center gap-1', className)}>
<Input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void commit(draft);
}
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
onBlur={() => {
if (!saving) void commit(draft);
}}
placeholder={placeholder}
disabled={saving}
className="h-7 text-sm"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
function ReadButton({
value,
emptyText,
disabled,
onClick,
multiline,
className,
}: {
value: string | null;
emptyText: string;
disabled?: boolean;
onClick: () => void;
multiline?: boolean;
className?: string;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}
>
<span
className={cn(
'flex-1',
multiline && 'whitespace-pre-wrap',
!value && 'text-muted-foreground',
)}
>
{value ?? emptyText}
</span>
{!disabled && (
<Pencil
className={cn(
'h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50',
multiline && 'mt-1 shrink-0',
)}
/>
)}
</button>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface Tag {
id: string;
name: string;
color: string;
}
export interface InlineTagEditorProps {
/** PUT endpoint for replacing the entity's tag list — body shape `{ tagIds: string[] }`. */
endpoint: string;
currentTags: Tag[];
/** TanStack Query key to invalidate after a successful change. */
invalidateKey: readonly unknown[];
/** Hide the "+ Add tag" button (read-only mode). */
readOnly?: boolean;
}
export function InlineTagEditor({
endpoint,
currentTags,
invalidateKey,
readOnly,
}: InlineTagEditorProps) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const { data: allTags } = useQuery<{ data: Tag[] }>({
queryKey: ['tags'],
queryFn: () => apiFetch('/api/v1/tags'),
staleTime: 60_000,
enabled: open,
});
const setTags = useMutation({
mutationFn: (tagIds: string[]) => apiFetch(endpoint, { method: 'PUT', body: { tagIds } }),
onSuccess: () => qc.invalidateQueries({ queryKey: invalidateKey }),
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to update tags'),
});
function toggleTag(tagId: string) {
const has = currentTags.some((t) => t.id === tagId);
const nextIds = has
? currentTags.filter((t) => t.id !== tagId).map((t) => t.id)
: [...currentTags.map((t) => t.id), tagId];
setTags.mutate(nextIds);
}
function removeTag(tagId: string) {
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
}
return (
<div className="flex flex-wrap items-center gap-1.5">
{currentTags.map((t) => (
<span
key={t.id}
className="group inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${t.color}20`, color: t.color }}
>
{t.name}
{!readOnly && (
<button
type="button"
onClick={() => removeTag(t.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity hover:opacity-100"
aria-label={`Remove tag ${t.name}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
{!readOnly && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Plus className="h-3 w-3 mr-1" />
{currentTags.length === 0 ? 'Add tag' : 'Add'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="max-h-64 overflow-y-auto py-1">
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading</div>}
{allTags?.data.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
No tags defined yet. Create some in Admin Tags.
</div>
)}
{allTags?.data.map((t) => {
const checked = currentTags.some((c) => c.id === t.id);
return (
<button
key={t.id}
type="button"
onClick={() => toggleTag(t.id)}
className={cn(
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-muted/60',
)}
>
<span
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: t.color }}
/>
<span className="flex-1 truncate">{t.name}</span>
{checked && <Check className="h-3.5 w-3.5 text-primary" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}

View File

@@ -21,7 +21,7 @@ interface Note {
}
interface NotesListProps {
entityType: 'clients' | 'interests';
entityType: 'clients' | 'interests' | 'yachts' | 'companies';
entityId: string;
currentUserId?: string;
}
@@ -43,8 +43,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
});
const createMutation = useMutation({
mutationFn: (content: string) =>
apiFetch(endpoint, { method: 'POST', body: { content } }),
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setNewNote('');
@@ -61,8 +60,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
});
const deleteMutation = useMutation({
mutationFn: (noteId: string) =>
apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
@@ -127,13 +125,9 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
<span className="text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
</span>
{note.isLocked && (
<Lock className="h-3 w-3 text-muted-foreground" />
)}
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
{canEdit(note) && (
<span className="text-xs text-muted-foreground">
{getTimeRemaining(note)}
</span>
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
)}
</div>
{editingId === note.id ? (

View File

@@ -1,8 +1,38 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { apiFetch } from '@/lib/api/client';
type YachtPatchField =
| 'name'
| 'hullNumber'
| 'registration'
| 'flag'
| 'yearBuilt'
| 'builder'
| 'model'
| 'hullMaterial'
| 'lengthFt'
| 'widthFt'
| 'draftFt'
| 'lengthM'
| 'widthM'
| 'draftM'
| 'status'
| 'notes';
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'retired', label: 'Retired' },
{ value: 'sold_away', label: 'Sold away' },
];
interface YachtTabsYacht {
id: string;
@@ -22,6 +52,7 @@ interface YachtTabsYacht {
draftM: string | null;
status: string;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
}
interface YachtTabsOptions {
@@ -30,25 +61,43 @@ interface YachtTabsOptions {
yacht: YachtTabsYacht;
}
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
if (value === null || value === undefined || value === '') return null;
function useYachtPatch(yachtId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<YachtPatchField, string | number | null>>) =>
apiFetch(`/api/v1/yachts/${yachtId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['yachts', yachtId] });
},
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
retired: 'Retired',
sold_away: 'Sold away',
};
function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
const hasFtDimensions = yacht.lengthFt || yacht.widthFt || yacht.draftFt;
const hasMDimensions = yacht.lengthM || yacht.widthM || yacht.draftM;
function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYacht }) {
const mutation = useYachtPatch(yachtId);
const save =
(field: YachtPatchField, transform?: (v: string | null) => string | number | null) =>
async (next: string | null) => {
const value = transform ? transform(next) : next;
await mutation.mutateAsync({ [field]: value });
};
const numericString = (next: string | null) => (next === null ? null : next);
const yearTransform = (next: string | null) => {
if (next === null) return null;
const n = Number.parseInt(next, 10);
return Number.isNaN(n) ? null : n;
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -56,77 +105,113 @@ function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>
<dl>
<InfoRow label="Name" value={yacht.name} />
<InfoRow label="Hull Number" value={yacht.hullNumber} />
<InfoRow label="Registration" value={yacht.registration} />
<InfoRow label="Flag" value={yacht.flag} />
<InfoRow label="Year Built" value={yacht.yearBuilt} />
<InfoRow label="Status" value={STATUS_LABELS[yacht.status] ?? yacht.status} />
<EditableRow label="Name">
<InlineEditableField value={yacht.name} onSave={save('name')} />
</EditableRow>
<EditableRow label="Hull Number">
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
</EditableRow>
<EditableRow label="Registration">
<InlineEditableField value={yacht.registration} onSave={save('registration')} />
</EditableRow>
<EditableRow label="Flag">
<InlineEditableField value={yacht.flag} onSave={save('flag')} />
</EditableRow>
<EditableRow label="Year Built">
<InlineEditableField
value={yacht.yearBuilt?.toString() ?? null}
onSave={save('yearBuilt', yearTransform)}
/>
</EditableRow>
<EditableRow label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={yacht.status}
onSave={save('status')}
/>
</EditableRow>
</dl>
</div>
{/* Build */}
{(yacht.builder || yacht.model || yacht.hullMaterial) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3>
<dl>
<InfoRow label="Builder" value={yacht.builder} />
<InfoRow label="Model" value={yacht.model} />
<InfoRow label="Hull Material" value={yacht.hullMaterial} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3>
<dl>
<EditableRow label="Builder">
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
</EditableRow>
<EditableRow label="Model">
<InlineEditableField value={yacht.model} onSave={save('model')} />
</EditableRow>
<EditableRow label="Hull Material">
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
</EditableRow>
</dl>
</div>
{/* Dimensions (ft) */}
{hasFtDimensions && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<InfoRow label="Length" value={yacht.lengthFt ? `${yacht.lengthFt} ft` : null} />
<InfoRow label="Width" value={yacht.widthFt ? `${yacht.widthFt} ft` : null} />
<InfoRow label="Draft" value={yacht.draftFt ? `${yacht.draftFt} ft` : null} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<EditableRow label="Length (ft)">
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} />
</EditableRow>
<EditableRow label="Width (ft)">
<InlineEditableField value={yacht.widthFt} onSave={save('widthFt', numericString)} />
</EditableRow>
<EditableRow label="Draft (ft)">
<InlineEditableField value={yacht.draftFt} onSave={save('draftFt', numericString)} />
</EditableRow>
</dl>
</div>
{/* Dimensions (m) */}
{hasMDimensions && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<InfoRow label="Length" value={yacht.lengthM ? `${yacht.lengthM} m` : null} />
<InfoRow label="Width" value={yacht.widthM ? `${yacht.widthM} m` : null} />
<InfoRow label="Draft" value={yacht.draftM ? `${yacht.draftM} m` : null} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} />
</EditableRow>
<EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={save('widthM', numericString)} />
</EditableRow>
<EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={save('draftM', numericString)} />
</EditableRow>
</dl>
</div>
{/* Notes */}
{yacht.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
{yacht.notes}
</p>
</div>
)}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={yacht.notes}
onSave={save('notes')}
emptyText="No notes — click to add"
/>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
</div>
</div>
);
}
export function getYachtTabs({
yachtId,
// currentUserId reserved for when NotesList supports entityType='yachts'.
currentUserId: _currentUserId,
yacht,
}: YachtTabsOptions): DetailTab[] {
void _currentUserId;
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab yacht={yacht} />,
content: <OverviewTab yachtId={yachtId} yacht={yacht} />,
},
{
id: 'ownership-history',
@@ -146,23 +231,7 @@ export function getYachtTabs({
{
id: 'notes',
label: 'Notes',
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
// Extend NotesList (or swap to a yacht-notes endpoint) in a follow-up.
content: (
<EmptyState
title="Notes"
description="Yacht notes coming soon — the notes endpoint is pending wiring."
/>
),
},
{
id: 'tags',
label: 'Tags',
// TODO: replace with an inline tag editor once one exists; yacht tags
// can be edited via the Edit form in the meantime.
content: (
<EmptyState title="Tags" description="Manage tags from the Edit yacht form for now." />
),
content: <NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />,
},
];
}

View File

@@ -6,15 +6,44 @@ export interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
}
/** In-memory cache: slug -> id, populated lazily by `resolvePortIdFromSlug`.
* Avoids re-fetching `/api/v1/admin/ports` on every request when the Zustand
* store hasn't hydrated yet (fresh browser context, e2e tests, hard reload). */
const slugToIdCache = new Map<string, string>();
async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
const cached = slugToIdCache.get(slug);
if (cached) return cached;
try {
const res = await fetch('/api/v1/admin/ports', { credentials: 'include' });
if (!res.ok) return null;
const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> };
const port = body.data?.find((p) => p.slug === slug);
if (!port) return null;
slugToIdCache.set(slug, port.id);
return port.id;
} catch {
return null;
}
}
/**
* Client-side fetch wrapper that attaches the `X-Port-Id` header from the
* UI store to every request. Used by all queryFn/mutationFn callbacks.
*
* Falls back to extracting the port slug from `window.location.pathname` and
* resolving it via `/api/v1/admin/ports` when the Zustand store hasn't been
* populated yet (fresh page load before `PortProvider`'s effect has fired).
*/
export async function apiFetch<T = unknown>(
url: string,
opts: ApiFetchOptions = {},
): Promise<T> {
const portId = useUIStore.getState().currentPortId;
export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
let portId = useUIStore.getState().currentPortId;
if (!portId && typeof window !== 'undefined') {
const slug = window.location.pathname.split('/').filter(Boolean)[0];
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
portId = await resolvePortIdFromSlug(slug);
}
}
const headers = new Headers(opts.headers);
if (portId) {

View File

@@ -156,6 +156,23 @@ export function withAuth(
override.permissionOverrides as Record<string, unknown>,
) as RolePermissions;
}
// Per-user residential toggle — flips the residential domain on
// top of whatever the role grants. We never use it to *revoke*
// residential access from a role that already grants it.
if (portRole.residentialAccess && permissions) {
permissions = {
...permissions,
residential_clients: { view: true, create: true, edit: true, delete: true },
residential_interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
},
};
}
} else if (profile.isSuperAdmin && portId) {
// Super admin still needs portSlug for response context.
const port = await db.query.ports.findFirst({

View File

@@ -16,7 +16,7 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
minPasswordLength: 12,
minPasswordLength: 9,
// Accounts are admin-created only — no self-service email verification flow.
requireEmailVerification: false,
},

View File

@@ -0,0 +1,13 @@
CREATE TABLE "crm_user_invites" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"name" text,
"token_hash" text NOT NULL,
"is_super_admin" boolean DEFAULT false NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "idx_crm_invites_token_hash" ON "crm_user_invites" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "idx_crm_invites_email" ON "crm_user_invites" USING btree ("email");

View File

@@ -0,0 +1,43 @@
CREATE TABLE "residential_clients" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"full_name" text NOT NULL,
"email" text,
"phone" text,
"place_of_residence" text,
"preferred_contact_method" text,
"status" text DEFAULT 'prospect' NOT NULL,
"source" text,
"notes" text,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "residential_interests" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"residential_client_id" text NOT NULL,
"pipeline_stage" text DEFAULT 'new' NOT NULL,
"source" text,
"notes" text,
"preferences" text,
"assigned_to" text,
"date_first_contact" timestamp with time zone,
"date_last_contact" timestamp with time zone,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "residential_clients" ADD CONSTRAINT "residential_clients_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "residential_interests" ADD CONSTRAINT "residential_interests_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "residential_interests" ADD CONSTRAINT "residential_interests_residential_client_id_residential_clients_id_fk" FOREIGN KEY ("residential_client_id") REFERENCES "public"."residential_clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_residential_clients_port" ON "residential_clients" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_residential_clients_email" ON "residential_clients" USING btree ("email");--> statement-breakpoint
CREATE INDEX "idx_residential_clients_archived" ON "residential_clients" USING btree ("port_id","archived_at");--> statement-breakpoint
CREATE INDEX "idx_residential_interests_port" ON "residential_interests" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_residential_interests_client" ON "residential_interests" USING btree ("residential_client_id");--> statement-breakpoint
CREATE INDEX "idx_residential_interests_stage" ON "residential_interests" USING btree ("port_id","pipeline_stage");--> statement-breakpoint
CREATE INDEX "idx_residential_interests_assigned" ON "residential_interests" USING btree ("assigned_to");--> statement-breakpoint
CREATE INDEX "idx_residential_interests_archived" ON "residential_interests" USING btree ("port_id","archived_at");

View File

@@ -0,0 +1 @@
ALTER TABLE "user_port_roles" ADD COLUMN "residential_access" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,27 @@
"when": 1777210206070,
"tag": "0009_outgoing_rumiko_fujikawa",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1777303428222,
"tag": "0010_brave_joshua_kane",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1777307410311,
"tag": "0011_red_cargill",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1777308900666,
"tag": "0012_large_zarda",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,32 @@
import { pgTable, text, boolean, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
/**
* Single-use admin-issued invites for CRM users (better-auth realm).
*
* `tokenHash` is a SHA-256 hash of the raw token sent in the email. Lookups
* happen by hash so a DB compromise never leaks active tokens. The invite
* is consumed at /set-password — the route creates the better-auth `user`
* row + `account` credential and the matching `user_profiles` extension.
*/
export const crmUserInvites = pgTable(
'crm_user_invites',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
email: text('email').notNull(),
name: text('name'),
tokenHash: text('token_hash').notNull(),
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('idx_crm_invites_token_hash').on(table.tokenHash),
index('idx_crm_invites_email').on(table.email),
],
);
export type CrmUserInvite = typeof crmUserInvites.$inferSelect;
export type NewCrmUserInvite = typeof crmUserInvites.$inferInsert;

View File

@@ -34,6 +34,13 @@ export * from './email';
// Portal (client-portal auth)
export * from './portal';
// CRM admin invites (better-auth realm)
export * from './crm-invites';
// Residential (parallel domain — separate clients & interests for the
// external residential team)
export * from './residential';
// Operations
export * from './operations';

View File

@@ -86,6 +86,7 @@ import {
customFieldDefinitions,
customFieldValues,
} from './system';
import { residentialClients, residentialInterests } from './residential';
// ─── Ports ────────────────────────────────────────────────────────────────────
@@ -116,6 +117,8 @@ export const portsRelations = relations(ports, ({ many }) => ({
savedViews: many(savedViews),
userNotificationPreferences: many(userNotificationPreferences),
customFieldDefinitions: many(customFieldDefinitions),
residentialClients: many(residentialClients),
residentialInterests: many(residentialInterests),
berthMaintenanceLogs: many(berthMaintenanceLog),
clientMergeLogs: many(clientMergeLog),
clientRelationships: many(clientRelationships),
@@ -819,3 +822,24 @@ export const customFieldValuesRelations = relations(customFieldValues, ({ one })
references: [customFieldDefinitions.id],
}),
}));
// ─── Residential ──────────────────────────────────────────────────────────────
export const residentialClientsRelations = relations(residentialClients, ({ one, many }) => ({
port: one(ports, {
fields: [residentialClients.portId],
references: [ports.id],
}),
interests: many(residentialInterests),
}));
export const residentialInterestsRelations = relations(residentialInterests, ({ one }) => ({
port: one(ports, {
fields: [residentialInterests.portId],
references: [ports.id],
}),
client: one(residentialClients, {
fields: [residentialInterests.residentialClientId],
references: [residentialClients.id],
}),
}));

View File

@@ -0,0 +1,94 @@
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
import { ports } from './ports';
/**
* Residential clients — physically separated from `clients` because the
* residential side is handled by an external team that should never see
* marina-side data, and vice versa. The two domains share a port but no
* tables, so the access boundary is enforced at the schema level.
*/
export const residentialClients = pgTable(
'residential_clients',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
fullName: text('full_name').notNull(),
email: text('email'),
phone: text('phone'),
placeOfResidence: text('place_of_residence'),
preferredContactMethod: text('preferred_contact_method'), // email | phone
/**
* Lifecycle: prospect | active | inactive. Distinct from
* pipeline_stage on residential_interests (which is per-inquiry).
*/
status: text('status').notNull().default('prospect'),
source: text('source'), // website | manual | referral | broker
notes: text('notes'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_residential_clients_port').on(table.portId),
index('idx_residential_clients_email').on(table.email),
index('idx_residential_clients_archived').on(table.portId, table.archivedAt),
],
);
/**
* Residential interests — one per inquiry/lead. A residential_client can
* have multiple interests over time (e.g. inquired about a unit in 2025,
* came back about a different unit in 2026).
*
* Pipeline stages: new | contacted | viewing_scheduled | offer_made |
* offer_accepted | closed_won | closed_lost.
*/
export const residentialInterests = pgTable(
'residential_interests',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
residentialClientId: text('residential_client_id')
.notNull()
.references(() => residentialClients.id, { onDelete: 'cascade' }),
pipelineStage: text('pipeline_stage').notNull().default('new'),
source: text('source'), // website | manual | referral | broker
notes: text('notes'),
/**
* Free-text capture of unit-type / size / floor / budget preferences —
* residential leads are exploratory and the external team uses notes
* heavily. Schema can grow into structured columns later if needed.
*/
preferences: text('preferences'),
/**
* better-auth user id of the residential team member working this lead.
*/
assignedTo: text('assigned_to'),
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_residential_interests_port').on(table.portId),
index('idx_residential_interests_client').on(table.residentialClientId),
index('idx_residential_interests_stage').on(table.portId, table.pipelineStage),
index('idx_residential_interests_assigned').on(table.assignedTo),
index('idx_residential_interests_archived').on(table.portId, table.archivedAt),
],
);
export type ResidentialClient = typeof residentialClients.$inferSelect;
export type NewResidentialClient = typeof residentialClients.$inferInsert;
export type ResidentialInterest = typeof residentialInterests.$inferSelect;
export type NewResidentialInterest = typeof residentialInterests.$inferInsert;

View File

@@ -118,6 +118,19 @@ export type RolePermissions = {
manage_tags: boolean;
system_backup: boolean;
};
residential_clients: {
view: boolean;
create: boolean;
edit: boolean;
delete: boolean;
};
residential_interests: {
view: boolean;
create: boolean;
edit: boolean;
delete: boolean;
change_stage: boolean;
};
};
export type UserPreferences = {
@@ -251,6 +264,13 @@ export const userPortRoles = pgTable(
roleId: text('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
/**
* Per-user per-port toggle that grants full residential domain access
* (residential_clients.* and residential_interests.*) on top of the
* user's primary role. Lets admins flip residential access for sales
* staff individually without minting a second role.
*/
residentialAccess: boolean('residential_access').notNull().default(false),
assignedBy: text('assigned_by'), // user ID of who assigned this
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},

View File

@@ -91,6 +91,14 @@ const ALL_PERMISSIONS: RolePermissions = {
manage_tags: true,
system_backup: true,
},
residential_clients: { view: true, create: true, edit: true, delete: true },
residential_interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
},
};
const DIRECTOR_PERMISSIONS: RolePermissions = {
@@ -157,6 +165,14 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
manage_tags: true,
system_backup: false,
},
residential_clients: { view: true, create: true, edit: true, delete: true },
residential_interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
},
};
const SALES_MANAGER_PERMISSIONS: RolePermissions = {
@@ -223,6 +239,14 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
manage_tags: true,
system_backup: false,
},
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
const SALES_AGENT_PERMISSIONS: RolePermissions = {
@@ -289,6 +313,14 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
manage_tags: true,
system_backup: false,
},
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
const VIEWER_PERMISSIONS: RolePermissions = {
@@ -355,6 +387,14 @@ const VIEWER_PERMISSIONS: RolePermissions = {
manage_tags: false,
system_backup: false,
},
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
// ─── Port Definitions ────────────────────────────────────────────────────────

View File

@@ -0,0 +1,101 @@
interface InviteData {
link: string;
ttlHours: number;
recipientName?: string;
isSuperAdmin: boolean;
}
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export function crmInviteEmail(data: InviteData): {
subject: string;
html: string;
text: string;
} {
const subject = `You're invited to the Port Nimara CRM`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to the Port Nimara CRM
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
You've been invited to the Port Nimara CRM as a ${role}. Click the
button below to set your password and activate your account. The
link expires in ${data.ttlHours} hours.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Set up your account
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>Port Nimara CRM</strong>
</p>`;
const text = [
`Welcome to the Port Nimara CRM`,
'',
`You've been invited as a ${role}.`,
`Set up your account: ${data.link}`,
'',
`The link expires in ${data.ttlHours} hours.`,
'',
`Thank you,`,
`Port Nimara CRM`,
].join('\n');
return { subject, html: shell({ title: subject, body }), text };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -0,0 +1,107 @@
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export interface ResidentialClientConfirmationData {
firstName: string;
contactEmail: string;
}
export function residentialClientConfirmation(data: ResidentialClientConfirmationData) {
const subject = 'Thank You for Your Interest — Port Nimara Residences';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to Port Nimara
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
Dear ${escapeHtml(data.firstName)},
</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
Thank you for expressing interest in Port Nimara residences. Our residential
sales team has received your inquiry and will reach out to you shortly with
more information.
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
If you have any questions in the meantime, please reach us at
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
</p>
<p style="font-size:16px; margin-top:30px;">
Best regards,<br />
<strong>The Port Nimara Residential Team</strong>
</p>`;
return { subject, html: shell({ title: subject, body }) };
}
export interface ResidentialSalesAlertData {
fullName: string;
email: string;
phone: string;
placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone';
notes?: string;
preferences?: string;
crmDeepLink?: string;
}
export function residentialSalesAlert(data: ResidentialSalesAlertData) {
const subject = `New Residential Inquiry — ${data.fullName}`;
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
New residential inquiry
</p>
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
<tr><td style="color:#666; width:140px;">Name</td><td>${escapeHtml(data.fullName)}</td></tr>
<tr><td style="color:#666;">Email</td><td>${escapeHtml(data.email)}</td></tr>
<tr><td style="color:#666;">Phone</td><td>${escapeHtml(data.phone)}</td></tr>
${data.placeOfResidence ? `<tr><td style="color:#666;">Residence</td><td>${escapeHtml(data.placeOfResidence)}</td></tr>` : ''}
${data.preferredContactMethod ? `<tr><td style="color:#666;">Prefers</td><td>${escapeHtml(data.preferredContactMethod)}</td></tr>` : ''}
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
</table>
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
<p style="font-size:14px; color:#666;">— Port Nimara CRM</p>`;
return { subject, html: shell({ title: subject, body }) };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -1,4 +1,4 @@
import { and, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
@@ -8,6 +8,7 @@ import { berthReservations } from '@/lib/db/schema/reservations';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
@@ -59,7 +60,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
if (sort === 'fullName') sortColumn = clients.fullName;
else if (sort === 'createdAt') sortColumn = clients.createdAt;
const result = await buildListQuery({
const result = await buildListQuery<typeof clients.$inferSelect>({
table: clients,
portIdColumn: clients.portId,
portId,
@@ -75,7 +76,41 @@ export async function listClients(portId: string, query: ListClientsInput) {
archivedAtColumn: clients.archivedAt,
});
return result;
if (result.data.length === 0) return result;
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts] = await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
inArray(yachts.currentOwnerId, ids),
isNull(yachts.archivedAt),
),
)
.groupBy(yachts.currentOwnerId),
db
.select({ clientId: companyMemberships.clientId, count: count() })
.from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId),
]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
return {
...result,
data: result.data.map((row) => ({
...row,
yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0,
})),
};
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
@@ -157,6 +192,8 @@ export async function getClientById(id: string, portId: string) {
},
});
const portalEnabled = await isPortalEnabledForPort(portId);
return {
...client,
contacts,
@@ -164,6 +201,7 @@ export async function getClientById(id: string, portId: string) {
yachts: yachtRows,
companies: membershipRows,
activeReservations,
clientPortalEnabled: portalEnabled,
};
}

View File

@@ -106,9 +106,18 @@ export async function createCompany(portId: string, data: CreateCompanyInput, me
export async function getCompanyById(id: string, portId: string) {
const company = await db.query.companies.findFirst({
where: and(eq(companies.id, id), eq(companies.portId, portId)),
with: {
tags: { with: { tag: true } },
},
});
if (!company) throw new NotFoundError('Company');
return company;
const { tags: tagJoins, ...rest } = company as typeof company & {
tags: Array<{ tag: { id: string; name: string; color: string } }>;
};
return {
...rest,
tags: tagJoins.map((t) => t.tag),
};
}
// ─── Update ──────────────────────────────────────────────────────────────────
@@ -297,3 +306,32 @@ export async function upsertByName(portId: string, name: string, meta: AuditMeta
}
});
}
export async function setCompanyTags(
companyId: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) });
if (!company || company.portId !== portId) throw new NotFoundError('Company');
await db.delete(companyTags).where(eq(companyTags.companyId, companyId));
if (tagIds.length > 0) {
await db.insert(companyTags).values(tagIds.map((tagId) => ({ companyId, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'company',
entityId: companyId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['tags'] });
}

View File

@@ -0,0 +1,118 @@
import { and, eq, gt, isNull } from 'drizzle-orm';
import postgres from 'postgres';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { crmUserInvites } from '@/lib/db/schema/crm-invites';
import { userProfiles } from '@/lib/db/schema/users';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { hashToken, mintToken } from '@/lib/portal/passwords';
const INVITE_TTL_HOURS = 72;
const MIN_PASSWORD_LENGTH = 9;
export async function createCrmInvite(args: {
email: string;
name?: string;
isSuperAdmin?: boolean;
}): Promise<{ inviteId: string; link: string }> {
const email = args.email.toLowerCase().trim();
const isSuperAdmin = args.isSuperAdmin ?? false;
// Reject if there's already a better-auth user with this email — they
// should reset their password instead.
const sql = postgres(env.DATABASE_URL);
try {
const existing = await sql<{ id: string }[]>`
SELECT id FROM "user" WHERE email = ${email} LIMIT 1
`;
if (existing.length > 0) {
throw new ConflictError(`A CRM user already exists for ${email}`);
}
} finally {
await sql.end();
}
const { raw, hash } = mintToken();
const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000);
const [row] = await db
.insert(crmUserInvites)
.values({
email,
name: args.name ?? null,
tokenHash: hash,
isSuperAdmin,
expiresAt,
})
.returning({ id: crmUserInvites.id });
if (!row) throw new Error('Failed to create CRM invite');
const link = `${env.APP_URL}/set-password?token=${raw}`;
const { subject, html, text } = crmInviteEmail({
link,
ttlHours: INVITE_TTL_HOURS,
recipientName: args.name,
isSuperAdmin,
});
await sendEmail(email, subject, html, undefined, text);
return { inviteId: row.id, link };
}
export async function consumeCrmInvite(args: {
token: string;
password: string;
}): Promise<{ userId: string; email: string }> {
if (args.password.length < MIN_PASSWORD_LENGTH) {
throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const tokenHash = hashToken(args.token);
const invite = await db.query.crmUserInvites.findFirst({
where: and(
eq(crmUserInvites.tokenHash, tokenHash),
isNull(crmUserInvites.usedAt),
gt(crmUserInvites.expiresAt, new Date()),
),
});
if (!invite) {
throw new NotFoundError('Invite link is invalid or has expired');
}
// Create the better-auth user with the chosen password.
const result = await auth.api.signUpEmail({
body: {
email: invite.email,
password: args.password,
name: invite.name ?? invite.email.split('@')[0] ?? 'User',
},
});
const userId = result.user.id;
// Create the matching user_profiles extension row.
await db
.insert(userProfiles)
.values({
id: crypto.randomUUID(),
userId,
displayName: invite.name ?? invite.email,
isSuperAdmin: invite.isSuperAdmin,
isActive: true,
preferences: {},
})
.onConflictDoNothing();
await db
.update(crmUserInvites)
.set({ usedAt: new Date() })
.where(eq(crmUserInvites.id, invite.id));
return { userId, email: invite.email };
}

View File

@@ -0,0 +1,121 @@
import { and, desc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { formTemplates } from '@/lib/db/schema/documents';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import type {
CreateFormTemplateInput,
UpdateFormTemplateInput,
} from '@/lib/validators/form-templates';
interface AuditMeta {
userId: string;
portId: string;
ipAddress?: string;
userAgent?: string;
}
export async function listFormTemplates(portId: string) {
return db
.select()
.from(formTemplates)
.where(eq(formTemplates.portId, portId))
.orderBy(desc(formTemplates.updatedAt));
}
export async function getFormTemplateById(id: string, portId: string) {
const tpl = await db.query.formTemplates.findFirst({
where: and(eq(formTemplates.id, id), eq(formTemplates.portId, portId)),
});
if (!tpl) throw new NotFoundError('Form template');
return tpl;
}
export async function createFormTemplate(
portId: string,
data: CreateFormTemplateInput,
meta: AuditMeta,
) {
const [tpl] = await db
.insert(formTemplates)
.values({
portId,
name: data.name,
description: data.description ?? null,
fields: data.fields,
branding: data.branding ?? {},
isActive: data.isActive ?? true,
createdBy: meta.userId,
})
.returning();
if (!tpl) throw new Error('Insert failed');
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'form_template',
entityId: tpl.id,
newValue: { name: data.name },
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
return tpl;
}
export async function updateFormTemplate(
id: string,
portId: string,
data: UpdateFormTemplateInput,
meta: AuditMeta,
) {
const existing = await getFormTemplateById(id, portId);
const [updated] = await db
.update(formTemplates)
.set({
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description ?? null }),
...(data.fields !== undefined && { fields: data.fields }),
...(data.branding !== undefined && { branding: data.branding }),
...(data.isActive !== undefined && { isActive: data.isActive }),
updatedAt: new Date(),
})
.where(eq(formTemplates.id, id))
.returning();
if (!updated) throw new NotFoundError('Form template');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'form_template',
entityId: id,
oldValue: { name: existing.name },
newValue: data,
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
return updated;
}
export async function deleteFormTemplate(id: string, portId: string, meta: AuditMeta) {
await getFormTemplateById(id, portId);
await db.delete(formTemplates).where(eq(formTemplates.id, id));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'form_template',
entityId: id,
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
}

View File

@@ -3,13 +3,15 @@ import { eq, and, desc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies } from '@/lib/db/schema/companies';
import { userProfiles } from '@/lib/db/schema/users';
import { NotFoundError, ValidationError } from '@/lib/errors';
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
type EntityType = 'clients' | 'interests';
type EntityType = 'clients' | 'interests' | 'yachts' | 'companies';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -19,33 +21,43 @@ async function verifyParentBelongsToPort(
portId: string,
): Promise<void> {
if (entityType === 'clients') {
const client = await db
const r = await db
.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, entityId), eq(clients.portId, portId)))
.limit(1);
if (!client.length) throw new NotFoundError('Client');
} else {
const interest = await db
if (!r.length) throw new NotFoundError('Client');
} else if (entityType === 'interests') {
const r = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.id, entityId), eq(interests.portId, portId)))
.limit(1);
if (!interest.length) throw new NotFoundError('Interest');
if (!r.length) throw new NotFoundError('Interest');
} else if (entityType === 'yachts') {
const r = await db
.select({ id: yachts.id })
.from(yachts)
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Yacht');
} else {
const r = await db
.select({ id: companies.id })
.from(companies)
.where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Company');
}
}
// ─── Service ─────────────────────────────────────────────────────────────────
export async function listForEntity(
portId: string,
entityType: EntityType,
entityId: string,
) {
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'clients') {
const rows = await db
return db
.select({
id: clientNotes.id,
clientId: clientNotes.clientId,
@@ -61,9 +73,8 @@ export async function listForEntity(
.leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
.where(eq(clientNotes.clientId, entityId))
.orderBy(desc(clientNotes.createdAt));
return rows;
} else {
const rows = await db
} else if (entityType === 'interests') {
return db
.select({
id: interestNotes.id,
interestId: interestNotes.interestId,
@@ -79,7 +90,40 @@ export async function listForEntity(
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
.where(eq(interestNotes.interestId, entityId))
.orderBy(desc(interestNotes.createdAt));
return rows;
} else if (entityType === 'yachts') {
return db
.select({
id: yachtNotes.id,
yachtId: yachtNotes.yachtId,
authorId: yachtNotes.authorId,
content: yachtNotes.content,
mentions: yachtNotes.mentions,
isLocked: yachtNotes.isLocked,
createdAt: yachtNotes.createdAt,
updatedAt: yachtNotes.updatedAt,
authorName: userProfiles.displayName,
})
.from(yachtNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(eq(yachtNotes.yachtId, entityId))
.orderBy(desc(yachtNotes.createdAt));
} else {
return db
.select({
id: companyNotes.id,
companyId: companyNotes.companyId,
authorId: companyNotes.authorId,
content: companyNotes.content,
mentions: companyNotes.mentions,
isLocked: companyNotes.isLocked,
createdAt: companyNotes.createdAt,
updatedAt: companyNotes.createdAt,
authorName: userProfiles.displayName,
})
.from(companyNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
.where(eq(companyNotes.companyId, entityId))
.orderBy(desc(companyNotes.createdAt));
}
}
@@ -92,6 +136,32 @@ export async function create(
) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'yachts') {
const [note] = await db
.insert(yachtNotes)
.values({ yachtId: entityId, authorId, content: data.content })
.returning();
if (!note) throw new Error('Insert failed');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'companies') {
const [note] = await db
.insert(companyNotes)
.values({ companyId: entityId, authorId, content: data.content })
.returning();
if (!note) throw new Error('Insert failed');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null, updatedAt: note.createdAt };
}
if (entityType === 'clients') {
const [note] = await db
.insert(clientNotes)
@@ -165,6 +235,7 @@ export async function create(
return { ...note, authorName };
}
throw new Error(`Unsupported entityType: ${entityType as string}`);
}
export async function update(
@@ -176,6 +247,56 @@ export async function update(
) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'yachts') {
const [existing] = await db
.select()
.from(yachtNotes)
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(yachtNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(yachtNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'companies') {
const [existing] = await db
.select()
.from(companyNotes)
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(companyNotes)
.set({ content: data.content })
.where(eq(companyNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return {
...updated,
authorName: profile[0]?.displayName ?? null,
updatedAt: updated.createdAt,
};
}
if (entityType === 'clients') {
const [existing] = await db
.select()
@@ -241,6 +362,32 @@ export async function deleteNote(
) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'yachts') {
const [existing] = await db
.select()
.from(yachtNotes)
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(yachtNotes).where(eq(yachtNotes.id, noteId));
return existing;
}
if (entityType === 'companies') {
const [existing] = await db
.select()
.from(companyNotes)
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(companyNotes).where(eq(companyNotes.id, noteId));
return existing;
}
if (entityType === 'clients') {
const [existing] = await db
.select()

View File

@@ -4,6 +4,7 @@ import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
import { portalAuthTokens, portalUsers } from '@/lib/db/schema/portal';
import { systemSettings } from '@/lib/db/schema/system';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
@@ -15,6 +16,19 @@ import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal
const ACTIVATION_TOKEN_TTL_HOURS = 72;
const RESET_TOKEN_TTL_MINUTES = 30;
const MIN_PASSWORD_LENGTH = 9;
const PORTAL_ENABLED_KEY = 'client_portal_enabled';
/**
* Per-port toggle for the client portal feature. Default-on so existing
* deployments behave the way they did before this setting existed.
*/
export async function isPortalEnabledForPort(portId: string): Promise<boolean> {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, PORTAL_ENABLED_KEY), eq(systemSettings.portId, portId)),
});
if (!row) return true;
return row.value === true || row.value === 'true';
}
// ─── Admin-side: invite a client to the portal ───────────────────────────────
@@ -32,6 +46,10 @@ export async function createPortalUser(args: {
});
if (!client) throw new NotFoundError('Client');
if (!(await isPortalEnabledForPort(args.portId))) {
throw new ConflictError('Client portal is disabled for this port');
}
// Email uniqueness check is enforced at the DB level too, but we do a
// friendlier preflight so the admin sees a clear conflict error.
const existing = await db.query.portalUsers.findFirst({
@@ -96,6 +114,9 @@ async function issueActivationToken(
}
export async function resendActivation(portalUserId: string, portId: string): Promise<void> {
if (!(await isPortalEnabledForPort(portId))) {
throw new ConflictError('Client portal is disabled for this port');
}
const user = await db.query.portalUsers.findFirst({
where: and(eq(portalUsers.id, portalUserId), eq(portalUsers.portId, portId)),
});
@@ -113,6 +134,13 @@ export async function activateAccount(rawToken: string, password: string): Promi
throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const tokenRow = await consumeToken(rawToken, 'activation');
const portalUser = await db.query.portalUsers.findFirst({
where: eq(portalUsers.id, tokenRow.portalUserId),
});
if (!portalUser) throw new ValidationError('Invalid or expired token');
if (!(await isPortalEnabledForPort(portalUser.portId))) {
throw new ValidationError('Client portal is disabled for this port');
}
const passwordHash = await hashPassword(password);
await db
.update(portalUsers)
@@ -147,6 +175,13 @@ export async function signIn(args: {
throw new UnauthorizedError('Invalid email or password');
}
// Disabled-port check happens AFTER the credential check so that a wrong
// password on a disabled-port account still surfaces "invalid email or
// password" — we never leak which ports have the portal turned off.
if (!(await isPortalEnabledForPort(user.portId))) {
throw new UnauthorizedError('Invalid email or password');
}
const token = await createPortalToken({
clientId: user.clientId,
portId: user.portId,
@@ -174,6 +209,13 @@ export async function requestPasswordReset(email: string): Promise<void> {
return;
}
// Same silent no-op when the port has the portal disabled — keeps the
// disabled-state from leaking through the public reset endpoint.
if (!(await isPortalEnabledForPort(user.portId))) {
logger.debug({ portId: user.portId }, 'Password reset on disabled-portal port');
return;
}
const { raw, hash } = mintToken();
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000);
@@ -206,6 +248,13 @@ export async function resetPassword(rawToken: string, password: string): Promise
throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const tokenRow = await consumeToken(rawToken, 'reset');
const portalUser = await db.query.portalUsers.findFirst({
where: eq(portalUsers.id, tokenRow.portalUserId),
});
if (!portalUser) throw new ValidationError('Invalid or expired token');
if (!(await isPortalEnabledForPort(portalUser.portId))) {
throw new ValidationError('Client portal is disabled for this port');
}
const passwordHash = await hashPassword(password);
await db
.update(portalUsers)

View File

@@ -0,0 +1,328 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore } from '@/lib/db/utils';
import type {
CreateResidentialClientInput,
CreateResidentialInterestInput,
ListResidentialClientsInput,
ListResidentialInterestsInput,
UpdateResidentialClientInput,
UpdateResidentialInterestInput,
} from '@/lib/validators/residential';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Residential clients ─────────────────────────────────────────────────────
export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {
const { page, limit, sort, order, search, includeArchived, status, source } = query;
const filters = [];
if (status) filters.push(eq(residentialClients.status, status));
if (source) filters.push(eq(residentialClients.source, source));
return buildListQuery({
table: residentialClients,
portIdColumn: residentialClients.portId,
portId,
idColumn: residentialClients.id,
updatedAtColumn: residentialClients.updatedAt,
filters,
sort: sort
? {
column:
(residentialClients[sort as keyof typeof residentialClients] as never) ??
residentialClients.updatedAt,
direction: order ?? 'desc',
}
: undefined,
page,
pageSize: limit,
searchColumns: [
residentialClients.fullName,
residentialClients.email,
residentialClients.phone,
residentialClients.placeOfResidence,
],
searchTerm: search,
includeArchived,
archivedAtColumn: residentialClients.archivedAt,
});
}
export async function getResidentialClientById(id: string, portId: string) {
const client = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!client) throw new NotFoundError('Residential client');
const interests = await db.query.residentialInterests.findMany({
where: eq(residentialInterests.residentialClientId, id),
orderBy: (t, { desc }) => [desc(t.updatedAt)],
});
return { ...client, interests };
}
export async function createResidentialClient(
portId: string,
data: CreateResidentialClientInput,
meta: AuditMeta,
) {
const [row] = await db
.insert(residentialClients)
.values({ portId, ...data })
.returning();
if (!row) throw new Error('Failed to create residential client');
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'residential_client',
entityId: row.id,
newValue: { fullName: row.fullName, email: row.email ?? undefined },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id });
return row;
}
export async function updateResidentialClient(
id: string,
portId: string,
data: UpdateResidentialClientInput,
meta: AuditMeta,
) {
const before = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!before) throw new NotFoundError('Residential client');
const [updated] = await db
.update(residentialClients)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Residential client');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'residential_client',
entityId: id,
oldValue: diffEntity(before, updated) as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:updated', { id });
return updated;
}
export async function archiveResidentialClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential client');
await softDelete(residentialClients, residentialClients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'residential_client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:archived', { id });
}
export async function restoreResidentialClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential client');
await restore(residentialClients, residentialClients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'residential_client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:restored', { id });
}
// ─── Residential interests ───────────────────────────────────────────────────
export async function listResidentialInterests(
portId: string,
query: ListResidentialInterestsInput,
) {
const {
page,
limit,
sort,
order,
search,
includeArchived,
pipelineStage,
assignedTo,
residentialClientId,
} = query;
const filters = [];
if (pipelineStage) filters.push(eq(residentialInterests.pipelineStage, pipelineStage));
if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo));
if (residentialClientId)
filters.push(eq(residentialInterests.residentialClientId, residentialClientId));
return buildListQuery({
table: residentialInterests,
portIdColumn: residentialInterests.portId,
portId,
idColumn: residentialInterests.id,
updatedAtColumn: residentialInterests.updatedAt,
filters,
sort: sort
? {
column:
(residentialInterests[sort as keyof typeof residentialInterests] as never) ??
residentialInterests.updatedAt,
direction: order ?? 'desc',
}
: undefined,
page,
pageSize: limit,
searchColumns: [residentialInterests.notes, residentialInterests.preferences],
searchTerm: search,
includeArchived,
archivedAtColumn: residentialInterests.archivedAt,
});
}
export async function getResidentialInterestById(id: string, portId: string) {
const interest = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!interest) throw new NotFoundError('Residential interest');
const client = await db.query.residentialClients.findFirst({
where: eq(residentialClients.id, interest.residentialClientId),
});
return { ...interest, client };
}
export async function createResidentialInterest(
portId: string,
data: CreateResidentialInterestInput,
meta: AuditMeta,
) {
// Validate the residential client belongs to this port — prevents
// cross-port linking.
const client = await db.query.residentialClients.findFirst({
where: and(
eq(residentialClients.id, data.residentialClientId),
eq(residentialClients.portId, portId),
),
});
if (!client) throw new NotFoundError('Residential client');
const [row] = await db
.insert(residentialInterests)
.values({ portId, ...data })
.returning();
if (!row) throw new Error('Failed to create residential interest');
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'residential_interest',
entityId: row.id,
newValue: { residentialClientId: row.residentialClientId, pipelineStage: row.pipelineStage },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id });
return row;
}
export async function updateResidentialInterest(
id: string,
portId: string,
data: UpdateResidentialInterestInput,
meta: AuditMeta,
) {
const before = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!before) throw new NotFoundError('Residential interest');
const [updated] = await db
.update(residentialInterests)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Residential interest');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'residential_interest',
entityId: id,
oldValue: diffEntity(before, updated) as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:updated', { id });
return updated;
}
export async function archiveResidentialInterest(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential interest');
await softDelete(residentialInterests, residentialInterests.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'residential_interest',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:archived', { id });
}

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