Compare commits
7 Commits
c612bbdfd9
...
8699f81879
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8699f81879 | ||
|
|
d62822c284 | ||
|
|
089f4a67a4 | ||
|
|
77ad10ced1 | ||
|
|
e598cc0708 | ||
|
|
f5772ce318 | ||
|
|
49d34e00c8 |
135
scripts/backfill-legacy-lead-source.ts
Normal file
135
scripts/backfill-legacy-lead-source.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
|
||||
*
|
||||
* Why this exists: the legacy NocoDB Interests table left the `Source`
|
||||
* column null for ~95 % of rows. The migration mapped null → null, so the
|
||||
* Lead Source Attribution chart shows them as "Unspecified". Per the
|
||||
* operator's best knowledge, almost all of those legacy rows came in
|
||||
* through the website (web form / portal) — the few that didn't are the
|
||||
* ones that already carry an explicit `Source` value (Form / portal /
|
||||
* External). Defaulting null → 'website' is therefore the closest
|
||||
* truth we can reconstruct without per-row sales notes review.
|
||||
*
|
||||
* Idempotent: only updates rows where `source IS NULL` AND the row has a
|
||||
* `migration_source_links` entry tying it back to the legacy NocoDB import,
|
||||
* so net-new manually-created interests with null source aren't touched.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, and, isNull, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||
|
||||
interface CliArgs {
|
||||
portSlug: string | null;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = { portSlug: null, dryRun: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
console.log(
|
||||
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (!args.portSlug) {
|
||||
console.error('Missing required --port-slug');
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, name: ports.name })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, args.portSlug!))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
console.error(`No port found with slug "${args.portSlug}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[backfill] target: ${port.name} (${port.id})`);
|
||||
|
||||
// Pull every interest id this port owns that has a NULL source.
|
||||
const candidateInterests = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
|
||||
|
||||
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
|
||||
|
||||
if (candidateInterests.length === 0) {
|
||||
console.log('Nothing to backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to ONLY those that came in via the legacy migration — preserves
|
||||
// null on net-new rows where the operator hasn't picked a source yet.
|
||||
const candidateIds = candidateInterests.map((r) => r.id);
|
||||
const legacyLinks = await db
|
||||
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
|
||||
eq(migrationSourceLinks.targetEntityType, 'interest'),
|
||||
inArray(migrationSourceLinks.targetEntityId, candidateIds),
|
||||
),
|
||||
);
|
||||
|
||||
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
|
||||
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
|
||||
|
||||
console.log(
|
||||
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
|
||||
);
|
||||
console.log(
|
||||
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
|
||||
);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('[backfill] --dry-run set; no writes.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toUpdate.length === 0) {
|
||||
console.log('Nothing to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update in chunks of 500 to keep query size sane.
|
||||
const CHUNK = 500;
|
||||
let updated = 0;
|
||||
for (let i = 0; i < toUpdate.length; i += CHUNK) {
|
||||
const chunk = toUpdate.slice(i, i + CHUNK);
|
||||
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
|
||||
// a concurrent process that set source on one of these rows
|
||||
// between SELECT and UPDATE doesn't get its value clobbered.
|
||||
const result = await db
|
||||
.update(interests)
|
||||
.set({ source: 'website' })
|
||||
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
|
||||
.returning({ id: interests.id });
|
||||
updated += result.length;
|
||||
}
|
||||
console.log(`[backfill] updated ${updated} rows.`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -178,6 +178,12 @@ async function main(): Promise<void> {
|
||||
);
|
||||
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
||||
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
||||
console.log(
|
||||
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
|
||||
);
|
||||
console.log(
|
||||
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
|
||||
);
|
||||
console.log(
|
||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||
);
|
||||
@@ -217,6 +223,14 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
|
||||
);
|
||||
console.log(
|
||||
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Signers: ${result.documentSignersInserted} inserted`);
|
||||
console.log(
|
||||
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('');
|
||||
|
||||
@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
label: 'Default signature (HTML)',
|
||||
description: 'Appended to the bottom of system-generated emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p>—<br>The Port Nimara team</p>',
|
||||
placeholder: '<p>-<br>The Port Nimara team</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'smtp_pass_override',
|
||||
label: 'SMTP password override',
|
||||
description: 'Optional. Stored in plain text — only set when overriding env credentials.',
|
||||
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Users,
|
||||
UsersRound,
|
||||
Webhook,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -209,6 +210,12 @@ const GROUPS: AdminGroup[] = [
|
||||
description: 'Configure the AI provider used by the mobile receipt scanner.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
href: 'website-analytics',
|
||||
label: 'Website analytics (Umami)',
|
||||
description: 'Per-port Umami URL, API token, and Website ID.',
|
||||
icon: Globe,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
/**
|
||||
* Per-port Umami credentials. We deliberately keep all three values
|
||||
* port-scoped (per the operator decision) so different ports can point at
|
||||
* different Umami instances if needed. The /website-analytics dashboard
|
||||
* page reads these settings via the umami.service layer at request time.
|
||||
*/
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'umami_api_url',
|
||||
label: 'Umami API URL',
|
||||
description:
|
||||
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
|
||||
type: 'string',
|
||||
placeholder: 'https://analytics.portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_api_token',
|
||||
label: 'API token',
|
||||
description:
|
||||
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_username',
|
||||
label: 'Username',
|
||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
||||
type: 'string',
|
||||
placeholder: 'admin',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_password',
|
||||
label: 'Password',
|
||||
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'umami_website_id',
|
||||
label: 'Website ID',
|
||||
description:
|
||||
'UUID of this port’s website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.',
|
||||
type: 'string',
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WebsiteAnalyticsSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Website analytics (Umami)"
|
||||
description="Connect this port to its Umami website to display traffic, top pages, referrers, and conversion data on the Website Analytics dashboard."
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Umami connection"
|
||||
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
|
||||
fields={FIELDS}
|
||||
extra={<UmamiTestButton />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
/**
|
||||
* Route-level loading UI for the client detail page. Renders while the
|
||||
* server component resolves the session and the client component bootstraps
|
||||
* its initial query — replaces the previous empty-header flash on direct
|
||||
* its initial query - replaces the previous empty-header flash on direct
|
||||
* URL visits.
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header strip — title, badges, action buttons */}
|
||||
{/* Header strip - title, badges, action buttons */}
|
||||
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
|
||||
}, [setChrome]);
|
||||
|
||||
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
|
||||
// fetch enough of the interest to display "Deposit for {client} — Berth {n}" in
|
||||
// fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
|
||||
// the review step. Doubles as the source of truth for the billing entity prefill.
|
||||
const { data: prefilledInterest } = useQuery<{
|
||||
data: {
|
||||
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header — desktop only; mobile gets the title from the topbar */}
|
||||
{/* Header - desktop only; mobile gets the title from the topbar */}
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
|
||||
{prefilledInterest?.data
|
||||
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
||||
prefilledInterest.data.berthMooringNumber
|
||||
? ` — Berth ${prefilledInterest.data.berthMooringNumber}`
|
||||
? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
|
||||
: ''
|
||||
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
|
||||
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'How to upload receipts',
|
||||
};
|
||||
|
||||
export default async function UploadReceiptsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
return <UploadReceiptsGuide portSlug={portSlug} />;
|
||||
}
|
||||
11
src/app/(dashboard)/[portSlug]/website-analytics/page.tsx
Normal file
11
src/app/(dashboard)/[portSlug]/website-analytics/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { WebsiteAnalyticsShell } from '@/components/website-analytics/website-analytics-shell';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website analytics',
|
||||
};
|
||||
|
||||
export default function WebsiteAnalyticsPage() {
|
||||
return <WebsiteAnalyticsShell />;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
<RealtimeToasts />
|
||||
{/* Desktop shell — hidden by CSS on mobile */}
|
||||
{/* Desktop shell - hidden by CSS on mobile */}
|
||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
portRoles={portRoles}
|
||||
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||
email: session.user.email,
|
||||
}}
|
||||
ports={ports}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<Topbar
|
||||
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile shell — hidden by CSS on desktop */}
|
||||
{/* Mobile shell - hidden by CSS on desktop */}
|
||||
<MobileLayout>{children}</MobileLayout>
|
||||
</SocketProvider>
|
||||
</PermissionsProvider>
|
||||
|
||||
@@ -12,14 +12,10 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function PortalLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
// This layout wraps all portal routes including login/verify
|
||||
// We can't easily check pathname in a server layout, so we attempt
|
||||
// to get the session and pass it down — login/verify pages handle their own
|
||||
// to get the session and pass it down - login/verify pages handle their own
|
||||
// redirect logic independently.
|
||||
const session = await getPortalSession().catch(() => null);
|
||||
|
||||
@@ -42,17 +38,11 @@ export default async function PortalLayout({
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{session && (
|
||||
<>
|
||||
<PortalHeader
|
||||
portName={portName}
|
||||
portLogoUrl={portLogoUrl}
|
||||
clientName={clientName}
|
||||
/>
|
||||
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
|
||||
<PortalNav />
|
||||
</>
|
||||
)}
|
||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
|
||||
{children}
|
||||
</main>
|
||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
|
||||
<PasswordSetForm
|
||||
endpoint="/api/portal/auth/activate"
|
||||
title="Activate your account"
|
||||
description="Welcome — choose a password to finish setting up your client portal account."
|
||||
description="Welcome - choose a password to finish setting up your client portal account."
|
||||
successTitle="Account activated"
|
||||
successDescription="You can now sign in with your new password."
|
||||
submitLabel="Activate account"
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
// Always returns 200 — caller never sees whether email exists.
|
||||
// Always returns 200 - caller never sees whether email exists.
|
||||
await fetch('/api/portal/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
|
||||
<span className="font-medium text-gray-900">General Interest</span>
|
||||
)}
|
||||
{interest.berthArea && (
|
||||
<span className="text-sm text-gray-400">— {interest.berthArea}</span>
|
||||
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
|
||||
)}
|
||||
</div>
|
||||
{interest.leadCategory && (
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
||||
{r.berthMooringNumber && (
|
||||
<span className="text-sm text-gray-400">— Berth {r.berthMooringNumber}</span>
|
||||
<span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
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 { QueryProvider } from '@/providers/query-provider';
|
||||
import { PortProvider } from '@/providers/port-provider';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
|
||||
* topbar — the scanner is its own contained surface. Adds the PWA
|
||||
* manifest link + theme color so iOS/Android pick up "Add to Home
|
||||
* Screen". Auth check matches the dashboard layout so unauthorized
|
||||
* users still bounce to /login.
|
||||
* topbar - the scanner is its own contained surface. PWA manifest +
|
||||
* iOS web-app meta tags are emitted via Next.js's metadata/viewport
|
||||
* exports so React doesn't try to render a second `<head>` mid-tree
|
||||
* (which throws hydration errors in the App Router). Auth check
|
||||
* matches the dashboard layout so unauthorized users still bounce.
|
||||
*/
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { portSlug } = await params;
|
||||
return {
|
||||
manifest: `/${portSlug}/scan/manifest.webmanifest`,
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'PN Scanner',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
other: {
|
||||
// Android/Chrome equivalent of the apple-* meta. metadata.appleWebApp
|
||||
// covers iOS only; this preserves the existing PWA hint for Chrome.
|
||||
'mobile-web-app-capable': 'yes',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#3a7bc8',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
export default async function ScannerLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -33,16 +64,7 @@ export default async function ScannerLayout({
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
|
||||
<head>
|
||||
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
|
||||
<meta name="theme-color" content="#3a7bc8" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
<PortProvider ports={[port]} defaultPortId={port.id}>
|
||||
<div className="min-h-[100dvh] bg-background">{children}</div>
|
||||
</PortProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
|
||||
const manifest = {
|
||||
name: `${portName} — Scanner`,
|
||||
name: `${portName} - Scanner`,
|
||||
short_name: 'Scanner',
|
||||
description: `Capture and submit expense receipts for ${portName}.`,
|
||||
start_url: `/${portSlug}/scan`,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
import { ScanShell } from '@/components/scan/scan-shell';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt — Port Nimara',
|
||||
title: 'Scan receipt - Port Nimara',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Liveness probe — confirms the Next.js process is responding.
|
||||
* Liveness probe - confirms the Next.js process is responding.
|
||||
*
|
||||
* Returns 200 unconditionally; if the process is wedged or has crashed
|
||||
* the request never lands here at all. Do NOT include database/Redis/MinIO
|
||||
* checks in this endpoint — a transient downstream blip should drop the
|
||||
* checks in this endpoint - a transient downstream blip should drop the
|
||||
* pod from the load balancer (readiness), not restart the pod (liveness).
|
||||
*
|
||||
* For deep dependency checks, hit `/api/ready` instead.
|
||||
|
||||
@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
||||
// Keep the helper aligned with that.
|
||||
type Tx = typeof db;
|
||||
|
||||
// POST /api/public/interests — unauthenticated public interest registration.
|
||||
// POST /api/public/interests - unauthenticated public interest registration.
|
||||
// Creates the trio (client + yacht + interest) plus an optional company +
|
||||
// membership, all inside a single transaction.
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||
|
||||
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
||||
// Resolve berth by mooring number (if provided). Read-only lookup - safe
|
||||
// to do outside the transaction.
|
||||
let berthId: string | null = null;
|
||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||
|
||||
@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/public/residential-inquiries — unauthenticated entry point for
|
||||
* 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.
|
||||
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
|
||||
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
|
||||
// Send notification emails (non-blocking - failures shouldn't 500 the
|
||||
// public form).
|
||||
void sendResidentialNotifications({
|
||||
portId,
|
||||
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
|
||||
});
|
||||
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
||||
|
||||
// Sales-team alert — pull recipients from system_settings if configured;
|
||||
// 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(
|
||||
|
||||
177
src/app/api/public/website-inquiries/route.ts
Normal file
177
src/app/api/public/website-inquiries/route.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
|
||||
/**
|
||||
* POST /api/public/website-inquiries
|
||||
*
|
||||
* Capture endpoint for the marketing website's dual-write. The website
|
||||
* server (`/server/api/register.ts`, `/server/api/contact.ts`) calls this
|
||||
* AFTER its existing NocoDB write succeeds, sending the same payload as a
|
||||
* server-to-server fire-and-forget POST. The CRM stores the raw payload
|
||||
* in `website_submissions` for later analysis / promotion to entities.
|
||||
*
|
||||
* Auth: shared-secret in `X-Webhook-Secret` header, timing-safe compared
|
||||
* against `WEBSITE_INTAKE_SECRET`. If the env var is unset on this
|
||||
* instance, the endpoint refuses every request with 503 - the correct
|
||||
* posture for dev/staging that hasn't been wired up yet.
|
||||
*
|
||||
* Idempotency: payload carries a `submission_id` UUID. The unique index
|
||||
* on `website_submissions.submission_id` makes redelivery a no-op; the
|
||||
* handler returns 200 + the existing record's id instead of erroring.
|
||||
*
|
||||
* No emails / no `interests` rows are created here. The endpoint's job is
|
||||
* pure data capture. A separate "promote" step (future) will turn captured
|
||||
* submissions into proper `clients` + `interests` rows once we trust the
|
||||
* pipeline.
|
||||
*/
|
||||
|
||||
const SubmissionSchema = z.object({
|
||||
submission_id: z.string().uuid(),
|
||||
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']),
|
||||
payload: z.record(z.unknown()),
|
||||
legacy_nocodb_id: z.string().optional(),
|
||||
/** Defaults to port-nimara since that's currently the only port with a
|
||||
* public marketing site. Future ports can override per-submission. */
|
||||
port_slug: z.string().default('port-nimara'),
|
||||
});
|
||||
|
||||
function verifySecret(header: string | null): boolean {
|
||||
const expected = env.WEBSITE_INTAKE_SECRET;
|
||||
if (!expected) return false;
|
||||
if (!header) return false;
|
||||
// Timing-safe compare requires equal-length buffers; pad to whichever is
|
||||
// longer so an early-exit on length mismatch can't leak the secret length.
|
||||
const a = Buffer.from(header);
|
||||
const b = Buffer.from(expected);
|
||||
const pad = Buffer.alloc(Math.max(a.length, b.length));
|
||||
const aPad = Buffer.concat([a, pad]).subarray(0, pad.length);
|
||||
const bPad = Buffer.concat([b, pad]).subarray(0, pad.length);
|
||||
return timingSafeEqual(aPad, bPad) && a.length === b.length;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Refuse outright if the CRM hasn't been wired up - safer than letting
|
||||
// unauthenticated traffic in just because the env var was forgotten.
|
||||
if (!env.WEBSITE_INTAKE_SECRET) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Website intake is not configured on this server.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
// Auth gate - shared secret in header, timing-safe compare.
|
||||
const secretHeader = req.headers.get('x-webhook-secret');
|
||||
if (!verifySecret(secretHeader)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Rate limit. All website-side traffic shares the website's egress IP,
|
||||
// so we use a dedicated bucket sized to accommodate normal traffic
|
||||
// (500/hr) rather than the 5/hr publicForm bucket meant for individual
|
||||
// human submissions. The shared-secret header is the real abuse
|
||||
// boundary; this limiter is just a backstop if the secret ever leaks.
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
const rl = await checkRateLimit(ip, rateLimiters.websiteIntake);
|
||||
if (!rl.allowed) {
|
||||
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded' },
|
||||
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
// Parse + validate body. Reject anything that doesn't conform — the
|
||||
// website is a known caller; a malformed payload signals tampering.
|
||||
let parsed;
|
||||
try {
|
||||
const body = await req.json();
|
||||
parsed = SubmissionSchema.parse(body);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid payload', details: err instanceof Error ? err.message : 'parse error' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve port. We require the slug to exist; can't capture submissions
|
||||
// for a port the CRM doesn't know about.
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, parsed.port_slug))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
// Don't echo the input slug back in the error - generic message is
|
||||
// sufficient and avoids the input-reflection pattern that complicates
|
||||
// log-injection / audit reviews. The slug is logged server-side
|
||||
// for debugging.
|
||||
logger.warn(
|
||||
{ portSlug: parsed.port_slug, submissionId: parsed.submission_id },
|
||||
'website-inquiry rejected: unknown port',
|
||||
);
|
||||
return NextResponse.json({ error: 'Unknown port' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Idempotent insert. Two parallel requests carrying the same submission_id
|
||||
// could both pass any pre-check, so we don't pre-check at all - the unique
|
||||
// index on submission_id is the source of truth, and `onConflictDoNothing`
|
||||
// keeps the second request's INSERT from raising 23505. When the conflict
|
||||
// hits, `returning()` yields zero rows and we look up the existing row to
|
||||
// return its id, mirroring the first-delivery shape so the website never
|
||||
// sees a difference between fresh and dup.
|
||||
const insertResult = await db
|
||||
.insert(websiteSubmissions)
|
||||
.values({
|
||||
portId: port.id,
|
||||
submissionId: parsed.submission_id,
|
||||
kind: parsed.kind,
|
||||
payload: parsed.payload,
|
||||
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
||||
sourceIp: ip,
|
||||
userAgent: req.headers.get('user-agent') ?? null,
|
||||
})
|
||||
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
|
||||
.returning({ id: websiteSubmissions.id });
|
||||
|
||||
if (insertResult[0]) {
|
||||
logger.info(
|
||||
{
|
||||
submissionId: parsed.submission_id,
|
||||
kind: parsed.kind,
|
||||
portSlug: parsed.port_slug,
|
||||
legacyNocodbId: parsed.legacy_nocodb_id,
|
||||
},
|
||||
'website inquiry captured',
|
||||
);
|
||||
return NextResponse.json({ id: insertResult[0].id, deduped: false });
|
||||
}
|
||||
|
||||
// Conflict path: row already exists. Fetch its id so the response shape
|
||||
// stays identical regardless of which request "won" the race.
|
||||
const existing = await db
|
||||
.select({ id: websiteSubmissions.id })
|
||||
.from(websiteSubmissions)
|
||||
.where(eq(websiteSubmissions.submissionId, parsed.submission_id))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
return NextResponse.json({ id: existing[0].id, deduped: true });
|
||||
}
|
||||
|
||||
// Should be unreachable - the conflict means a row exists, so the lookup
|
||||
// above should always find it. If it doesn't (e.g. simultaneous DELETE),
|
||||
// surface a 500 explicitly rather than silently 200ing a missing id.
|
||||
logger.error(
|
||||
{ submissionId: parsed.submission_id },
|
||||
'website-inquiry conflict but row not found on lookup',
|
||||
);
|
||||
return NextResponse.json({ error: 'Insert failed' }, { status: 500 });
|
||||
}
|
||||
@@ -21,7 +21,7 @@ interface ReadyResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe — verifies that every backing service this process
|
||||
* Readiness probe - verifies that every backing service this process
|
||||
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||
* load balancer until the next probe succeeds; it should not trigger a
|
||||
* pod restart (that's what `/api/health` is for).
|
||||
|
||||
@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
||||
* exercised by the realapi socket fanout test.
|
||||
*
|
||||
* Requires super_admin or per-port admin permissions; the engine itself
|
||||
* is idempotent — duplicate runs only re-evaluate, never duplicate rows.
|
||||
* is idempotent - duplicate runs only re-evaluate, never duplicate rows.
|
||||
*/
|
||||
export const POST = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
|
||||
|
||||
/**
|
||||
* Admin probe — calls Documenso /api/v1/health using the port's effective
|
||||
* Admin probe - calls Documenso /api/v1/health using the port's effective
|
||||
* config. Used by the "Test connection" button on /admin/documenso.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
|
||||
.map((p) => {
|
||||
const a = clientById.get(p.clientAId);
|
||||
const b = clientById.get(p.clientBId);
|
||||
if (!a || !b) return null; // FK orphan — shouldn't happen, but be defensive
|
||||
if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
|
||||
// Skip pairs where one side has already been merged or archived.
|
||||
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
||||
try {
|
||||
// crm_user_invites is a global table (no per-port column) — invites
|
||||
// crm_user_invites is a global table (no per-port column) - invites
|
||||
// mint better-auth users that may later be assigned roles in any
|
||||
// port. Listing it cross-tenant would let a port-A director
|
||||
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||
|
||||
@@ -13,7 +13,7 @@ const schema = z.object({
|
||||
apiKey: z.string().min(1),
|
||||
});
|
||||
|
||||
// `manage_settings`-gated for parity with the parent OCR settings route —
|
||||
// `manage_settings`-gated for parity with the parent OCR settings route -
|
||||
// triggers outbound AI provider auth requests using a caller-supplied key.
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req) => {
|
||||
|
||||
@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
|
||||
* POST /api/v1/admin/templates/preview
|
||||
*
|
||||
* Generates a preview PDF from a TipTap JSON content block.
|
||||
* Returns { data: { pdfBase64: string } } — the client can render this
|
||||
* Returns { data: { pdfBase64: string } } - the client can render this
|
||||
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
|
||||
*
|
||||
* Body:
|
||||
* content: TipTap JSON document
|
||||
* sampleData?: Record<string, string> — variable substitutions
|
||||
* sampleData?: Record<string, string> - variable substitutions
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'manage', async (req, _ctx) => {
|
||||
@@ -60,10 +60,7 @@ export const POST = withAuth(
|
||||
/**
|
||||
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
|
||||
*/
|
||||
function substituteInDoc(
|
||||
node: TipTapNode,
|
||||
data: Record<string, string>,
|
||||
): TipTapNode {
|
||||
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
|
||||
if (node.type === 'text' && node.text) {
|
||||
return { ...node, text: substituteVariables(node.text, data) };
|
||||
}
|
||||
|
||||
24
src/app/api/v1/admin/umami/test/route.ts
Normal file
24
src/app/api/v1/admin/umami/test/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { testConnection } from '@/lib/services/umami.service';
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/umami/test - admin-only Umami connection check.
|
||||
*
|
||||
* Returns `{ data: { ok: true, visitors } }` on success or
|
||||
* `{ data: { ok: false, error } }` on failure. Mirrors the shape used by
|
||||
* the Documenso health endpoint so the existing test-button UI pattern
|
||||
* just works.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
const result = await testConnection(ctx.portId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ data: { ok: false, error } });
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getRevenueBreakdown,
|
||||
type DateRange,
|
||||
type MetricBase,
|
||||
type PresetDateRange,
|
||||
} from '@/lib/services/analytics.service';
|
||||
|
||||
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||
@@ -18,17 +19,69 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
|
||||
lead_source_attribution: getLeadSourceAttribution,
|
||||
};
|
||||
|
||||
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
|
||||
const rawRange = url.searchParams.get('range') ?? '30d';
|
||||
const fromParam = url.searchParams.get('from');
|
||||
const toParam = url.searchParams.get('to');
|
||||
|
||||
if (!metric || !(metric in METRICS)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
||||
}
|
||||
if (!ALL_RANGES.includes(range)) {
|
||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
||||
|
||||
let range: DateRange;
|
||||
if (rawRange === 'custom') {
|
||||
if (!fromParam || !toParam) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
|
||||
return NextResponse.json(
|
||||
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (fromParam > toParam) {
|
||||
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
|
||||
}
|
||||
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
|
||||
// (rolls over silently when handed to `new Date`). Re-serialize and
|
||||
// confirm it matches the input to catch invalid calendar values.
|
||||
for (const [label, raw] of [
|
||||
['from', fromParam],
|
||||
['to', toParam],
|
||||
] as const) {
|
||||
const d = new Date(`${raw}T00:00:00.000Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
|
||||
return NextResponse.json(
|
||||
{ error: `\`${label}\` is not a valid calendar date` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
// Backstop against the occupancy-timeline N+1 query loop. Each day
|
||||
// in the range issues its own DB query, so a multi-year custom
|
||||
// range would saturate the connection pool. 365 days is a generous
|
||||
// ceiling for analytical queries; if a longer span is needed, the
|
||||
// service should be restructured to use `generate_series` instead
|
||||
// of a JS loop.
|
||||
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
|
||||
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
|
||||
if ((toMs - fromMs) / 86_400_000 > 365) {
|
||||
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
|
||||
}
|
||||
range = { kind: 'custom', from: fromParam, to: toParam };
|
||||
} else {
|
||||
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
|
||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
||||
}
|
||||
range = rawRange as PresetDateRange;
|
||||
}
|
||||
|
||||
const data = await METRICS[metric](ctx.portId, range);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
||||
// PATCH cannot use `withPermission` wrapper — the required permission depends
|
||||
// PATCH cannot use `withPermission` wrapper - the required permission depends
|
||||
// on the `action` field in the body. `requirePermission` is called inside the
|
||||
// handler after the body is parsed.
|
||||
export const PATCH = withAuth(patchHandler);
|
||||
|
||||
@@ -40,7 +40,7 @@ export const PUT = withAuth(
|
||||
}),
|
||||
);
|
||||
|
||||
// PATCH /api/v1/berths/[id]/waiting-list — reorder a single entry
|
||||
// PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
|
||||
export const PATCH = withAuth(
|
||||
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getBerthOptions } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
|
||||
// GET /api/v1/berths/options - lightweight list for selects/comboboxes
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx) => {
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@ const inviteSchema = z.object({
|
||||
*
|
||||
* Admin creates a portal account for a client and triggers the activation
|
||||
* email. Idempotent in spirit: if a portal user already exists for the
|
||||
* email, returns 409 — the admin can resend the activation via
|
||||
* email, returns 409 - the admin can resend the activation via
|
||||
* ?action=resend.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
|
||||
const nameResult = rawName ? normalizeName(rawName) : null;
|
||||
|
||||
// If the caller didn't give us anything useful to match on, return empty
|
||||
// — short-circuit rather than scan every client for nothing.
|
||||
// - short-circuit rather than scan every client for nothing.
|
||||
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
|
||||
mediumScore: 50,
|
||||
});
|
||||
|
||||
// Only return medium+ — low-confidence noise isn't useful at the
|
||||
// Only return medium+ - low-confidence noise isn't useful at the
|
||||
// create-form layer (background scoring queue picks those up).
|
||||
const useful = matches.filter((m) => m.confidence !== 'low');
|
||||
if (useful.length === 0) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
||||
|
||||
const mergeSchema = z.object({
|
||||
/** Surviving expense id — typically the row's existing `duplicateOf` pointer. */
|
||||
/** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
|
||||
targetId: z.string().min(1),
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const POST = withAuth(
|
||||
});
|
||||
}
|
||||
|
||||
// Per-port budget gate — refuse the call before we spend tokens
|
||||
// Per-port budget gate - refuse the call before we spend tokens
|
||||
// when the port has already hit its hard cap, or when the request
|
||||
// would push it past the cap. Soft-cap warnings ride along on the
|
||||
// success response so the UI can show a banner without blocking.
|
||||
@@ -99,7 +99,7 @@ export const POST = withAuth(
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
||||
// Provider hiccup — degrade to manual entry rather than 500-ing.
|
||||
// Provider hiccup - degrade to manual entry rather than 500-ing.
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
parsed: EMPTY,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const POST = withAuth(
|
||||
try {
|
||||
const body = await parseBody(req, createFolderSchema);
|
||||
|
||||
// Sanitize path — no null bytes, no path traversal
|
||||
// Sanitize path - no null bytes, no path traversal
|
||||
const safePath = body.path
|
||||
.replace(/\x00/g, '')
|
||||
.replace(/\.\.\//g, '')
|
||||
|
||||
@@ -20,7 +20,7 @@ export const GET = withAuth(
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /api/v1/interests/[id]/recommendations — add manual recommendation
|
||||
// POST /api/v1/interests/[id]/recommendations - add manual recommendation
|
||||
export const POST = withAuth(
|
||||
withPermission('interests', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
|
||||
@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
won: 'Won',
|
||||
lost_other_marina: 'Lost — went to another marina',
|
||||
lost_unqualified: 'Lost — unqualified',
|
||||
lost_no_response: 'Lost — no response',
|
||||
lost_other_marina: 'Lost - went to another marina',
|
||||
lost_unqualified: 'Lost - unqualified',
|
||||
lost_no_response: 'Lost - no response',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ function buildAuditDescription(
|
||||
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
||||
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
||||
const reason = (newValue?.reason as string | undefined) ?? '';
|
||||
return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`;
|
||||
return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
|
||||
}
|
||||
|
||||
if (type === 'outcome_cleared') {
|
||||
@@ -200,9 +200,9 @@ function buildAuditDescription(
|
||||
const reason = (newValue.reason as string | undefined) ?? '';
|
||||
const auto = userId === 'system';
|
||||
if (auto) {
|
||||
return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`;
|
||||
return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
|
||||
}
|
||||
return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`;
|
||||
return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
|
||||
}
|
||||
|
||||
if (action === 'update' && newValue?.pipelineStage) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
|
||||
const results = await search(ctx.portId, q);
|
||||
|
||||
// Fire-and-forget — do not await
|
||||
// Fire-and-forget - do not await
|
||||
saveRecentSearch(ctx.userId, ctx.portId, q);
|
||||
|
||||
return NextResponse.json(results);
|
||||
|
||||
113
src/app/api/v1/website-analytics/route.ts
Normal file
113
src/app/api/v1/website-analytics/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
|
||||
import {
|
||||
getActiveVisitors,
|
||||
getMetric,
|
||||
getPageviewsSeries,
|
||||
getStats,
|
||||
type UmamiMetricType,
|
||||
} from '@/lib/services/umami.service';
|
||||
|
||||
/**
|
||||
* GET /api/v1/website-analytics?metric=...&range=...
|
||||
*
|
||||
* Single endpoint serving every Umami widget on the /website-analytics
|
||||
* page. Mirrors the shape of /api/v1/analytics so the client side can
|
||||
* reuse the same hook pattern.
|
||||
*
|
||||
* Supported metrics:
|
||||
* - stats → KPI tiles (pageviews, visitors, visits, etc.)
|
||||
* - pageviews → time-series for the trend chart
|
||||
* - active → live "right now" count (range ignored)
|
||||
* - top-{type} → top pages/referrers/countries/etc.
|
||||
* where type ∈ url|referrer|country|browser|
|
||||
* os|device|event
|
||||
*
|
||||
* Range param accepts the same presets as /api/v1/analytics, plus
|
||||
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD`.
|
||||
*/
|
||||
|
||||
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
|
||||
|
||||
function parseRange(req: NextRequest): DateRange | { error: string } {
|
||||
const url = new URL(req.url);
|
||||
const rawRange = url.searchParams.get('range') ?? '30d';
|
||||
const fromParam = url.searchParams.get('from');
|
||||
const toParam = url.searchParams.get('to');
|
||||
|
||||
if (rawRange === 'custom') {
|
||||
if (!fromParam || !toParam) {
|
||||
return { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' };
|
||||
}
|
||||
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
|
||||
return { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' };
|
||||
}
|
||||
if (fromParam > toParam) {
|
||||
return { error: '`from` must be on or before `to`' };
|
||||
}
|
||||
// Round-trip date check (catches "2026-02-31" type rollovers).
|
||||
for (const [label, raw] of [
|
||||
['from', fromParam],
|
||||
['to', toParam],
|
||||
] as const) {
|
||||
const d = new Date(`${raw}T00:00:00.000Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
|
||||
return { error: `\`${label}\` is not a valid calendar date` };
|
||||
}
|
||||
}
|
||||
return { kind: 'custom', from: fromParam, to: toParam };
|
||||
}
|
||||
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
|
||||
return { error: 'Invalid range' };
|
||||
}
|
||||
return rawRange as PresetDateRange;
|
||||
}
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric');
|
||||
if (!metric) {
|
||||
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rangeOrError = parseRange(req);
|
||||
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
|
||||
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
|
||||
}
|
||||
const range = rangeOrError as DateRange;
|
||||
|
||||
try {
|
||||
let data: unknown;
|
||||
|
||||
if (metric === 'stats') {
|
||||
data = await getStats(ctx.portId, range);
|
||||
} else if (metric === 'pageviews') {
|
||||
data = await getPageviewsSeries(ctx.portId, range);
|
||||
} else if (metric === 'active') {
|
||||
data = await getActiveVisitors(ctx.portId);
|
||||
} else if (TOP_METRIC_RX.test(metric)) {
|
||||
const type = metric.replace(/^top-/, '') as UmamiMetricType;
|
||||
const limit = Number(url.searchParams.get('limit') ?? 10);
|
||||
data = await getMetric(ctx.portId, range, type, limit);
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// `data === null` from the service means Umami isn't configured for
|
||||
// this port - surface that explicitly so the UI can render a
|
||||
// "configure your credentials" empty state instead of a chart.
|
||||
if (data === null) {
|
||||
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ metric, range, data });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ error: message, metric, range }, { status: 502 });
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.info({ signatureHash }, 'Duplicate Documenso webhook — skipping');
|
||||
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
|
||||
/* Wave watermark — subtle background texture for auth pages */
|
||||
/* Wave watermark - subtle background texture for auth pages */
|
||||
.wave-watermark {
|
||||
background-image: repeating-linear-gradient(
|
||||
135deg,
|
||||
@@ -134,7 +134,7 @@
|
||||
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
||||
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
||||
*
|
||||
* IMPORTANT: only `display: none` rules are emitted — we never set a positive
|
||||
* IMPORTANT: only `display: none` rules are emitted - we never set a positive
|
||||
* display, because the desktop shell uses Tailwind's `flex` class which would
|
||||
* be overridden by `display: block` (same specificity, later cascade).
|
||||
*/
|
||||
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Recharts focus-ring suppression.
|
||||
*
|
||||
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
|
||||
* them (the library adds tabindex on chart sectors / paths). The global
|
||||
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
|
||||
* which on a chart surface reads as a stray rectangle around the plot
|
||||
* area. Hover/tooltip already handles chart interactivity, so suppress
|
||||
* the ring entirely here.
|
||||
*
|
||||
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
|
||||
* it during purge (an earlier copy inside `@layer base` was being
|
||||
* silently removed at build time, leaving the ring intact).
|
||||
*/
|
||||
div.recharts-wrapper:focus,
|
||||
div.recharts-wrapper:focus-visible,
|
||||
svg.recharts-surface:focus,
|
||||
svg.recharts-surface:focus-visible,
|
||||
div.recharts-responsive-container:focus,
|
||||
div.recharts-responsive-container:focus-visible,
|
||||
.recharts-wrapper *:focus,
|
||||
.recharts-wrapper *:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
--tw-ring-shadow: 0 0 #0000 !important;
|
||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||
--tw-ring-color: transparent !important;
|
||||
--tw-ring-offset-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function AuditLogList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Filter state — debounce text inputs.
|
||||
// Filter state - debounce text inputs.
|
||||
const [search, setSearch] = useState('');
|
||||
const [entityType, setEntityType] = useState<string>('all');
|
||||
const [action, setAction] = useState<string>('all');
|
||||
@@ -215,7 +215,7 @@ export function AuditLogList() {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return <span className="text-xs text-muted-foreground">-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -245,7 +245,7 @@ export function AuditLogList() {
|
||||
<PageHeader
|
||||
title="Audit Log"
|
||||
eyebrow="Admin"
|
||||
description="Every state change in this port — fully searchable."
|
||||
description="Every state change in this port - fully searchable."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
|
||||
@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CustomFieldForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
field,
|
||||
onSuccess,
|
||||
}: CustomFieldFormProps) {
|
||||
export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
|
||||
const isEdit = !!field;
|
||||
|
||||
// Form state
|
||||
@@ -72,9 +67,7 @@ export function CustomFieldForm({
|
||||
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
|
||||
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
|
||||
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
|
||||
const [selectOptions, setSelectOptions] = useState<string[]>(
|
||||
field?.selectOptions ?? [],
|
||||
);
|
||||
const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
|
||||
const [newOption, setNewOption] = useState('');
|
||||
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
|
||||
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
|
||||
@@ -169,13 +162,11 @@ export function CustomFieldForm({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
||||
{/* Entity Type — create only */}
|
||||
{/* Entity Type - create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-entity-type">Entity Type</Label>
|
||||
{isEdit ? (
|
||||
@@ -198,7 +189,7 @@ export function CustomFieldForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field Name — create only */}
|
||||
{/* Field Name - create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-field-name">
|
||||
Field Name
|
||||
@@ -232,7 +223,7 @@ export function CustomFieldForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Field Type — create only */}
|
||||
{/* Field Type - create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-field-type">Field Type</Label>
|
||||
{isEdit ? (
|
||||
@@ -260,7 +251,7 @@ export function CustomFieldForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Select Options — visible when fieldType = 'select' */}
|
||||
{/* Select Options - visible when fieldType = 'select' */}
|
||||
{fieldType === 'select' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Options</Label>
|
||||
@@ -302,11 +293,7 @@ export function CustomFieldForm({
|
||||
{/* Is Required */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cf-is-required">Required field</Label>
|
||||
<Switch
|
||||
id="cf-is-required"
|
||||
checked={isRequired}
|
||||
onCheckedChange={setIsRequired}
|
||||
/>
|
||||
<Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||
|
||||
@@ -61,20 +55,13 @@ interface TemplateFormProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function TemplateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateFormProps) {
|
||||
export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
|
||||
const isEdit = !!template;
|
||||
|
||||
const [name, setName] = useState(template?.name ?? '');
|
||||
const [type, setType] = useState(template?.templateType ?? 'other');
|
||||
const [contentJson, setContentJson] = useState(
|
||||
template?.content
|
||||
? JSON.stringify(template.content, null, 2)
|
||||
: EMPTY_DOC,
|
||||
template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -86,7 +73,7 @@ export function TemplateForm({
|
||||
setJsonError(null);
|
||||
return true;
|
||||
} catch {
|
||||
setJsonError('Invalid JSON — check syntax.');
|
||||
setJsonError('Invalid JSON - check syntax.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -115,8 +102,7 @@ export function TemplateForm({
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Something went wrong';
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -127,9 +113,7 @@ export function TemplateForm({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{isEdit ? 'Edit Template' : 'New Document Template'}
|
||||
</SheetTitle>
|
||||
<SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||
@@ -145,7 +129,7 @@ export function TemplateForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type — only on create */}
|
||||
{/* Type - only on create */}
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-type">Document Type</Label>
|
||||
@@ -166,15 +150,11 @@ export function TemplateForm({
|
||||
|
||||
{/* TipTap JSON Content */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-content">
|
||||
Document Content (TipTap JSON)
|
||||
</Label>
|
||||
<Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste or edit TipTap JSON. Use{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{'{{variable.key}}'}
|
||||
</code>{' '}
|
||||
tokens for dynamic content.
|
||||
<code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
|
||||
dynamic content.
|
||||
</p>
|
||||
<textarea
|
||||
id="template-content"
|
||||
@@ -187,9 +167,7 @@ export function TemplateForm({
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-xs text-destructive">{jsonError}</p>
|
||||
)}
|
||||
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Available Variables Reference */}
|
||||
@@ -200,19 +178,15 @@ export function TemplateForm({
|
||||
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
{TEMPLATE_VARIABLES.map((v) => (
|
||||
<div key={v.key} className="text-xs">
|
||||
<code className="rounded bg-muted px-1">
|
||||
{`{{${v.key}}}`}
|
||||
</code>{' '}
|
||||
<span className="text-muted-foreground">— {v.label}</span>
|
||||
<code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
|
||||
<span className="text-muted-foreground">- {v.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{error && (
|
||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter>
|
||||
@@ -225,11 +199,7 @@ export function TemplateForm({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !!jsonError}>
|
||||
{loading
|
||||
? 'Saving…'
|
||||
: isEdit
|
||||
? 'Save Changes'
|
||||
: 'Create Template'}
|
||||
{loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TemplateForm } from './template-form';
|
||||
import { TemplateVersionHistory } from './template-version-history';
|
||||
@@ -57,9 +52,7 @@ export function TemplateList() {
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: AdminTemplate[] }>(
|
||||
'/api/v1/admin/templates',
|
||||
);
|
||||
const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
|
||||
setTemplates(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -122,9 +115,7 @@ export function TemplateList() {
|
||||
accessorKey: 'version',
|
||||
header: 'Version',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
v{row.original.version}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">v{row.original.version}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -151,10 +142,7 @@ export function TemplateList() {
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TemplatePreview
|
||||
content={row.original.content}
|
||||
templateName={row.original.name}
|
||||
/>
|
||||
<TemplatePreview content={row.original.content} templateName={row.original.name} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -177,9 +165,7 @@ export function TemplateList() {
|
||||
title={row.original.isActive ? 'Deactivate' : 'Activate'}
|
||||
onClick={() => handleToggleActive(row.original)}
|
||||
>
|
||||
<span className="text-xs">
|
||||
{row.original.isActive ? 'Off' : 'On'}
|
||||
</span>
|
||||
<span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
@@ -233,9 +219,7 @@ export function TemplateList() {
|
||||
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
Version History — {historyTemplate?.name}
|
||||
</SheetTitle>
|
||||
<SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
{historyTemplate && (
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||
|
||||
@@ -24,9 +19,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Build sample data from TEMPLATE_VARIABLES examples
|
||||
const sampleData = Object.fromEntries(
|
||||
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
|
||||
);
|
||||
const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
|
||||
|
||||
async function handlePreview() {
|
||||
if (!content) {
|
||||
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Preview — {templateName}</DialogTitle>
|
||||
<DialogTitle>Preview - {templateName}</DialogTitle>
|
||||
{pdfBase64 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenInNewTab}
|
||||
className="mr-6"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
Open in new tab
|
||||
</Button>
|
||||
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{pdfBase64 && !loading && (
|
||||
|
||||
@@ -117,7 +117,7 @@ export function InvitationsManager() {
|
||||
{invites.map((i) => (
|
||||
<tr key={i.id} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{i.email}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{i.name ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{i.name ?? '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
|
||||
</td>
|
||||
@@ -163,7 +163,7 @@ export function InvitationsManager() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
||||
Enable AI receipt parsing for this port
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Off by default. Receipts are read on-device using Tesseract.js — accurate enough for
|
||||
Off by default. Receipts are read on-device using Tesseract.js - accurate enough for
|
||||
most receipts and incurs no AI cost. Turning this on lets the configured provider
|
||||
re-parse receipts server-side for higher accuracy on hard-to-read images.
|
||||
</p>
|
||||
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
||||
id={`apiKey-${scope}`}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
autoComplete="off"
|
||||
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
|
||||
placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
|
||||
@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
|
||||
};
|
||||
|
||||
function formatDate(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
if (!ts) return '-';
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
|
||||
}
|
||||
|
||||
function truncateReason(reason: string | undefined): string {
|
||||
if (!reason) return '—';
|
||||
if (!reason) return '-';
|
||||
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{total} total jobs — page {page} of {totalPages}
|
||||
{total} total jobs - page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -95,7 +95,7 @@ export function RoleList() {
|
||||
accessorKey: 'description',
|
||||
header: 'Description',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">{row.original.description ?? '—'}</span>
|
||||
<span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -363,7 +363,7 @@ export function SettingsManager() {
|
||||
);
|
||||
void saveSetting(setting.key, parsed);
|
||||
} catch {
|
||||
// invalid JSON — do nothing
|
||||
// invalid JSON - do nothing
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Email subtitle — only when display name is shown as title */}
|
||||
{/* Email subtitle - only when display name is shown as title */}
|
||||
{user.displayName && user.displayName !== user.email ? (
|
||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
|
||||
@@ -57,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
void load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [webhookId, page]);
|
||||
|
||||
if (loading && deliveries.length === 0) {
|
||||
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{d.responseStatus ?? '—'}
|
||||
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
|
||||
<TableCell className="text-sm">{d.attempt}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{d.deliveredAt
|
||||
|
||||
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal file
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface TestResponse {
|
||||
ok: boolean;
|
||||
visitors?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hits POST /api/v1/admin/umami/test which calls Umami's `/api/websites/:id/
|
||||
* active` to verify auth + websiteId in one request. On success, shows the
|
||||
* live visitor count as proof we got real data back.
|
||||
*/
|
||||
export function UmamiTestButton() {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [result, setResult] = useState<TestResponse | null>(null);
|
||||
|
||||
async function runTest() {
|
||||
setPending(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await apiFetch<{ data: TestResponse }>('/api/v1/admin/umami/test', {
|
||||
method: 'POST',
|
||||
});
|
||||
setResult(res.data);
|
||||
if (res.data.ok) {
|
||||
toast.success(`Umami reachable - ${res.data.visitors ?? 0} active visitor(s) right now`);
|
||||
} else {
|
||||
toast.error(res.data.error ?? 'Umami test failed');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Test failed';
|
||||
setResult({ ok: false, error: message });
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{result &&
|
||||
(result.ok ? (
|
||||
<span className="flex items-center text-xs text-green-600">
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
|
||||
Connected ({result.visitors ?? 0} active)
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center text-xs text-destructive">
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
{result.error ?? 'Failed'}
|
||||
</span>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="outline" onClick={runTest} disabled={pending}>
|
||||
{pending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Test connection
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
export function AlertBell() {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Count is cheap (one aggregate query) — fire on every page so the badge stays live.
|
||||
// List is heavier — only fetch when the popover is actually open.
|
||||
// Count is cheap (one aggregate query) - fire on every page so the badge stays live.
|
||||
// List is heavier - only fetch when the popover is actually open.
|
||||
const { data: count } = useAlertCount();
|
||||
const { data: list, isLoading } = useAlertList('open', open);
|
||||
useAlertRealtime();
|
||||
|
||||
@@ -22,11 +22,10 @@ export function AlertRail() {
|
||||
<section
|
||||
data-testid="alert-rail"
|
||||
aria-label="Active alerts"
|
||||
// `h-full` is intentional only at xl: where the parent dashboard grid
|
||||
// gives this rail a sibling column whose height it should match. On
|
||||
// mobile (single-column stack) there's no fixed-height context, so
|
||||
// forcing 100% height makes the section overflow / look stretched.
|
||||
className="flex flex-col gap-3 xl:h-full"
|
||||
// Natural height - the parent aside no longer forces 100% of the
|
||||
// dashboard grid row, so the rail can sit compactly under Reminders
|
||||
// without bleeding down into the Recent Activity panel below.
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||
@@ -57,7 +56,7 @@ export function AlertRail() {
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
+{overflow} more — view all
|
||||
+{overflow} more - view all
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'mooringNumber',
|
||||
header: 'Mooring #',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.mooringNumber}</span>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'area',
|
||||
header: 'Area',
|
||||
cell: ({ row }) => row.original.area ?? '—',
|
||||
cell: ({ row }) => row.original.area ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { lengthM, widthM } = row.original;
|
||||
if (!lengthM && !widthM) return '—';
|
||||
if (!lengthM && !widthM) return '-';
|
||||
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
||||
},
|
||||
},
|
||||
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
header: 'Price',
|
||||
cell: ({ row }) => {
|
||||
const { price, priceCurrency } = row.original;
|
||||
if (!price) return '—';
|
||||
if (!price) return '-';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: priceCurrency || 'USD',
|
||||
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'tenureType',
|
||||
header: 'Tenure',
|
||||
cell: ({ row }) =>
|
||||
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
|
||||
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
|
||||
@@ -93,7 +93,7 @@ function SelectOrEmpty({
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
<SelectItem value={NONE}>-</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
|
||||
@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
href={`/${portSlug}/interests/${i.id}` as never}
|
||||
className="hover:text-brand"
|
||||
>
|
||||
{i.clientName ?? '—'}
|
||||
{i.clientName ?? '-'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '—'}
|
||||
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '—'}
|
||||
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{new Date(i.createdAt).toLocaleDateString()}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function BerthList() {
|
||||
title="Berths"
|
||||
description="View and manage berth allocations"
|
||||
variant="gradient"
|
||||
// No "New" button — berths are import-only
|
||||
// No "New" button - berths are import-only
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
||||
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
|
||||
who's interested + how warm without clicking into the Interests tab. */}
|
||||
<BerthInterestPulse berthId={berth.id} />
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function getClientColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
||||
if (!primary) return <span className="text-muted-foreground">—</span>;
|
||||
if (!primary) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<span className="text-sm">
|
||||
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
||||
@@ -86,7 +86,7 @@ export function getClientColumns({
|
||||
cell: ({ getValue }) => {
|
||||
const iso = getValue() as string | null;
|
||||
return (
|
||||
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '—'}</span>
|
||||
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export function getClientColumns({
|
||||
header: 'Source',
|
||||
cell: ({ getValue }) => {
|
||||
const source = getValue() as string | null;
|
||||
if (!source) return <span className="text-muted-foreground">—</span>;
|
||||
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
@@ -111,7 +111,7 @@ export function getClientColumns({
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.yachtCount ?? 0;
|
||||
return c === 0 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{c}
|
||||
@@ -126,7 +126,7 @@ export function getClientColumns({
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.companyCount ?? 0;
|
||||
return c === 0 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{c}
|
||||
@@ -140,7 +140,7 @@ export function getClientColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const clientTags = row.original.tags ?? [];
|
||||
if (clientTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
||||
if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clientTags.slice(0, 3).map((tag) => (
|
||||
|
||||
@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
|
||||
|
||||
function formatSince(startDate: string | Date): string {
|
||||
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
if (Number.isNaN(d.getTime())) return '-';
|
||||
return format(d, 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
|
||||
Primary
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right: archive/restore as a small icon button — destructive
|
||||
{/* Top-right: archive/restore as a small icon button - destructive
|
||||
action sits out of the primary action flow. */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||
{/* Dedup suggestion — only on the create path. Watches the
|
||||
{/* Dedup suggestion - only on the create path. Watches the
|
||||
live form values for email / phone / name and surfaces
|
||||
an existing client when one matches. The user can
|
||||
attach the new interest to that client instead of
|
||||
|
||||
@@ -180,7 +180,7 @@ function InterestPreviewDrawer({
|
||||
}) {
|
||||
// Pin the most recently selected interest so the drawer stays populated
|
||||
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
||||
// after `open=false`). Conditional setState is safe here — the guard
|
||||
// after `open=false`). Conditional setState is safe here - the guard
|
||||
// ensures it only fires when the prop actually changes to a new row.
|
||||
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
||||
if (interest && interest !== pinned) setPinned(interest);
|
||||
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
||||
{/* Pipeline-stepper segmented bar — the same primitive used on the
|
||||
{/* Pipeline-stepper segmented bar - the same primitive used on the
|
||||
row card, so the at-a-glance progress hint is consistent
|
||||
across surfaces. */}
|
||||
{stage ? (
|
||||
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Milestones — three sections matching the full interest detail
|
||||
{/* Milestones - three sections matching the full interest detail
|
||||
page (EOI / Deposit / Contract). Done-state is derived from
|
||||
the pipeline stage so seed data without per-step dates still
|
||||
renders correctly. The full milestone columns + per-step
|
||||
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Compact key/value pairs — lead category, source, last contact,
|
||||
{/* Compact key/value pairs - lead category, source, last contact,
|
||||
activity. Each row collapses cleanly when its value is
|
||||
missing so the drawer scales from sparse seed data to full
|
||||
records without empty placeholders. */}
|
||||
|
||||
@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
||||
interface PipelineSummaryProps {
|
||||
clientId: string;
|
||||
/**
|
||||
* `hero` — single-line pulse for the detail header (highest active stage only).
|
||||
* `panel` — compact list of every active interest, for the Overview tab.
|
||||
* `hero` - single-line pulse for the detail header (highest active stage only).
|
||||
* `panel` - compact list of every active interest, for the Overview tab.
|
||||
*/
|
||||
variant?: 'hero' | 'panel';
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '—'}
|
||||
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{y.hullNumber ?? '—'}</TableCell>
|
||||
<TableCell>{y.hullNumber ?? '-'}</TableCell>
|
||||
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -225,10 +225,10 @@ function ContactRow({
|
||||
|
||||
{/* Bottom / right: tag + actions.
|
||||
Two layers of hiding compose here:
|
||||
(a) phoneEditing — when the phone editor is open, hide the entire
|
||||
(a) phoneEditing - when the phone editor is open, hide the entire
|
||||
action cluster (tag + star + trash) so the user can focus on
|
||||
the form without chips fighting for space.
|
||||
(b) contact.value — when the value is empty (stale import row,
|
||||
(b) contact.value - when the value is empty (stale import row,
|
||||
aborted edit), hide just the tag + Make-primary star;
|
||||
neither makes sense without a value. The trash icon stays
|
||||
so the user can clean up the empty entry.
|
||||
|
||||
@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
|
||||
// Clear the dismissed flag when inputs change — the user typed
|
||||
// Clear the dismissed flag when inputs change - the user typed
|
||||
// something new, so the prior dismissal no longer applies.
|
||||
setDismissed(false);
|
||||
}, 300);
|
||||
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
|
||||
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
||||
},
|
||||
enabled: hasSomething && !dismissed,
|
||||
// Same query is fine to cache for a minute — moves are slow at this layer.
|
||||
// Same query is fine to cache for a minute - moves are slow at this layer.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
{isHigh
|
||||
? 'This looks like an existing client'
|
||||
: 'Possible match — check before creating'}
|
||||
: 'Possible match - check before creating'}
|
||||
</p>
|
||||
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Export queued — refresh in ~30 seconds');
|
||||
toast.success('Export queued - refresh in ~30 seconds');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
setEmailOverride('');
|
||||
},
|
||||
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
Email the bundle when ready
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sends a 7-day signed download link to the client's primary email — or to the
|
||||
Sends a 7-day signed download link to the client's primary email - or to the
|
||||
override below.
|
||||
</p>
|
||||
{emailToClient ? (
|
||||
|
||||
@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
let msg = err instanceof Error ? err.message : 'Failed to add membership';
|
||||
// Detect 409 — service returns a "membership already exists" message
|
||||
// Detect 409 - service returns a "membership already exists" message
|
||||
if (/already exists/i.test(msg)) {
|
||||
msg = 'This membership already exists (same client + role + start date).';
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function getCompanyColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as string | null;
|
||||
if (!value) return <span className="text-muted-foreground">—</span>;
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-sm">{value}</span>;
|
||||
},
|
||||
},
|
||||
@@ -87,7 +87,7 @@ export function getCompanyColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as string | null;
|
||||
if (!value) return <span className="text-muted-foreground">—</span>;
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-sm">{value}</span>;
|
||||
},
|
||||
},
|
||||
@@ -98,7 +98,7 @@ export function getCompanyColumns({
|
||||
size: 88,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.memberCount ?? 0;
|
||||
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
||||
if (n === 0) return <span className="text-muted-foreground">-</span>;
|
||||
return <Badge variant="secondary">{n}</Badge>;
|
||||
},
|
||||
},
|
||||
@@ -109,7 +109,7 @@ export function getCompanyColumns({
|
||||
size: 88,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.yachtCount ?? 0;
|
||||
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
||||
if (n === 0) return <span className="text-muted-foreground">-</span>;
|
||||
return <Badge variant="secondary">{n}</Badge>;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateCompanyInput) => {
|
||||
if (isEdit) {
|
||||
// updateCompanySchema omits tagIds — strip them from PATCH body.
|
||||
// updateCompanySchema omits tagIds - strip them from PATCH body.
|
||||
const { tagIds: _tIds, ...rest } = data;
|
||||
void _tIds;
|
||||
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
||||
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
value={watch('incorporationCountryIso')}
|
||||
onChange={(iso) => {
|
||||
setValue('incorporationCountryIso', iso ?? undefined);
|
||||
// Wipe subdivision when country flips — codes are country-scoped.
|
||||
// Wipe subdivision when country flips - codes are country-scoped.
|
||||
setValue('incorporationSubdivisionIso', undefined);
|
||||
}}
|
||||
data-testid="company-incorp-country"
|
||||
|
||||
@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString();
|
||||
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
||||
</TableCell>
|
||||
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
||||
{m.roleDetail ?? '—'}
|
||||
{m.roleDetail ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(m.startDate)}</TableCell>
|
||||
<TableCell>
|
||||
{m.endDate ? (
|
||||
formatDate(m.endDate)
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
||||
Primary
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
function formatDimensions(y: OwnedYachtRow): string | null {
|
||||
if (y.lengthFt || y.widthFt) {
|
||||
const length = y.lengthFt ?? '—';
|
||||
const width = y.widthFt ?? '—';
|
||||
const length = y.lengthFt ?? '-';
|
||||
const width = y.widthFt ?? '-';
|
||||
return `${length} × ${width} ft`;
|
||||
}
|
||||
if (y.lengthM || y.widthM) {
|
||||
const length = y.lengthM ?? '—';
|
||||
const width = y.widthM ?? '—';
|
||||
const length = y.lengthM ?? '-';
|
||||
const width = y.widthM ?? '-';
|
||||
return `${length} × ${width} m`;
|
||||
}
|
||||
return null;
|
||||
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
|
||||
{dims ? (
|
||||
<span className="text-sm">{dims}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{y.hullNumber ? (
|
||||
<span className="text-sm">{y.hullNumber}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
<InlineCountryField
|
||||
value={company.incorporationCountryIso}
|
||||
onSave={async (iso) => {
|
||||
// Wipe subdivision when country flips — codes are country-scoped.
|
||||
// Wipe subdivision when country flips - codes are country-scoped.
|
||||
await mutation.mutateAsync({
|
||||
incorporationCountryIso: iso,
|
||||
incorporationSubdivisionIso: null,
|
||||
@@ -175,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
variant="textarea"
|
||||
value={company.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="No notes — click to add"
|
||||
emptyText="No notes - click to add"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ function ActivityFeedInner() {
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No recent activity yet — your team's actions (interests created, stages changed,
|
||||
No recent activity yet - your team's actions (interests created, stages changed,
|
||||
invoices sent) will appear here.
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { usePortContext } from '@/providers/port-provider';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { KpiCardsWithBoundary } from './kpi-cards';
|
||||
import { ActivityFeed } from './activity-feed';
|
||||
@@ -12,29 +13,53 @@ import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { MyRemindersRail } from './my-reminders-rail';
|
||||
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
|
||||
const RANGE_LABELS: Record<DateRange, string> = {
|
||||
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
};
|
||||
|
||||
function rangeLabel(range: DateRange): string {
|
||||
if (isCustomRange(range)) {
|
||||
const fmt: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
};
|
||||
const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
|
||||
const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
|
||||
return `${from} – ${to}`;
|
||||
}
|
||||
return PRESET_LABELS[range];
|
||||
}
|
||||
|
||||
export function DashboardShell() {
|
||||
const [range, setRange] = useState<DateRange>('30d');
|
||||
const { currentPort } = usePortContext();
|
||||
const portName = currentPort?.name ?? 'this port';
|
||||
|
||||
// Use a partial query-key prefix (no range segment) for invalidations.
|
||||
// Reading: "any cached analytics result, regardless of range, please
|
||||
// refetch on this event." This avoids any chance that a custom-range
|
||||
// object literal hashes differently than the one stored in the cache,
|
||||
// and keeps the invalidation surface broad enough to refresh whichever
|
||||
// range the user is currently looking at.
|
||||
useRealtimeInvalidation({
|
||||
'interest:stageChanged': [
|
||||
['analytics', 'pipeline_funnel', range],
|
||||
['analytics', 'lead_source_attribution', range],
|
||||
['analytics', 'pipeline_funnel'],
|
||||
['analytics', 'lead_source_attribution'],
|
||||
['dashboard', 'kpis'],
|
||||
],
|
||||
'client:created': [['dashboard', 'kpis']],
|
||||
'berth:statusChanged': [
|
||||
['analytics', 'occupancy_timeline', range],
|
||||
['analytics', 'occupancy_timeline'],
|
||||
['dashboard', 'kpis'],
|
||||
],
|
||||
});
|
||||
@@ -44,8 +69,8 @@ export function DashboardShell() {
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
eyebrow="Overview"
|
||||
description="Live snapshot of your marina activity"
|
||||
kpiLine={<span>{RANGE_LABELS[range]}</span>}
|
||||
description={`Live snapshot of ${portName} activity`}
|
||||
kpiLine={<span>{rangeLabel(range)}</span>}
|
||||
variant="gradient"
|
||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||
/>
|
||||
@@ -54,7 +79,12 @@ export function DashboardShell() {
|
||||
<KpiCardsWithBoundary />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
{/* `items-start` is critical: without it, the right-column aside is
|
||||
stretched to match the chart column's row height, which forces
|
||||
MyRemindersRail (or any other child with `h-full`) to push later
|
||||
children out of the aside's box and into the rows below where
|
||||
ActivityFeed renders. */}
|
||||
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
<WidgetErrorBoundary>
|
||||
<PipelineFunnelChart range={range} />
|
||||
@@ -70,6 +100,11 @@ export function DashboardShell() {
|
||||
</WidgetErrorBoundary>
|
||||
</div>
|
||||
<aside className="min-w-0 space-y-4">
|
||||
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
|
||||
isn't configured for this port. */}
|
||||
<WidgetErrorBoundary>
|
||||
<WebsiteGlanceTile />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<MyRemindersRail />
|
||||
</WidgetErrorBoundary>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value: DateRange;
|
||||
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OPTIONS: Array<{ value: DateRange; label: string }> = [
|
||||
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7d', label: '7d' },
|
||||
{ value: '30d', label: '30d' },
|
||||
{ value: '90d', label: '90d' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Format a custom range as a compact button label, e.g. "Apr 14 – May 4".
|
||||
* Same year omits the year on both sides; different years includes both.
|
||||
*/
|
||||
function formatCustom(range: { from: string; to: string }): string {
|
||||
const from = new Date(`${range.from}T00:00:00.000Z`);
|
||||
const to = new Date(`${range.to}T00:00:00.000Z`);
|
||||
const sameYear = from.getUTCFullYear() === to.getUTCFullYear();
|
||||
const fmt: Intl.DateTimeFormatOptions = sameYear
|
||||
? { month: 'short', day: 'numeric', timeZone: 'UTC' }
|
||||
: { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' };
|
||||
return `${from.toLocaleDateString('en-US', fmt)} – ${to.toLocaleDateString('en-US', fmt)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's date as a YYYY-MM-DD string in UTC. Used as the default for the
|
||||
* "to" picker so users can't accidentally pick a future date by leaving the
|
||||
* field empty.
|
||||
*/
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isCustom = isCustomRange(value);
|
||||
|
||||
// Local state for the popover form. Seeded from the current value if it's
|
||||
// already custom, otherwise defaults to a 14-day window ending today.
|
||||
const [draftFrom, setDraftFrom] = useState<string>(() => {
|
||||
if (isCustom) return value.from;
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() - 14);
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
const [draftTo, setDraftTo] = useState<string>(() => (isCustom ? value.to : todayIso()));
|
||||
|
||||
const today = todayIso();
|
||||
const draftValid =
|
||||
draftFrom !== '' &&
|
||||
draftTo !== '' &&
|
||||
draftFrom <= draftTo &&
|
||||
draftFrom <= today &&
|
||||
draftTo <= today;
|
||||
|
||||
function applyCustom() {
|
||||
if (!draftValid) return;
|
||||
onChange({ kind: 'custom', from: draftFrom, to: draftTo });
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{OPTIONS.map((opt) => {
|
||||
const active = opt.value === value;
|
||||
{PRESETS.map((opt) => {
|
||||
const active = !isCustom && opt.value === value;
|
||||
return (
|
||||
<Button
|
||||
key={opt.value}
|
||||
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom range - popover with two date inputs and an Apply button */}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isCustom}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring inline-flex items-center gap-1',
|
||||
isCustom
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
data-testid="range-custom"
|
||||
>
|
||||
<Calendar className="h-3 w-3" aria-hidden />
|
||||
{isCustom ? formatCustom(value) : 'Custom'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[260px] p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Custom range
|
||||
</div>
|
||||
<label className="block text-xs">
|
||||
<span className="block text-muted-foreground mb-1">From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFrom}
|
||||
/* `max` capped at min(draftTo, today). Without the today
|
||||
cap, users could pick a future From, end up with an
|
||||
empty result, and not understand why. */
|
||||
max={draftTo && draftTo < today ? draftTo : today}
|
||||
onChange={(e) => setDraftFrom(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="block text-muted-foreground mb-1">To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftTo}
|
||||
min={draftFrom || undefined}
|
||||
max={today}
|
||||
onChange={(e) => setDraftTo(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={applyCustom} disabled={!draftValid}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { KPITile } from '@/components/ui/kpi-tile';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
@@ -37,11 +38,19 @@ function KpiTileSkeleton() {
|
||||
}
|
||||
|
||||
export function KpiCards() {
|
||||
// Keying on currentPortId ensures React Query treats a port-resolved fetch
|
||||
// as a different query than the one that fires on first paint when the
|
||||
// store hasn't yet hydrated. Without this, an early null-port fetch could
|
||||
// cache an error and display "-" indefinitely until the staleTime expires.
|
||||
const portId = useUIStore((s) => s.currentPortId);
|
||||
const { data, isLoading, isError } = useQuery<KpiData>({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryKey: ['dashboard', 'kpis', portId],
|
||||
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
|
||||
staleTime: 60_000,
|
||||
retry: 2,
|
||||
// Avoid running until we have a port id - gates against the early
|
||||
// unauth/no-port window where the API would return zeroes/errors.
|
||||
enabled: !!portId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -62,22 +71,22 @@ export function KpiCards() {
|
||||
}> = [
|
||||
{
|
||||
label: 'Total Clients',
|
||||
value: isError ? '—' : String(data?.totalClients ?? 0),
|
||||
value: isError ? '-' : String(data?.totalClients ?? 0),
|
||||
accent: 'brand',
|
||||
},
|
||||
{
|
||||
label: 'Active Interests',
|
||||
value: isError ? '—' : String(data?.activeInterests ?? 0),
|
||||
value: isError ? '-' : String(data?.activeInterests ?? 0),
|
||||
accent: 'teal',
|
||||
},
|
||||
{
|
||||
label: 'Pipeline Value',
|
||||
value: isError ? '—' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
||||
value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
||||
accent: 'success',
|
||||
},
|
||||
{
|
||||
label: 'Occupancy Rate',
|
||||
value: isError ? '—' : formatPercent(data?.occupancyRate ?? 0),
|
||||
value: isError ? '-' : formatPercent(data?.occupancyRate ?? 0),
|
||||
accent: 'purple',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
|
||||
) : !slices.length ? (
|
||||
<EmptyState
|
||||
title="No interests in range"
|
||||
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
|
||||
description="Lights up once new interests are created - tracks where each came from (website, referral, broker)."
|
||||
/>
|
||||
) : (
|
||||
// Percentage radii + center-anchored chart so the pie scales with
|
||||
|
||||
@@ -36,7 +36,7 @@ const PRIORITY_BADGE: Record<string, string> = {
|
||||
/**
|
||||
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
||||
* to the current user (overdue first, then upcoming). Each item links to its
|
||||
* subject — interest preferred, then client, then the generic entity ref.
|
||||
* subject - interest preferred, then client, then the generic entity ref.
|
||||
*
|
||||
* Limited to 6 items; "View all" routes to /reminders.
|
||||
*/
|
||||
@@ -67,11 +67,13 @@ export function MyRemindersRail() {
|
||||
return `/${portSlug}/reminders`;
|
||||
}
|
||||
|
||||
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
||||
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
||||
// tall empty card.
|
||||
// Natural height - the parent dashboard grid uses `items-start` so the
|
||||
// aside column no longer forces this rail to fill the chart column's
|
||||
// height. Stretching here pushed AlertRail out of the aside's box and
|
||||
// into the territory below where ActivityFeed renders, producing a
|
||||
// visible overlap on tall viewports.
|
||||
return (
|
||||
<Card className="xl:h-full">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
@@ -100,7 +102,7 @@ export function MyRemindersRail() {
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="py-3 text-center text-sm text-muted-foreground">
|
||||
All caught up — no reminders.
|
||||
All caught up - no reminders.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
import type {
|
||||
DateRange,
|
||||
LeadSourceAttributionData,
|
||||
MetricBase,
|
||||
OccupancyTimelineData,
|
||||
@@ -18,12 +18,27 @@ interface MetricResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a DateRange (preset or custom) into the URL query params the
|
||||
* /api/v1/analytics route expects: `range=30d` for presets, or
|
||||
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD` for custom.
|
||||
*/
|
||||
function rangeToQuery(range: DateRange): string {
|
||||
if (isCustomRange(range)) {
|
||||
return `range=custom&from=${range.from}&to=${range.to}`;
|
||||
}
|
||||
return `range=${range}`;
|
||||
}
|
||||
|
||||
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
|
||||
return useQuery<T>({
|
||||
// Stringify custom ranges into the cache key so React Query treats
|
||||
// each {from,to} pair as its own query - otherwise switching dates
|
||||
// would never refetch.
|
||||
queryKey: ['analytics', metric, range],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch<MetricResponse<T>>(
|
||||
`/api/v1/analytics?metric=${metric}&range=${range}`,
|
||||
`/api/v1/analytics?metric=${metric}&${rangeToQuery(range)}`,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
79
src/components/dashboard/website-glance-tile.tsx
Normal file
79
src/components/dashboard/website-glance-tile.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Compact "Website at a glance" tile for the main sales dashboard. Shows
|
||||
* pageviews today + active visitors right now + a deep-link to the full
|
||||
* /website-analytics page. Soft-fails (renders nothing) when Umami isn't
|
||||
* configured for this port - so the dashboard doesn't get cluttered with
|
||||
* a "configure Umami" prompt that the user already saw on the dedicated
|
||||
* page.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Globe, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
useUmamiActive,
|
||||
useUmamiStats,
|
||||
} from '@/components/website-analytics/use-website-analytics';
|
||||
|
||||
export function WebsiteGlanceTile() {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const stats = useUmamiStats('today');
|
||||
const active = useUmamiActive('today');
|
||||
|
||||
// Hide the tile entirely if Umami isn't configured - this dashboard is
|
||||
// for sales, not for prompting the operator into integration setup.
|
||||
if (
|
||||
stats.data?.error === 'umami_not_configured' ||
|
||||
active.data?.error === 'umami_not_configured'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const today = stats.data?.data?.pageviews?.value ?? 0;
|
||||
const activeNow = active.data?.data?.visitors ?? 0;
|
||||
const loading = stats.isLoading || active.isLoading;
|
||||
|
||||
return (
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={portSlug ? (`/${portSlug}/website-analytics` as any) : ('/' as any)}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="relative overflow-hidden p-3 sm:p-5 transition-shadow hover:shadow-md">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
<Globe className="h-3 w-3" aria-hidden />
|
||||
Website today
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-7 w-20" />
|
||||
) : (
|
||||
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
|
||||
{today.toLocaleString()}
|
||||
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
{activeNow} active right now
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
|
||||
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
|
||||
toast.info(
|
||||
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} — opens in PR8 wizard`,
|
||||
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');
|
||||
|
||||
@@ -26,7 +26,7 @@ interface DocumentListProps {
|
||||
interestId?: string;
|
||||
clientId?: string;
|
||||
/** Override the default empty state ("No documents yet.") with a contextual
|
||||
* CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
||||
* CTA - e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
||||
emptyState?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
|
||||
};
|
||||
|
||||
const getSignerProgress = (doc: DocumentRow) => {
|
||||
if (!doc.signers) return '—';
|
||||
if (!doc.signers) return '-';
|
||||
const signed = doc.signers.filter((s) => s.status === 'signed').length;
|
||||
return `${signed}/${doc.signers.length} signed`;
|
||||
};
|
||||
|
||||
@@ -180,7 +180,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
||||
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
|
||||
</StatusPill>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '—'}
|
||||
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
|
||||
|
||||
@@ -22,14 +22,14 @@ import {
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
/** Required for the EOI's top paragraph (Section 2) — without these the
|
||||
/** Required for the EOI's top paragraph (Section 2) - without these the
|
||||
* document is unsignable, so generation is blocked. Yacht and berth fields
|
||||
* belong to Section 3 and may be left blank. */
|
||||
interface EoiPrerequisites {
|
||||
hasName: boolean;
|
||||
hasEmail: boolean;
|
||||
hasAddress: boolean;
|
||||
/** Optional — info-only checks. Generation proceeds without them. */
|
||||
/** Optional - info-only checks. Generation proceeds without them. */
|
||||
hasYacht: boolean;
|
||||
hasBerth: boolean;
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export function EoiGenerateDialog({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Optional (Section 3 — left blank if absent)
|
||||
Optional (Section 3 - left blank if absent)
|
||||
</p>
|
||||
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3 text-sm">
|
||||
|
||||
@@ -156,7 +156,7 @@ export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCar
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Amount — prominent */}
|
||||
{/* Amount - prominent */}
|
||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
||||
{amountFormatted}
|
||||
</p>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function getExpenseColumns({
|
||||
className="font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.establishmentName ?? '—'}
|
||||
{row.original.establishmentName ?? '-'}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -113,7 +113,7 @@ export function getExpenseColumns({
|
||||
header: 'Category',
|
||||
cell: ({ getValue }) => {
|
||||
const cat = getValue() as string | null;
|
||||
if (!cat) return <span className="text-muted-foreground">—</span>;
|
||||
if (!cat) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{cat.replace(/_/g, ' ')}
|
||||
|
||||
@@ -146,19 +146,19 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Category</span>
|
||||
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '—'}</p>
|
||||
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Payment Method</span>
|
||||
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}</p>
|
||||
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Payer</span>
|
||||
<p className="mt-0.5">{expense.payer ?? '—'}</p>
|
||||
<p className="mt-0.5">{expense.payer ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description</span>
|
||||
<p className="mt-0.5">{expense.description ?? '—'}</p>
|
||||
<p className="mt-0.5">{expense.description ?? '-'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -30,7 +30,7 @@ interface InlineStagePickerProps {
|
||||
|
||||
/**
|
||||
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
|
||||
* for inline editing — user clicks the chip, picks a new stage from the
|
||||
* for inline editing - user clicks the chip, picks a new stage from the
|
||||
* popover (with optional reason), commits in one click. The popover stays
|
||||
* compact: a small reason field above the stage list, and clicking any stage
|
||||
* fires the mutation immediately.
|
||||
@@ -140,7 +140,7 @@ export function InlineStagePicker({
|
||||
isCurrent && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||
{/* Colored chip (mirrors the inline stage badge) - turns
|
||||
the picker into a visual scan rather than just a list. */}
|
||||
<span
|
||||
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function getInterestColumns({
|
||||
className="truncate font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.clientName ?? '—'}
|
||||
{row.original.clientName ?? '-'}
|
||||
</Link>
|
||||
{notesCount > 0 ? (
|
||||
<span
|
||||
@@ -99,7 +99,7 @@ export function getInterestColumns({
|
||||
header: 'Berth',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.berthId || !row.original.berthMooringNumber) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
@@ -150,7 +150,7 @@ export function getInterestColumns({
|
||||
header: 'Category',
|
||||
cell: ({ getValue }) => {
|
||||
const cat = getValue() as string | null;
|
||||
if (!cat) return <span className="text-muted-foreground">—</span>;
|
||||
if (!cat) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{CATEGORY_LABELS[cat] ?? cat}
|
||||
@@ -164,7 +164,7 @@ export function getInterestColumns({
|
||||
header: 'Source',
|
||||
cell: ({ getValue }) => {
|
||||
const source = getValue() as string | null;
|
||||
if (!source) return <span className="text-muted-foreground">—</span>;
|
||||
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
@@ -178,7 +178,7 @@ export function getInterestColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const rowTags = row.original.tags ?? [];
|
||||
if (rowTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
||||
if (rowTags.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rowTags.slice(0, 3).map((tag) => (
|
||||
@@ -203,7 +203,7 @@ export function getInterestColumns({
|
||||
cell: ({ row }) => {
|
||||
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
||||
if (!lastIso) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
return <span className="text-muted-foreground text-sm">-</span>;
|
||||
}
|
||||
const d = new Date(lastIso);
|
||||
return (
|
||||
|
||||
@@ -30,9 +30,9 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
|
||||
lost_other_marina: { label: 'Lost — other marina', className: 'bg-rose-100 text-rose-700' },
|
||||
lost_unqualified: { label: 'Lost — unqualified', className: 'bg-rose-100 text-rose-700' },
|
||||
lost_no_response: { label: 'Lost — no response', className: 'bg-rose-100 text-rose-700' },
|
||||
lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' },
|
||||
lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' },
|
||||
lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
|
||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ interface InterestDetailHeaderProps {
|
||||
clientPrimaryPhone?: string | null;
|
||||
clientPrimaryPhoneE164?: string | null;
|
||||
/** Pending/snoozed reminders attached to this interest. Drives the
|
||||
* alarm-bell badge on the header — surfaces follow-ups so the rep
|
||||
* alarm-bell badge on the header - surfaces follow-ups so the rep
|
||||
* doesn't have to remember to check /reminders. */
|
||||
activeReminderCount?: number;
|
||||
berthId: string | null;
|
||||
@@ -107,7 +107,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||
const isClosed = !!interest.outcome;
|
||||
|
||||
// Contact deep-links — resolved from the linked client's primary channels.
|
||||
// Contact deep-links - resolved from the linked client's primary channels.
|
||||
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
||||
// stripping non-digits from the display value when the canonical form is
|
||||
// missing.
|
||||
@@ -258,7 +258,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact deep-links — let the rep email / call / WhatsApp the
|
||||
{/* Contact deep-links - let the rep email / call / WhatsApp the
|
||||
client without leaving the interest workspace. Resolved from
|
||||
the linked client's primary contact channels (server-side
|
||||
fetch in getInterestById). */}
|
||||
@@ -343,7 +343,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
the won/lost meaning (green vs rose). Adding a "Won" /
|
||||
"Lost" text label inline blew out the cluster width and
|
||||
forced the Email/Call/WhatsApp action-chip row above to
|
||||
stack vertically — bad trade. From sm up, the full
|
||||
stack vertically - bad trade. From sm up, the full
|
||||
"Mark won" / "Close as lost" labels read clearly. */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -36,7 +36,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
||||
});
|
||||
|
||||
const prerequisites = {
|
||||
// Required (EOI Section 2 — top paragraph): name, address, email.
|
||||
// Required (EOI Section 2 - top paragraph): name, address, email.
|
||||
hasName: Boolean(interest?.clientName),
|
||||
hasEmail: Boolean(interest?.clientPrimaryEmail),
|
||||
hasAddress: Boolean(interest?.clientHasAddress),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user