feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||||
|
|
||||||
|
export default function OcrSettingsPage() {
|
||||||
|
return <OcrSettingsForm />;
|
||||||
|
}
|
||||||
@@ -149,6 +149,12 @@ const SECTIONS: AdminSection[] = [
|
|||||||
description: 'Initial-setup wizard for fresh ports.',
|
description: 'Initial-setup wizard for fresh ports.',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: 'ocr',
|
||||||
|
label: 'Receipt OCR',
|
||||||
|
description: 'Configure the AI provider used by the mobile receipt scanner.',
|
||||||
|
icon: ScrollText,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function AdminLandingPage({
|
export default async function AdminLandingPage({
|
||||||
|
|||||||
5
src/app/(dashboard)/[portSlug]/alerts/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/alerts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
return <AlertsPageShell />;
|
||||||
|
}
|
||||||
50
src/app/(scanner)/[portSlug]/scan/layout.tsx
Normal file
50
src/app/(scanner)/[portSlug]/scan/layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export default async function ScannerLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user) redirect('/login');
|
||||||
|
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const port = await db.query.ports.findFirst({
|
||||||
|
where: eq(portsTable.slug, portSlug),
|
||||||
|
});
|
||||||
|
if (!port) redirect('/login');
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="min-h-[100dvh] bg-background">{children}</div>
|
||||||
|
</PortProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-port PWA manifest. Scoped to `/<portSlug>/scan` so the install
|
||||||
|
* only covers the scanner page, not the rest of the CRM. Each port
|
||||||
|
* gets its own homescreen icon labeled with its name.
|
||||||
|
*/
|
||||||
|
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||||
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
name: `${portName} — Scanner`,
|
||||||
|
short_name: 'Scanner',
|
||||||
|
description: `Capture and submit expense receipts for ${portName}.`,
|
||||||
|
start_url: `/${portSlug}/scan`,
|
||||||
|
scope: `/${portSlug}/scan`,
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
theme_color: '#3a7bc8',
|
||||||
|
icons: [
|
||||||
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{
|
||||||
|
src: '/icon-512-maskable.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(manifest, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/manifest+json',
|
||||||
|
'Cache-Control': 'public, max-age=300, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/app/(scanner)/[portSlug]/scan/page.tsx
Normal file
11
src/app/(scanner)/[portSlug]/scan/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { ScanShell } from '@/components/scan/scan-shell';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Scan receipt — Port Nimara',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScanPage() {
|
||||||
|
return <ScanShell />;
|
||||||
|
}
|
||||||
25
src/app/api/v1/admin/alerts/run-engine/route.ts
Normal file
25
src/app/api/v1/admin/alerts/run-engine/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin trigger for an immediate alert engine sweep over the caller's port.
|
||||||
|
* Useful for manual ops ("re-evaluate now after I fixed a rule") and
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const POST = withAuth(async (_req, ctx) => {
|
||||||
|
try {
|
||||||
|
if (!ctx.isSuperAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const summary = await runAlertEngineForPorts([ctx.portId]);
|
||||||
|
return NextResponse.json({ data: summary });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,29 +1,76 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseQuery } from '@/lib/api/route-helpers';
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
import { listAuditLogs } from '@/lib/services/audit.service';
|
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { user } from '@/lib/db/schema/users';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
const auditQuerySchema = z.object({
|
const auditQuerySchema = z.object({
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
||||||
entityType: z.string().optional(),
|
entityType: z.string().optional(),
|
||||||
action: z.string().optional(),
|
action: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
entityId: z.string().optional(),
|
entityId: z.string().optional(),
|
||||||
dateFrom: z.string().optional(),
|
dateFrom: z.string().optional(),
|
||||||
dateTo: z.string().optional(),
|
dateTo: z.string().optional(),
|
||||||
|
/** Free-text query against the tsvector `search_text` column. */
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
|
/** Cursor pair from the previous page's response. */
|
||||||
|
cursorAt: z.string().optional(),
|
||||||
|
cursorId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'view_audit_log', async (req, ctx) => {
|
withPermission('admin', 'view_audit_log', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const query = parseQuery(req, auditQuerySchema);
|
const query = parseQuery(req, auditQuerySchema);
|
||||||
const result = await listAuditLogs(ctx.portId, query);
|
const cursor =
|
||||||
return NextResponse.json(result);
|
query.cursorAt && query.cursorId
|
||||||
|
? { createdAt: new Date(query.cursorAt), id: query.cursorId }
|
||||||
|
: undefined;
|
||||||
|
const { rows, nextCursor } = await searchAuditLogs({
|
||||||
|
portId: ctx.portId,
|
||||||
|
q: query.search,
|
||||||
|
userId: query.userId,
|
||||||
|
action: query.action,
|
||||||
|
entityType: query.entityType,
|
||||||
|
entityId: query.entityId,
|
||||||
|
from: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||||
|
to: query.dateTo ? new Date(query.dateTo) : undefined,
|
||||||
|
cursor,
|
||||||
|
limit: query.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve actor emails in one batched query so the table can show
|
||||||
|
// who did what without N+1 round trips.
|
||||||
|
const userIds = Array.from(
|
||||||
|
new Set(rows.map((r) => r.userId).filter((id): id is string => Boolean(id))),
|
||||||
|
);
|
||||||
|
const userRows = userIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: user.id, email: user.email, name: user.name })
|
||||||
|
.from(user)
|
||||||
|
.where(inArray(user.id, userIds))
|
||||||
|
: [];
|
||||||
|
const userMap = new Map(userRows.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
const data = rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
nextCursor: nextCursor
|
||||||
|
? { createdAt: nextCursor.createdAt.toISOString(), id: nextCursor.id }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/app/api/v1/admin/ocr-settings/route.ts
Normal file
61
src/app/api/v1/admin/ocr-settings/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||||
|
|
||||||
|
const saveSchema = z.object({
|
||||||
|
/** When 'global', requires super_admin and stores at port_id=null. */
|
||||||
|
scope: z.enum(['port', 'global']),
|
||||||
|
provider: z.enum(['openai', 'claude']),
|
||||||
|
model: z.string().min(1),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
clearApiKey: z.boolean().optional(),
|
||||||
|
useGlobal: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET = withAuth(async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const scope = url.searchParams.get('scope') ?? 'port';
|
||||||
|
if (scope === 'global' && !ctx.isSuperAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
|
||||||
|
return NextResponse.json({ data: config, models: OCR_MODELS });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PUT = withAuth(async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, saveSchema);
|
||||||
|
if (body.scope === 'global' && !ctx.isSuperAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const validModels = OCR_MODELS[body.provider];
|
||||||
|
if (!validModels.includes(body.model)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid model for provider ${body.provider}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await saveOcrConfig(
|
||||||
|
body.scope === 'global' ? null : ctx.portId,
|
||||||
|
{
|
||||||
|
provider: body.provider,
|
||||||
|
model: body.model,
|
||||||
|
apiKey: body.apiKey,
|
||||||
|
clearApiKey: body.clearApiKey,
|
||||||
|
useGlobal: body.useGlobal,
|
||||||
|
},
|
||||||
|
ctx.userId,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
27
src/app/api/v1/admin/ocr-settings/test/route.ts
Normal file
27
src/app/api/v1/admin/ocr-settings/test/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||||
|
import { testProvider } from '@/lib/services/ocr-providers';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
provider: z.enum(['openai', 'claude']),
|
||||||
|
model: z.string().min(1),
|
||||||
|
apiKey: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withAuth(async (req) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, schema);
|
||||||
|
if (!OCR_MODELS[body.provider].includes(body.model)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid model' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const result = await testProvider(body.provider, body.apiKey, body.model);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
11
src/app/api/v1/alerts/[id]/acknowledge/route.ts
Normal file
11
src/app/api/v1/alerts/[id]/acknowledge/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { acknowledgeAlert } from '@/lib/services/alerts.service';
|
||||||
|
|
||||||
|
export const POST = withAuth(async (_req, ctx, params) => {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
|
await acknowledgeAlert(id, ctx.userId);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
11
src/app/api/v1/alerts/[id]/dismiss/route.ts
Normal file
11
src/app/api/v1/alerts/[id]/dismiss/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { dismissAlert } from '@/lib/services/alerts.service';
|
||||||
|
|
||||||
|
export const POST = withAuth(async (_req, ctx, params) => {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
|
await dismissAlert(id, ctx.userId);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
24
src/app/api/v1/alerts/count/route.ts
Normal file
24
src/app/api/v1/alerts/count/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { alerts } from '@/lib/db/schema/insights';
|
||||||
|
|
||||||
|
export const GET = withAuth(async (_req, ctx) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ severity: alerts.severity, count: sql<number>`count(*)::int` })
|
||||||
|
.from(alerts)
|
||||||
|
.where(
|
||||||
|
and(eq(alerts.portId, ctx.portId), isNull(alerts.resolvedAt), isNull(alerts.dismissedAt)),
|
||||||
|
)
|
||||||
|
.groupBy(alerts.severity);
|
||||||
|
|
||||||
|
const bySeverity = { info: 0, warning: 0, critical: 0 } as Record<string, number>;
|
||||||
|
let total = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
bySeverity[r.severity] = r.count;
|
||||||
|
total += r.count;
|
||||||
|
}
|
||||||
|
return NextResponse.json({ total, bySeverity });
|
||||||
|
});
|
||||||
26
src/app/api/v1/alerts/route.ts
Normal file
26
src/app/api/v1/alerts/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { listAlertsForPort } from '@/lib/services/alerts.service';
|
||||||
|
|
||||||
|
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||||
|
|
||||||
|
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
|
||||||
|
|
||||||
|
const rows = await listAlertsForPort(ctx.portId, {
|
||||||
|
includeDismissed: status !== 'open',
|
||||||
|
includeResolved: status !== 'open',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter to the requested status bucket so callers don't see overlap.
|
||||||
|
const filtered = rows.filter((a) => {
|
||||||
|
if (status === 'open') return !a.dismissedAt && !a.resolvedAt;
|
||||||
|
if (status === 'dismissed') return Boolean(a.dismissedAt) && !a.resolvedAt;
|
||||||
|
if (status === 'resolved') return Boolean(a.resolvedAt);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: filtered });
|
||||||
|
});
|
||||||
35
src/app/api/v1/analytics/route.ts
Normal file
35
src/app/api/v1/analytics/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import {
|
||||||
|
ALL_RANGES,
|
||||||
|
getLeadSourceAttribution,
|
||||||
|
getOccupancyTimeline,
|
||||||
|
getPipelineFunnel,
|
||||||
|
getRevenueBreakdown,
|
||||||
|
type DateRange,
|
||||||
|
type MetricBase,
|
||||||
|
} from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||||
|
pipeline_funnel: getPipelineFunnel,
|
||||||
|
occupancy_timeline: getOccupancyTimeline,
|
||||||
|
revenue_breakdown: getRevenueBreakdown,
|
||||||
|
lead_source_attribution: getLeadSourceAttribution,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(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;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await METRICS[metric](ctx.portId, range);
|
||||||
|
return NextResponse.json({ metric, range, data });
|
||||||
|
});
|
||||||
18
src/app/api/v1/expenses/[id]/clear-duplicate/route.ts
Normal file
18
src/app/api/v1/expenses/[id]/clear-duplicate/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { clearDuplicate } from '@/lib/services/expense-dedup.service';
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('expenses', 'edit', async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
|
await clearDuplicate(id, ctx.portId);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
28
src/app/api/v1/expenses/[id]/merge/route.ts
Normal file
28
src/app/api/v1/expenses/[id]/merge/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
||||||
|
|
||||||
|
const mergeSchema = z.object({
|
||||||
|
/** Surviving expense id — typically the row's existing `duplicateOf` pointer. */
|
||||||
|
targetId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('expenses', 'edit', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const sourceId = params.id;
|
||||||
|
if (!sourceId) {
|
||||||
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const body = await parseBody(req, mergeSchema);
|
||||||
|
await mergeDuplicate(sourceId, body.targetId, ctx.portId);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -2,24 +2,62 @@ import { NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { scanReceipt } from '@/lib/services/receipt-scanner';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||||
|
import { runOcr, type ParsedReceipt } from '@/lib/services/ocr-providers';
|
||||||
|
|
||||||
|
const EMPTY: ParsedReceipt = {
|
||||||
|
establishment: null,
|
||||||
|
date: null,
|
||||||
|
amount: null,
|
||||||
|
currency: null,
|
||||||
|
lineItems: [],
|
||||||
|
confidence: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('expenses', 'create', async (req, _ctx) => {
|
withPermission('expenses', 'create', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const mimeType = file.type || 'image/jpeg';
|
const mimeType = file.type || 'image/jpeg';
|
||||||
|
|
||||||
const result = await scanReceipt(buffer, mimeType);
|
const config = await getResolvedOcrConfig(ctx.portId);
|
||||||
|
if (!config.apiKey) {
|
||||||
|
// Manual-entry path — no OCR configured. Frontend will show the
|
||||||
|
// verify form with empty fields so the user can fill it in.
|
||||||
|
return NextResponse.json({
|
||||||
|
data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ data: result });
|
try {
|
||||||
|
const parsed = await runOcr({
|
||||||
|
provider: config.provider,
|
||||||
|
model: config.model,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
imageBuffer: buffer,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
data: { parsed, source: 'ai', provider: config.provider, model: config.model },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
||||||
|
// Provider hiccup — degrade to manual entry rather than 500-ing.
|
||||||
|
return NextResponse.json({
|
||||||
|
data: {
|
||||||
|
parsed: EMPTY,
|
||||||
|
source: 'manual',
|
||||||
|
reason: 'provider-error',
|
||||||
|
providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Search } from 'lucide-react';
|
import { Search, X } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/shared/data-table';
|
import { DataTable } from '@/components/shared/data-table';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -23,13 +25,19 @@ interface AuditEntry {
|
|||||||
userId: string | null;
|
userId: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string | null;
|
||||||
fieldChanged: string | null;
|
fieldChanged: string | null;
|
||||||
oldValue: Record<string, unknown> | null;
|
oldValue: Record<string, unknown> | null;
|
||||||
newValue: Record<string, unknown> | null;
|
newValue: Record<string, unknown> | null;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
ipAddress: string | null;
|
ipAddress: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
actor: { id: string; email: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditResponse {
|
||||||
|
data: AuditEntry[];
|
||||||
|
pagination: { nextCursor: { createdAt: string; id: string } | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
@@ -40,6 +48,8 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
restore: 'bg-teal-500',
|
restore: 'bg-teal-500',
|
||||||
login: 'bg-gray-500',
|
login: 'bg-gray-500',
|
||||||
permission_denied: 'bg-red-800',
|
permission_denied: 'bg-red-800',
|
||||||
|
merge: 'bg-purple-500',
|
||||||
|
revert: 'bg-amber-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENTITY_TYPES = [
|
const ENTITY_TYPES = [
|
||||||
@@ -58,40 +68,96 @@ const ENTITY_TYPES = [
|
|||||||
'webhook',
|
'webhook',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function useDebounced<T>(value: T, ms = 300): T {
|
||||||
|
const [v, setV] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setV(value), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, ms]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
export function AuditLogList() {
|
export function AuditLogList() {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<{
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
} | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [total, setTotal] = useState(0);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
|
||||||
const [actionFilter, setActionFilter] = useState<string>('all');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
// Filter state — debounce text inputs.
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [entityType, setEntityType] = useState<string>('all');
|
||||||
|
const [action, setAction] = useState<string>('all');
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounced(search);
|
||||||
|
const debouncedUserId = useDebounced(userId);
|
||||||
|
|
||||||
|
const queryString = useMemo(() => {
|
||||||
|
const params = new URLSearchParams({ limit: '50' });
|
||||||
|
if (entityType !== 'all') params.set('entityType', entityType);
|
||||||
|
if (action !== 'all') params.set('action', action);
|
||||||
|
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||||
|
if (debouncedUserId) params.set('userId', debouncedUserId);
|
||||||
|
if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString());
|
||||||
|
if (dateTo) {
|
||||||
|
const end = new Date(dateTo);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
params.set('dateTo', end.toISOString());
|
||||||
|
}
|
||||||
|
return params.toString();
|
||||||
|
}, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
const fetchFirstPage = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${queryString}`);
|
||||||
page: String(page),
|
|
||||||
limit: '50',
|
|
||||||
});
|
|
||||||
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
|
|
||||||
if (actionFilter !== 'all') params.set('action', actionFilter);
|
|
||||||
if (search) params.set('search', search);
|
|
||||||
|
|
||||||
const res = await apiFetch<{
|
|
||||||
data: AuditEntry[];
|
|
||||||
pagination: { total: number };
|
|
||||||
}>(`/api/v1/admin/audit?${params}`);
|
|
||||||
setEntries(res.data);
|
setEntries(res.data);
|
||||||
setTotal(res.pagination.total);
|
setNextCursor(res.pagination.nextCursor);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, entityTypeFilter, actionFilter, search]);
|
}, [queryString]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (!nextCursor) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(queryString);
|
||||||
|
params.set('cursorAt', nextCursor.createdAt);
|
||||||
|
params.set('cursorId', nextCursor.id);
|
||||||
|
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${params}`);
|
||||||
|
setEntries((prev) => [...prev, ...res.data]);
|
||||||
|
setNextCursor(res.pagination.nextCursor);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [queryString, nextCursor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchLogs();
|
void fetchFirstPage();
|
||||||
}, [fetchLogs]);
|
}, [fetchFirstPage]);
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setSearch('');
|
||||||
|
setEntityType('all');
|
||||||
|
setAction('all');
|
||||||
|
setUserId('');
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilter =
|
||||||
|
Boolean(search) ||
|
||||||
|
entityType !== 'all' ||
|
||||||
|
action !== 'all' ||
|
||||||
|
Boolean(userId) ||
|
||||||
|
Boolean(dateFrom) ||
|
||||||
|
Boolean(dateTo);
|
||||||
|
|
||||||
const columns: ColumnDef<AuditEntry, unknown>[] = [
|
const columns: ColumnDef<AuditEntry, unknown>[] = [
|
||||||
{
|
{
|
||||||
@@ -117,7 +183,7 @@ export function AuditLogList() {
|
|||||||
{row.original.action}
|
{row.original.action}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
size: 100,
|
size: 110,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'entityType',
|
accessorKey: 'entityType',
|
||||||
@@ -125,9 +191,11 @@ export function AuditLogList() {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium capitalize">{row.original.entityType}</span>
|
<span className="font-medium capitalize">{row.original.entityType}</span>
|
||||||
<code className="ml-2 text-xs text-muted-foreground">
|
{row.original.entityId ? (
|
||||||
{row.original.entityId.slice(0, 8)}...
|
<code className="ml-2 text-xs text-muted-foreground">
|
||||||
</code>
|
{row.original.entityId.slice(0, 8)}…
|
||||||
|
</code>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -150,108 +218,166 @@ export function AuditLogList() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'userId',
|
id: 'actor',
|
||||||
header: 'User',
|
header: 'Actor',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<code className="text-xs">
|
const { actor, userId: rawId } = row.original;
|
||||||
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
|
if (actor) {
|
||||||
</code>
|
return (
|
||||||
),
|
<div className="text-sm">
|
||||||
size: 100,
|
<div className="font-medium">{actor.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{actor.email}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rawId) {
|
||||||
|
return <code className="text-xs">{rawId.slice(0, 8)}…</code>;
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-muted-foreground">system</span>;
|
||||||
|
},
|
||||||
|
size: 200,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Audit Log" description={`${total} entries`} />
|
<PageHeader
|
||||||
|
title="Audit Log"
|
||||||
<div className="flex items-center gap-3 mb-4">
|
eyebrow="Admin"
|
||||||
<div className="relative flex-1 max-w-sm">
|
description="Every state change in this port — fully searchable."
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
variant="gradient"
|
||||||
<Input
|
|
||||||
className="pl-9"
|
|
||||||
placeholder="Search..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={entityTypeFilter}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setEntityTypeFilter(v);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-36">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Entities</SelectItem>
|
|
||||||
{ENTITY_TYPES.map((t) => (
|
|
||||||
<SelectItem key={t} value={t}>
|
|
||||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={actionFilter}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setActionFilter(v);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-36">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Actions</SelectItem>
|
|
||||||
<SelectItem value="create">Create</SelectItem>
|
|
||||||
<SelectItem value="update">Update</SelectItem>
|
|
||||||
<SelectItem value="delete">Delete</SelectItem>
|
|
||||||
<SelectItem value="archive">Archive</SelectItem>
|
|
||||||
<SelectItem value="restore">Restore</SelectItem>
|
|
||||||
<SelectItem value="permission_denied">Permission Denied</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={entries}
|
|
||||||
isLoading={loading}
|
|
||||||
getRowId={(row) => row.id}
|
|
||||||
emptyState={
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-muted-foreground">No audit log entries found.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{total > 50 && (
|
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||||
<div className="flex items-center justify-center gap-2 mt-4">
|
<div className="space-y-1.5">
|
||||||
<button
|
<Label htmlFor="audit-search" className="text-xs">
|
||||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
Search
|
||||||
disabled={page <= 1}
|
</Label>
|
||||||
onClick={() => setPage((p) => p - 1)}
|
<div className="relative w-72">
|
||||||
>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
Previous
|
<Input
|
||||||
</button>
|
id="audit-search"
|
||||||
<span className="text-sm text-muted-foreground">
|
className="pl-9"
|
||||||
Page {page} of {Math.ceil(total / 50)}
|
placeholder="entity id, action, vendor…"
|
||||||
</span>
|
value={search}
|
||||||
<button
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
data-testid="audit-search"
|
||||||
disabled={page >= Math.ceil(total / 50)}
|
/>
|
||||||
onClick={() => setPage((p) => p + 1)}
|
</div>
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Entity</Label>
|
||||||
|
<Select value={entityType} onValueChange={setEntityType}>
|
||||||
|
<SelectTrigger className="w-36" data-testid="audit-entity">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All entities</SelectItem>
|
||||||
|
{ENTITY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Action</Label>
|
||||||
|
<Select value={action} onValueChange={setAction}>
|
||||||
|
<SelectTrigger className="w-36" data-testid="audit-action">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All actions</SelectItem>
|
||||||
|
<SelectItem value="create">Create</SelectItem>
|
||||||
|
<SelectItem value="update">Update</SelectItem>
|
||||||
|
<SelectItem value="delete">Delete</SelectItem>
|
||||||
|
<SelectItem value="archive">Archive</SelectItem>
|
||||||
|
<SelectItem value="restore">Restore</SelectItem>
|
||||||
|
<SelectItem value="merge">Merge</SelectItem>
|
||||||
|
<SelectItem value="revert">Revert</SelectItem>
|
||||||
|
<SelectItem value="login">Login</SelectItem>
|
||||||
|
<SelectItem value="permission_denied">Permission denied</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="audit-user" className="text-xs">
|
||||||
|
User id
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="audit-user"
|
||||||
|
className="w-44"
|
||||||
|
placeholder="exact user id"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="audit-from" className="text-xs">
|
||||||
|
From
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="audit-from"
|
||||||
|
type="date"
|
||||||
|
className="w-36"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="audit-to" className="text-xs">
|
||||||
|
To
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="audit-to"
|
||||||
|
type="date"
|
||||||
|
className="w-36"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilter ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters} className="ml-auto">
|
||||||
|
<X className="mr-1.5 h-3 w-3" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={entries}
|
||||||
|
isLoading={loading}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
emptyState={
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">No audit log entries found.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nextCursor ? (
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
data-testid="audit-load-more"
|
||||||
|
>
|
||||||
|
{loadingMore ? 'Loading…' : 'Load more'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
290
src/components/admin/ocr-settings-form.tsx
Normal file
290
src/components/admin/ocr-settings-form.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { CheckCircle2, Eye, EyeOff, Loader2, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
type Provider = 'openai' | 'claude';
|
||||||
|
|
||||||
|
interface ConfigResp {
|
||||||
|
data: {
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
hasApiKey: boolean;
|
||||||
|
useGlobal: boolean;
|
||||||
|
};
|
||||||
|
models: Record<Provider, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scope = 'port' | 'global';
|
||||||
|
|
||||||
|
interface SettingsBlockProps {
|
||||||
|
scope: Scope;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Hide the "use global" checkbox on the global tab. */
|
||||||
|
showUseGlobal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlockProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = ['ocr-settings', scope];
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ConfigResp>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => apiFetch<ConfigResp>(`/api/v1/admin/ocr-settings?scope=${scope}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [provider, setProvider] = useState<Provider>('openai');
|
||||||
|
const [model, setModel] = useState<string>('gpt-4o-mini');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [useGlobal, setUseGlobal] = useState(false);
|
||||||
|
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.data) return;
|
||||||
|
setProvider(data.data.provider);
|
||||||
|
setModel(data.data.model);
|
||||||
|
setUseGlobal(data.data.useGlobal);
|
||||||
|
}, [data?.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (clearApiKey?: boolean) =>
|
||||||
|
apiFetch('/api/v1/admin/ocr-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
scope,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
apiKey: apiKey.length > 0 ? apiKey : undefined,
|
||||||
|
clearApiKey: Boolean(clearApiKey),
|
||||||
|
useGlobal: scope === 'global' ? false : useGlobal,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setApiKey('');
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch<{ ok: boolean; reason?: string }>(`/api/v1/admin/ocr-settings/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { provider, model, apiKey },
|
||||||
|
}),
|
||||||
|
onSuccess: (res) =>
|
||||||
|
setTestStatus(res.ok ? { ok: true } : { ok: false, reason: res.reason ?? 'Unknown' }),
|
||||||
|
onError: (err: unknown) =>
|
||||||
|
setTestStatus({
|
||||||
|
ok: false,
|
||||||
|
reason: err instanceof Error ? err.message : 'Network error',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = data?.models[provider] ?? [];
|
||||||
|
const hasKey = data?.data.hasApiKey ?? false;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{showUseGlobal ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`useGlobal-${scope}`}
|
||||||
|
checked={useGlobal}
|
||||||
|
onCheckedChange={(v) => setUseGlobal(v === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor={`useGlobal-${scope}`} className="text-sm font-medium">
|
||||||
|
Use the global API key for this port
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, this port falls back to the system-wide OCR settings. Per-port
|
||||||
|
provider/model/key are ignored.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
||||||
|
<Select
|
||||||
|
value={provider}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const p = v as Provider;
|
||||||
|
setProvider(p);
|
||||||
|
setModel(data?.models[p][0] ?? '');
|
||||||
|
setTestStatus(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`provider-${scope}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
|
<SelectItem value="claude">Claude (Anthropic)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`model-${scope}`}>Model</Label>
|
||||||
|
<Select value={model} onValueChange={setModel}>
|
||||||
|
<SelectTrigger id={`model-${scope}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{models.map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`apiKey-${scope}`}>API key</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id={`apiKey-${scope}`}
|
||||||
|
type={showKey ? 'text' : 'password'}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKey(e.target.value);
|
||||||
|
setTestStatus(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
aria-label={showKey ? 'Hide key' : 'Show key'}
|
||||||
|
>
|
||||||
|
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Stored encrypted at rest. Never re-displayed after saving.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => save.mutate(false)}
|
||||||
|
disabled={save.isPending}
|
||||||
|
data-testid={`save-${scope}`}
|
||||||
|
>
|
||||||
|
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
|
||||||
|
Save settings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
disabled={test.isPending || apiKey.length === 0}
|
||||||
|
>
|
||||||
|
{test.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
|
||||||
|
Test connection
|
||||||
|
</Button>
|
||||||
|
{hasKey ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => save.mutate(true)}
|
||||||
|
disabled={save.isPending}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
Clear stored key
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{testStatus?.ok ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm text-green-700">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Connection OK
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{testStatus && !testStatus.ok ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm text-destructive">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
{testStatus.reason}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OcrSettingsForm() {
|
||||||
|
const { isSuperAdmin } = usePermissions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Receipt OCR"
|
||||||
|
eyebrow="Admin"
|
||||||
|
description="Configure the AI provider used to read receipts captured via the mobile scanner."
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsBlock
|
||||||
|
scope="port"
|
||||||
|
title="This port"
|
||||||
|
description="Provider and key used when staff at this port scan a receipt."
|
||||||
|
showUseGlobal
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<SettingsBlock
|
||||||
|
scope="global"
|
||||||
|
title="Global default"
|
||||||
|
description="Used by any port that opted into the global key. Super-admin only."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/alerts/alert-bell.tsx
Normal file
84
src/components/alerts/alert-bell.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||||
|
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.
|
||||||
|
const { data: count } = useAlertCount();
|
||||||
|
const { data: list, isLoading } = useAlertList('open', open);
|
||||||
|
useAlertRealtime();
|
||||||
|
|
||||||
|
const total = count?.total ?? 0;
|
||||||
|
const critical = count?.bySeverity.critical ?? 0;
|
||||||
|
const alerts = list?.data ?? [];
|
||||||
|
const top = alerts.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative"
|
||||||
|
aria-label={`Alerts${total > 0 ? ` (${total} active)` : ''}`}
|
||||||
|
data-testid="alert-bell"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="h-5 w-5" />
|
||||||
|
{total > 0 ? (
|
||||||
|
<span
|
||||||
|
key={total}
|
||||||
|
data-testid="alert-bell-badge"
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white shadow-sm ring-2 ring-background animate-badge-pop',
|
||||||
|
critical > 0 ? 'bg-destructive' : 'bg-amber-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{total > 99 ? '99+' : total}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-96 p-0">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<h4 className="text-sm font-semibold">Active alerts</h4>
|
||||||
|
<Link
|
||||||
|
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<ScrollArea className="max-h-[420px]">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : top.length === 0 ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<AlertCardEmpty />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{top.map((a) => (
|
||||||
|
<AlertCard key={a.id} alert={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/alerts/alert-card.tsx
Normal file
116
src/components/alerts/alert-card.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertTriangle, Bell, Check, ExternalLink, Info, ShieldAlert, X } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { AlertRow } from './types';
|
||||||
|
import { useAlertActions } from './use-alerts';
|
||||||
|
|
||||||
|
interface AlertCardProps {
|
||||||
|
alert: AlertRow;
|
||||||
|
/** Hide the side action buttons in compact contexts (e.g. resolved/dismissed history). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_STYLES: Record<string, { stripe: string; icon: typeof Info }> = {
|
||||||
|
info: { stripe: 'bg-[hsl(var(--chart-1))]', icon: Info },
|
||||||
|
warning: { stripe: 'bg-amber-500', icon: AlertTriangle },
|
||||||
|
critical: { stripe: 'bg-destructive', icon: ShieldAlert },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { acknowledge, dismiss } = useAlertActions();
|
||||||
|
const sev = SEVERITY_STYLES[alert.severity] ?? SEVERITY_STYLES.info!;
|
||||||
|
const Icon = sev.icon;
|
||||||
|
const acknowledged = Boolean(alert.acknowledgedAt);
|
||||||
|
const fired = formatDistanceToNow(new Date(alert.firedAt), { addSuffix: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="alert-card"
|
||||||
|
data-severity={alert.severity}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex gap-3 overflow-hidden rounded-lg border border-border bg-card p-3 shadow-xs transition-shadow duration-base ease-spring hover:shadow-sm',
|
||||||
|
acknowledged && 'opacity-70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('absolute inset-y-0 left-0 w-1', sev.stripe)} aria-hidden />
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 h-4 w-4 shrink-0',
|
||||||
|
alert.severity === 'critical' && 'text-destructive',
|
||||||
|
alert.severity === 'warning' && 'text-amber-600',
|
||||||
|
alert.severity === 'info' && 'text-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{alert.title}</p>
|
||||||
|
{acknowledged ? (
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">ack</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{alert.body ? (
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{alert.body}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<span>{fired}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="font-mono text-[10px]">{alert.ruleId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!readOnly ? (
|
||||||
|
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
|
||||||
|
{!acknowledged ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label="Acknowledge"
|
||||||
|
disabled={acknowledge.isPending}
|
||||||
|
onClick={() => acknowledge.mutate(alert.id)}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
disabled={dismiss.isPending}
|
||||||
|
onClick={() => dismiss.mutate(alert.id)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{alert.link ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label="Open"
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onClick={() => router.push(alert.link as any)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertCardEmpty() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
|
||||||
|
<Bell className="mb-2 h-8 w-8 text-muted-foreground/40" aria-hidden />
|
||||||
|
<p className="text-sm font-medium">All clear</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">No active alerts right now.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/alerts/alert-rail.tsx
Normal file
63
src/components/alerts/alert-rail.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||||
|
import { useAlertList, useAlertRealtime } from './use-alerts';
|
||||||
|
|
||||||
|
export function AlertRail() {
|
||||||
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
const { data, isLoading } = useAlertList('open');
|
||||||
|
useAlertRealtime();
|
||||||
|
|
||||||
|
const alerts = data?.data ?? [];
|
||||||
|
// Show first 5 in the rail; surplus pushes user to the full /alerts page.
|
||||||
|
const visible = alerts.slice(0, 5);
|
||||||
|
const overflow = Math.max(alerts.length - visible.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-testid="alert-rail"
|
||||||
|
aria-label="Active alerts"
|
||||||
|
className="flex h-full flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||||
|
<Link
|
||||||
|
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="ml-1 inline h-3 w-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</div>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<AlertCardEmpty />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{visible.map((a) => (
|
||||||
|
<AlertCard key={a.id} alert={a} />
|
||||||
|
))}
|
||||||
|
{overflow > 0 ? (
|
||||||
|
<Link
|
||||||
|
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
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/alerts/alerts-page-shell.tsx
Normal file
66
src/components/alerts/alerts-page-shell.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||||
|
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||||
|
import type { AlertStatus } from './types';
|
||||||
|
|
||||||
|
export function AlertsPageShell() {
|
||||||
|
const [tab, setTab] = useState<AlertStatus>('open');
|
||||||
|
const { data: count } = useAlertCount();
|
||||||
|
const { data, isLoading } = useAlertList(tab);
|
||||||
|
useAlertRealtime();
|
||||||
|
|
||||||
|
const total = count?.total ?? 0;
|
||||||
|
const alerts = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Alerts"
|
||||||
|
eyebrow="Operational"
|
||||||
|
description="Rules-based signals about pipeline, agreements, expenses, and access"
|
||||||
|
kpiLine={
|
||||||
|
<span>
|
||||||
|
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
|
||||||
|
{total} active
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="open" data-testid="tab-open">
|
||||||
|
Active{total > 0 ? ` · ${total}` : ''}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="dismissed" data-testid="tab-dismissed">
|
||||||
|
Dismissed
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="resolved" data-testid="tab-resolved">
|
||||||
|
Resolved
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value={tab} className="mt-4 space-y-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
) : alerts.length === 0 ? (
|
||||||
|
<AlertCardEmpty />
|
||||||
|
) : (
|
||||||
|
alerts.map((a) => <AlertCard key={a.id} alert={a} readOnly={tab !== 'open'} />)
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/alerts/types.ts
Normal file
14
src/components/alerts/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Alert } from '@/lib/db/schema/insights';
|
||||||
|
|
||||||
|
export type AlertRow = Alert;
|
||||||
|
|
||||||
|
export interface AlertListResponse {
|
||||||
|
data: AlertRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertCountResponse {
|
||||||
|
total: number;
|
||||||
|
bySeverity: Record<'info' | 'warning' | 'critical', number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||||
50
src/components/alerts/use-alerts.ts
Normal file
50
src/components/alerts/use-alerts.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import type { AlertCountResponse, AlertListResponse, AlertStatus } from './types';
|
||||||
|
|
||||||
|
export function useAlertList(status: AlertStatus = 'open', enabled = true) {
|
||||||
|
return useQuery<AlertListResponse>({
|
||||||
|
queryKey: ['alerts', status],
|
||||||
|
queryFn: () => apiFetch<AlertListResponse>(`/api/v1/alerts?status=${status}`),
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlertCount() {
|
||||||
|
return useQuery<AlertCountResponse>({
|
||||||
|
queryKey: ['alerts', 'count'],
|
||||||
|
queryFn: () => apiFetch<AlertCountResponse>('/api/v1/alerts/count'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlertActions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alerts'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const acknowledge = useMutation({
|
||||||
|
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/acknowledge`, { method: 'POST' }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
const dismiss = useMutation({
|
||||||
|
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/dismiss`, { method: 'POST' }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { acknowledge, dismiss };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlertRealtime() {
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'alert:created': [['alerts']],
|
||||||
|
'alert:resolved': [['alerts']],
|
||||||
|
'alert:dismissed': [['alerts']],
|
||||||
|
});
|
||||||
|
}
|
||||||
216
src/components/berths/berth-interests-tab.tsx
Normal file
216
src/components/berths/berth-interests-tab.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { Bookmark } from 'lucide-react';
|
||||||
|
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||||
|
|
||||||
|
interface BerthInterestsTabProps {
|
||||||
|
berthId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageFilter = 'all' | 'active' | 'lost';
|
||||||
|
type SortMode = 'newest' | 'stage' | 'category';
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
open: 'Open',
|
||||||
|
details_sent: 'Details Sent',
|
||||||
|
in_communication: 'In Communication',
|
||||||
|
visited: 'Visited',
|
||||||
|
signed_eoi_nda: 'Signed EOI/NDA',
|
||||||
|
deposit_10pct: 'Deposit 10%',
|
||||||
|
contract: 'Contract',
|
||||||
|
completed: 'Completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAGE_ORDER: Record<string, number> = {
|
||||||
|
open: 0,
|
||||||
|
details_sent: 1,
|
||||||
|
in_communication: 2,
|
||||||
|
visited: 3,
|
||||||
|
signed_eoi_nda: 4,
|
||||||
|
deposit_10pct: 5,
|
||||||
|
contract: 6,
|
||||||
|
completed: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_RANK: Record<string, number> = {
|
||||||
|
hot_lead: 0,
|
||||||
|
specific_qualified: 1,
|
||||||
|
general_interest: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
hot_lead: 'Hot Lead',
|
||||||
|
specific_qualified: 'Specific Qualified',
|
||||||
|
general_interest: 'General Interest',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
website: 'Website',
|
||||||
|
manual: 'Manual',
|
||||||
|
referral: 'Referral',
|
||||||
|
broker: 'Broker',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ListResponse {
|
||||||
|
data: InterestRow[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const [stage, setStage] = useState<StageFilter>('all');
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>('newest');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ListResponse>({
|
||||||
|
queryKey: ['interests', 'by-berth', berthId],
|
||||||
|
queryFn: () => apiFetch<ListResponse>(`/api/v1/interests?berthId=${berthId}&limit=200`),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'interest:created': [['interests', 'by-berth', berthId]],
|
||||||
|
'interest:updated': [['interests', 'by-berth', berthId]],
|
||||||
|
'interest:stageChanged': [['interests', 'by-berth', berthId]],
|
||||||
|
'interest:archived': [['interests', 'by-berth', berthId]],
|
||||||
|
'interest:berthLinked': [['interests', 'by-berth', berthId]],
|
||||||
|
'interest:berthUnlinked': [['interests', 'by-berth', berthId]],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo<InterestRow[]>(() => {
|
||||||
|
const all = data?.data ?? [];
|
||||||
|
const filtered = all.filter((i) => {
|
||||||
|
if (stage === 'active') return i.pipelineStage !== 'completed' && !i.archivedAt;
|
||||||
|
if (stage === 'lost') return Boolean(i.archivedAt);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
if (sortMode === 'stage') {
|
||||||
|
const sa = STAGE_ORDER[a.pipelineStage] ?? 99;
|
||||||
|
const sb = STAGE_ORDER[b.pipelineStage] ?? 99;
|
||||||
|
if (sa !== sb) return sb - sa; // furthest along first
|
||||||
|
}
|
||||||
|
if (sortMode === 'category') {
|
||||||
|
const ca = CATEGORY_RANK[a.leadCategory ?? ''] ?? 99;
|
||||||
|
const cb = CATEGORY_RANK[b.leadCategory ?? ''] ?? 99;
|
||||||
|
if (ca !== cb) return ca - cb; // hottest first
|
||||||
|
}
|
||||||
|
// Default + tiebreaker: newest first.
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [data?.data, stage, sortMode]);
|
||||||
|
|
||||||
|
if (isLoading) return <TableSkeleton />;
|
||||||
|
|
||||||
|
if ((data?.data ?? []).length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Bookmark}
|
||||||
|
title="No interests linked to this berth"
|
||||||
|
description="Interests will appear here when prospects express interest in this specific berth."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{rows.length} of {data?.total ?? 0} interest{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Select value={stage} onValueChange={(v) => setStage(v as StageFilter)}>
|
||||||
|
<SelectTrigger className="h-8 w-[140px]" data-testid="berth-interests-filter">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All stages</SelectItem>
|
||||||
|
<SelectItem value="active">Active only</SelectItem>
|
||||||
|
<SelectItem value="lost">Lost / archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
|
||||||
|
<SelectTrigger className="h-8 w-[160px]" data-testid="berth-interests-sort">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="newest">Newest</SelectItem>
|
||||||
|
<SelectItem value="stage">Stage progress</SelectItem>
|
||||||
|
<SelectItem value="category">Lead category</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<table className="w-full text-sm" data-testid="berth-interests-table">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Client</th>
|
||||||
|
<th className="px-3 py-2">Stage</th>
|
||||||
|
<th className="px-3 py-2">Category</th>
|
||||||
|
<th className="px-3 py-2">Source</th>
|
||||||
|
<th className="px-3 py-2">Last activity</th>
|
||||||
|
<th className="px-3 py-2 text-right" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((i) => (
|
||||||
|
<tr
|
||||||
|
key={i.id}
|
||||||
|
className="border-t border-border last:border-b-0 hover:bg-gradient-brand-soft/40"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-medium text-foreground">
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${i.id}` as never}
|
||||||
|
className="hover:text-brand"
|
||||||
|
>
|
||||||
|
{i.clientName ?? '—'}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{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) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{new Date(i.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
|
||||||
|
<Link href={`/${portSlug}/interests/${i.id}` as never}>Open</Link>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { type DetailTab } from '@/components/shared/detail-layout';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||||
|
import { BerthInterestsTab } from './berth-interests-tab';
|
||||||
|
|
||||||
type BerthData = {
|
type BerthData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -181,7 +182,7 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] {
|
|||||||
{
|
{
|
||||||
id: 'interests',
|
id: 'interests',
|
||||||
label: 'Interests',
|
label: 'Interests',
|
||||||
content: <StubTab label="Interests" />,
|
content: <BerthInterestsTab berthId={berth.id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reservations',
|
id: 'reservations',
|
||||||
|
|||||||
134
src/components/dashboard/chart-card.tsx
Normal file
134
src/components/dashboard/chart-card.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, type ReactNode } from 'react';
|
||||||
|
import { MoreHorizontal, Download, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ChartCardProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
/** Filename stem used for both CSV + PNG exports (no extension). */
|
||||||
|
exportFilename: string;
|
||||||
|
/** Returns CSV content for the current chart data, or null when nothing to export. */
|
||||||
|
toCsv?: () => string | null;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportContainerAsPng(container: HTMLElement, filename: string) {
|
||||||
|
const svg = container.querySelector('svg');
|
||||||
|
if (!svg) return;
|
||||||
|
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||||
|
const { width, height } = svg.getBoundingClientRect();
|
||||||
|
clone.setAttribute('width', String(width));
|
||||||
|
clone.setAttribute('height', String(height));
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
const xml = new XMLSerializer().serializeToString(clone);
|
||||||
|
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('Failed to load chart for export'));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const dpr = window.devicePixelRatio ?? 1;
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) downloadBlob(blob, filename);
|
||||||
|
}, 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
exportFilename,
|
||||||
|
toCsv,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ChartCardProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function onDownloadCsv() {
|
||||||
|
const csv = toCsv?.();
|
||||||
|
if (!csv) return;
|
||||||
|
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `${exportFilename}.csv`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDownloadPng() {
|
||||||
|
if (containerRef.current) {
|
||||||
|
void exportContainerAsPng(containerRef.current, `${exportFilename}.png`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('h-full', className)}>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
{description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
aria-label="Chart options"
|
||||||
|
data-testid="chart-menu"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
{toCsv ? (
|
||||||
|
<DropdownMenuItem onSelect={onDownloadCsv}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem onSelect={onDownloadPng}>
|
||||||
|
<ImageIcon className="mr-2 h-4 w-4" />
|
||||||
|
Download PNG
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div ref={containerRef}>{children}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { KpiCardsWithBoundary } from './kpi-cards';
|
import { KpiCardsWithBoundary } from './kpi-cards';
|
||||||
import { PipelineChart } from './pipeline-chart';
|
|
||||||
import { RevenueForecast } from './revenue-forecast';
|
|
||||||
import { ActivityFeed } from './activity-feed';
|
import { ActivityFeed } from './activity-feed';
|
||||||
|
import { DateRangePicker } from './date-range-picker';
|
||||||
|
import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||||
|
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||||
|
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||||
|
import { LeadSourceChart } from './lead-source-chart';
|
||||||
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
|
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||||
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
const RANGE_LABELS: Record<DateRange, string> = {
|
||||||
|
today: 'Today',
|
||||||
|
'7d': 'Last 7 days',
|
||||||
|
'30d': 'Last 30 days',
|
||||||
|
'90d': 'Last 90 days',
|
||||||
|
};
|
||||||
|
|
||||||
export function DashboardShell() {
|
export function DashboardShell() {
|
||||||
|
const [range, setRange] = useState<DateRange>('30d');
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'interest:stageChanged': [
|
'interest:stageChanged': [
|
||||||
['dashboard', 'pipeline'],
|
['analytics', 'pipeline_funnel', range],
|
||||||
['dashboard', 'forecast'],
|
['analytics', 'lead_source_attribution', range],
|
||||||
|
['dashboard', 'kpis'],
|
||||||
],
|
],
|
||||||
'client:created': [['dashboard', 'kpis']],
|
'client:created': [['dashboard', 'kpis']],
|
||||||
'berth:statusChanged': [
|
'berth:statusChanged': [
|
||||||
|
['analytics', 'occupancy_timeline', range],
|
||||||
['dashboard', 'kpis'],
|
['dashboard', 'kpis'],
|
||||||
['dashboard', 'forecast'],
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,26 +44,37 @@ export function DashboardShell() {
|
|||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
eyebrow="Overview"
|
eyebrow="Overview"
|
||||||
description="Live snapshot of your marina activity"
|
description="Live snapshot of your marina activity"
|
||||||
kpiLine={<span>Last 30 days</span>}
|
kpiLine={<span>{RANGE_LABELS[range]}</span>}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
|
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Row 1: KPI cards */}
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<KpiCardsWithBoundary />
|
<KpiCardsWithBoundary />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Pipeline chart + Revenue forecast */}
|
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||||
<div className="lg:col-span-2">
|
<WidgetErrorBoundary>
|
||||||
<PipelineChart />
|
<PipelineFunnelChart range={range} />
|
||||||
</div>
|
</WidgetErrorBoundary>
|
||||||
<div className="lg:col-span-1">
|
<WidgetErrorBoundary>
|
||||||
<RevenueForecast />
|
<OccupancyTimelineChart range={range} />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<RevenueBreakdownChart range={range} />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<LeadSourceChart range={range} />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
<aside className="min-w-0">
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<AlertRail />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Activity feed */}
|
|
||||||
<ActivityFeed />
|
<ActivityFeed />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
55
src/components/dashboard/date-range-picker.tsx
Normal file
55
src/components/dashboard/date-range-picker.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
interface DateRangePickerProps {
|
||||||
|
value: DateRange;
|
||||||
|
onChange: (next: DateRange) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTIONS: Array<{ value: DateRange; label: string }> = [
|
||||||
|
{ value: 'today', label: 'Today' },
|
||||||
|
{ value: '7d', label: '7d' },
|
||||||
|
{ value: '30d', label: '30d' },
|
||||||
|
{ value: '90d', label: '90d' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Date range"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-lg border border-border bg-muted/40 p-0.5 shadow-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{OPTIONS.map((opt) => {
|
||||||
|
const active = opt.value === value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring',
|
||||||
|
active
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
data-testid={`range-${opt.value}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/dashboard/lead-source-chart.tsx
Normal file
89
src/components/dashboard/lead-source-chart.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
|
||||||
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { ChartCard } from './chart-card';
|
||||||
|
import { useLeadSource } from './use-analytics';
|
||||||
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
range: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'hsl(var(--chart-1))',
|
||||||
|
'hsl(var(--chart-2))',
|
||||||
|
'hsl(var(--chart-3))',
|
||||||
|
'hsl(var(--chart-4))',
|
||||||
|
'hsl(var(--chart-5))',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
website: 'Website',
|
||||||
|
referral: 'Referral',
|
||||||
|
manual: 'Manual',
|
||||||
|
social: 'Social',
|
||||||
|
unspecified: 'Unspecified',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LeadSourceChart({ range }: Props) {
|
||||||
|
const { data, isLoading } = useLeadSource(range);
|
||||||
|
const slices = data?.slices ?? [];
|
||||||
|
|
||||||
|
function toCsv(): string | null {
|
||||||
|
if (!slices.length) return null;
|
||||||
|
const header = 'source,count';
|
||||||
|
const rows = slices.map((s) => `${s.source},${s.count}`);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = slices.map((s) => ({
|
||||||
|
name: SOURCE_LABELS[s.source] ?? s.source,
|
||||||
|
value: s.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="Lead Source Attribution"
|
||||||
|
description="Where new interests came from"
|
||||||
|
exportFilename={`lead-source-${range}`}
|
||||||
|
toCsv={toCsv}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : !slices.length ? (
|
||||||
|
<EmptyState title="No interests in range" />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={90}
|
||||||
|
innerRadius={50}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{chartData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/dashboard/occupancy-timeline-chart.tsx
Normal file
98
src/components/dashboard/occupancy-timeline-chart.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { ChartCard } from './chart-card';
|
||||||
|
import { useOccupancy } from './use-analytics';
|
||||||
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
range: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string) {
|
||||||
|
const d = new Date(`${iso}T00:00:00`);
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OccupancyTimelineChart({ range }: Props) {
|
||||||
|
const { data, isLoading } = useOccupancy(range);
|
||||||
|
const points = data?.points ?? [];
|
||||||
|
const noBerths = points.length > 0 && points[0]?.total === 0;
|
||||||
|
|
||||||
|
function toCsv(): string | null {
|
||||||
|
if (!points.length) return null;
|
||||||
|
const header = 'date,occupied,total,occupancy_pct';
|
||||||
|
const rows = points.map((p) => `${p.date},${p.occupied},${p.total},${p.occupancyPct}`);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="Occupancy Timeline"
|
||||||
|
description="Daily berth occupancy across the range"
|
||||||
|
exportFilename={`occupancy-timeline-${range}`}
|
||||||
|
toCsv={toCsv}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : noBerths ? (
|
||||||
|
<EmptyState title="No berths configured" description="Add berths to see occupancy." />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart
|
||||||
|
data={points.map((p) => ({ ...p, label: shortDate(p.date) }))}
|
||||||
|
margin={{ top: 8, right: 8, left: -16, bottom: 8 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="occupancyGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="hsl(var(--chart-2))" stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
minTickGap={20}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickFormatter={(v: number) => `${v}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
formatter={(value, _name, item) => {
|
||||||
|
const p = item?.payload as { occupied?: number; total?: number } | undefined;
|
||||||
|
return [`${value}% (${p?.occupied ?? 0}/${p?.total ?? 0})`, 'Occupancy'];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="occupancyPct"
|
||||||
|
stroke="hsl(var(--chart-2))"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#occupancyGradient)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/dashboard/pipeline-funnel-chart.tsx
Normal file
89
src/components/dashboard/pipeline-funnel-chart.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { ChartCard } from './chart-card';
|
||||||
|
import { useFunnel } from './use-analytics';
|
||||||
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
open: 'Open',
|
||||||
|
details_sent: 'Details Sent',
|
||||||
|
in_communication: 'In Communication',
|
||||||
|
visited: 'Visited',
|
||||||
|
signed_eoi_nda: 'Signed EOI/NDA',
|
||||||
|
deposit_10pct: 'Deposit 10%',
|
||||||
|
contract: 'Contract',
|
||||||
|
completed: 'Completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
range: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineFunnelChart({ range }: Props) {
|
||||||
|
const { data, isLoading } = useFunnel(range);
|
||||||
|
|
||||||
|
const stages = data?.stages ?? [];
|
||||||
|
const chartData = stages.map((s) => ({
|
||||||
|
stage: STAGE_LABELS[s.stage] ?? s.stage,
|
||||||
|
count: s.count,
|
||||||
|
conversionPct: s.conversionPct,
|
||||||
|
}));
|
||||||
|
const allZero = stages.every((s) => s.count === 0);
|
||||||
|
|
||||||
|
function toCsv(): string | null {
|
||||||
|
if (!stages.length) return null;
|
||||||
|
const header = 'stage,count,conversion_pct';
|
||||||
|
const rows = stages.map((s) => `${s.stage},${s.count},${s.conversionPct}`);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="Pipeline Funnel"
|
||||||
|
description="Interests by stage with conversion rate vs. open"
|
||||||
|
exportFilename={`pipeline-funnel-${range}`}
|
||||||
|
toCsv={toCsv}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : allZero ? (
|
||||||
|
<EmptyState title="No interests in range" description="Try a longer date range." />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="stage"
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
angle={-40}
|
||||||
|
textAnchor="end"
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
formatter={(value, _name, item) => {
|
||||||
|
const pct = (item?.payload as { conversionPct?: number } | undefined)
|
||||||
|
?.conversionPct;
|
||||||
|
return [`${value} (${pct ?? 0}%)`, 'Count'];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/dashboard/revenue-breakdown-chart.tsx
Normal file
82
src/components/dashboard/revenue-breakdown-chart.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { ChartCard } from './chart-card';
|
||||||
|
import { useRevenue } from './use-analytics';
|
||||||
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
range: DateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
draft: 'Draft',
|
||||||
|
sent: 'Sent',
|
||||||
|
paid: 'Paid',
|
||||||
|
overdue: 'Overdue',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RevenueBreakdownChart({ range }: Props) {
|
||||||
|
const { data, isLoading } = useRevenue(range);
|
||||||
|
const bars = data?.bars ?? [];
|
||||||
|
|
||||||
|
function toCsv(): string | null {
|
||||||
|
if (!bars.length) return null;
|
||||||
|
const header = 'status,currency,amount';
|
||||||
|
const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = bars.map((b) => ({
|
||||||
|
label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`,
|
||||||
|
amount: b.amount,
|
||||||
|
currency: b.currency,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="Revenue Breakdown"
|
||||||
|
description="Invoice totals grouped by status and currency"
|
||||||
|
exportFilename={`revenue-breakdown-${range}`}
|
||||||
|
toCsv={toCsv}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : !bars.length ? (
|
||||||
|
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
angle={-30}
|
||||||
|
textAnchor="end"
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
formatter={(value, _name, item) => {
|
||||||
|
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? '';
|
||||||
|
const num = typeof value === 'number' ? value : Number(value);
|
||||||
|
return [`${num.toLocaleString()} ${c}`, 'Amount'];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/dashboard/use-analytics.ts
Normal file
42
src/components/dashboard/use-analytics.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import type {
|
||||||
|
DateRange,
|
||||||
|
LeadSourceAttributionData,
|
||||||
|
MetricBase,
|
||||||
|
OccupancyTimelineData,
|
||||||
|
PipelineFunnelData,
|
||||||
|
RevenueBreakdownData,
|
||||||
|
} from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
|
interface MetricResponse<T> {
|
||||||
|
metric: MetricBase;
|
||||||
|
range: DateRange;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
|
||||||
|
return useQuery<T>({
|
||||||
|
queryKey: ['analytics', metric, range],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch<MetricResponse<T>>(
|
||||||
|
`/api/v1/analytics?metric=${metric}&range=${range}`,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFunnel = (range: DateRange) =>
|
||||||
|
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
|
||||||
|
export const useOccupancy = (range: DateRange) =>
|
||||||
|
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
|
||||||
|
export const useRevenue = (range: DateRange) =>
|
||||||
|
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
|
||||||
|
export const useLeadSource = (range: DateRange) =>
|
||||||
|
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);
|
||||||
@@ -35,6 +35,7 @@ interface HubDoc {
|
|||||||
|
|
||||||
interface HubCounts {
|
interface HubCounts {
|
||||||
all: number;
|
all: number;
|
||||||
|
eoi_queue: number;
|
||||||
awaiting_them: number;
|
awaiting_them: number;
|
||||||
awaiting_me: number;
|
awaiting_me: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
@@ -43,6 +44,7 @@ interface HubCounts {
|
|||||||
|
|
||||||
const TAB_LABELS: Record<DocumentsHubTab, string> = {
|
const TAB_LABELS: Record<DocumentsHubTab, string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
eoi_queue: 'EOI queue',
|
||||||
awaiting_them: 'Awaiting them',
|
awaiting_them: 'Awaiting them',
|
||||||
awaiting_me: 'Awaiting me',
|
awaiting_me: 'Awaiting me',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
@@ -118,6 +120,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
|
|
||||||
const counts: HubCounts = countsResp?.data ?? {
|
const counts: HubCounts = countsResp?.data ?? {
|
||||||
all: 0,
|
all: 0,
|
||||||
|
eoi_queue: 0,
|
||||||
awaiting_them: 0,
|
awaiting_them: 0,
|
||||||
awaiting_me: 0,
|
awaiting_me: 0,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface ExpenseRow {
|
|||||||
receiptFileIds: string[] | null;
|
receiptFileIds: string[] | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** Set by the dedup engine when this expense looks like a duplicate of another. */
|
||||||
|
duplicateOf: string | null;
|
||||||
|
dedupScannedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
||||||
@@ -94,7 +97,8 @@ export function getExpenseColumns({
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.amountUsd ? (
|
row.original.amountUsd ? (
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
${Number(row.original.amountUsd).toLocaleString('en-US', {
|
$
|
||||||
|
{Number(row.original.amountUsd).toLocaleString('en-US', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
})}
|
})}
|
||||||
@@ -125,10 +129,7 @@ export function getExpenseColumns({
|
|||||||
const status = (getValue() as string | null) ?? 'unpaid';
|
const status = (getValue() as string | null) ?? 'unpaid';
|
||||||
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
|
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge variant="outline" className={`capitalize text-xs border ${colorClass}`}>
|
||||||
variant="outline"
|
|
||||||
className={`capitalize text-xs border ${colorClass}`}
|
|
||||||
>
|
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -162,10 +163,7 @@ export function getExpenseColumns({
|
|||||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => onArchive(row.original)}
|
|
||||||
>
|
|
||||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
Archive
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import type { ExpenseRow } from './expense-columns';
|
import type { ExpenseRow } from './expense-columns';
|
||||||
|
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
|
||||||
|
|
||||||
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
||||||
unpaid: 'bg-red-100 text-red-700 border-red-200',
|
unpaid: 'bg-red-100 text-red-700 border-red-200',
|
||||||
@@ -52,9 +53,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
|||||||
|
|
||||||
if (error || !data?.data) {
|
if (error || !data?.data) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 text-center text-muted-foreground">
|
<div className="p-6 text-center text-muted-foreground">Failed to load expense details.</div>
|
||||||
Failed to load expense details.
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +63,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<ExpenseDuplicateBanner expense={expense} />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">
|
<h2 className="text-xl font-semibold">
|
||||||
@@ -107,10 +107,12 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
|||||||
</p>
|
</p>
|
||||||
{expense.amountUsd && expense.currency !== 'USD' && (
|
{expense.amountUsd && expense.currency !== 'USD' && (
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
≈ ${Number(expense.amountUsd).toLocaleString('en-US', {
|
≈ $
|
||||||
|
{Number(expense.amountUsd).toLocaleString('en-US', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
})} USD
|
})}{' '}
|
||||||
|
USD
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -121,10 +123,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
|||||||
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
|
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Badge
|
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
|
||||||
variant="outline"
|
|
||||||
className={`capitalize text-sm border ${statusColor}`}
|
|
||||||
>
|
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -138,15 +137,11 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
|||||||
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Category</span>
|
<span className="text-muted-foreground">Category</span>
|
||||||
<p className="mt-0.5 capitalize">
|
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '—'}</p>
|
||||||
{expense.category?.replace(/_/g, ' ') ?? '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payment Method</span>
|
<span className="text-muted-foreground">Payment Method</span>
|
||||||
<p className="mt-0.5 capitalize">
|
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}</p>
|
||||||
{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payer</span>
|
<span className="text-muted-foreground">Payer</span>
|
||||||
|
|||||||
121
src/components/expenses/expense-duplicate-banner.tsx
Normal file
121
src/components/expenses/expense-duplicate-banner.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ExpenseRow } from './expense-columns';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
expense: ExpenseRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseDuplicateBanner({ expense }: Props) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
|
||||||
|
// Fetch the candidate expense for context.
|
||||||
|
const { data: candidateResp } = useQuery<{ data: ExpenseRow }>({
|
||||||
|
queryKey: ['expenses', expense.duplicateOf],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/expenses/${expense.duplicateOf}`),
|
||||||
|
enabled: Boolean(expense.duplicateOf),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
const candidate = candidateResp?.data;
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const merge = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch(`/api/v1/expenses/${expense.id}/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { targetId: expense.duplicateOf },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
setResolving(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch(`/api/v1/expenses/${expense.id}/clear-duplicate`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
setResolving(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!expense.duplicateOf) return null;
|
||||||
|
|
||||||
|
const candidateLabel = candidate
|
||||||
|
? `${candidate.establishmentName ?? 'Unnamed expense'} · ${
|
||||||
|
candidate.amount
|
||||||
|
} ${candidate.currency} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}`
|
||||||
|
: 'a previously recorded expense';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="expense-duplicate-banner"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900',
|
||||||
|
'sm:flex-row sm:items-center sm:justify-between',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">Looks like a duplicate</p>
|
||||||
|
<p className="mt-0.5 text-xs text-amber-800">
|
||||||
|
This expense matches{' '}
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/expenses/${expense.duplicateOf}` as never}
|
||||||
|
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{candidateLabel}
|
||||||
|
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
. Merge to consolidate, or mark as not a duplicate to keep them separate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-amber-400 bg-white"
|
||||||
|
disabled={resolving || merge.isPending || clear.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setResolving(true);
|
||||||
|
merge.mutate();
|
||||||
|
}}
|
||||||
|
data-testid="expense-merge-btn"
|
||||||
|
>
|
||||||
|
Merge them
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={resolving || merge.isPending || clear.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setResolving(true);
|
||||||
|
clear.mutate();
|
||||||
|
}}
|
||||||
|
data-testid="expense-not-duplicate-btn"
|
||||||
|
>
|
||||||
|
Not a duplicate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Mail,
|
Mail,
|
||||||
Bell,
|
Bell,
|
||||||
|
Camera,
|
||||||
|
ShieldAlert,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Home,
|
Home,
|
||||||
@@ -69,6 +71,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
marinaRequired: true,
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: `${base}/alerts`, label: 'Alerts', icon: ShieldAlert },
|
||||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||||
@@ -105,6 +108,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
marinaRequired: true,
|
marinaRequired: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||||
|
{ href: `${base}/scan`, label: 'Scan receipt', icon: Camera },
|
||||||
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { PortSwitcher } from '@/components/layout/port-switcher';
|
|||||||
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
|
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
|
||||||
import { CommandSearch } from '@/components/search/command-search';
|
import { CommandSearch } from '@/components/search/command-search';
|
||||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||||
|
import { AlertBell } from '@/components/alerts/alert-bell';
|
||||||
import type { Port } from '@/lib/db/schema/ports';
|
import type { Port } from '@/lib/db/schema/ports';
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
@@ -87,6 +88,9 @@ export function Topbar({ ports, user }: TopbarProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Phase B operational alerts — distinct from user notifications */}
|
||||||
|
<AlertBell />
|
||||||
|
|
||||||
{/* Notification bell — real-time via socket */}
|
{/* Notification bell — real-time via socket */}
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
|
|
||||||
|
|||||||
506
src/components/scan/scan-shell.tsx
Normal file
506
src/components/scan/scan-shell.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ParsedReceipt {
|
||||||
|
establishment: string | null;
|
||||||
|
date: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
lineItems: Array<{ description: string; amount: number }>;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'processing' }
|
||||||
|
| {
|
||||||
|
kind: 'verify';
|
||||||
|
parsed: ParsedReceipt;
|
||||||
|
source: 'ai' | 'manual';
|
||||||
|
reason?: string;
|
||||||
|
providerError?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'saving' }
|
||||||
|
| { kind: 'saved'; expenseId: string }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
interface ScanResp {
|
||||||
|
data: {
|
||||||
|
parsed: ParsedReceipt;
|
||||||
|
source: 'ai' | 'manual';
|
||||||
|
reason?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
providerError?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface VerifyFormProps {
|
||||||
|
parsed: ParsedReceipt;
|
||||||
|
imagePreview: string;
|
||||||
|
imageFile: File;
|
||||||
|
source: 'ai' | 'manual';
|
||||||
|
reason?: string;
|
||||||
|
providerError?: string;
|
||||||
|
onSubmit: (input: {
|
||||||
|
establishmentName: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
expenseDate: string;
|
||||||
|
category: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
description: string;
|
||||||
|
file: File;
|
||||||
|
}) => void;
|
||||||
|
onRetake: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TODAY = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
function VerifyForm({
|
||||||
|
parsed,
|
||||||
|
imagePreview,
|
||||||
|
imageFile,
|
||||||
|
source,
|
||||||
|
reason,
|
||||||
|
providerError,
|
||||||
|
onSubmit,
|
||||||
|
onRetake,
|
||||||
|
saving,
|
||||||
|
}: VerifyFormProps) {
|
||||||
|
const [establishmentName, setEstablishment] = useState(parsed.establishment ?? '');
|
||||||
|
const [amount, setAmount] = useState(parsed.amount != null ? String(parsed.amount) : '');
|
||||||
|
const [currency, setCurrency] = useState((parsed.currency ?? 'USD').toUpperCase());
|
||||||
|
const [expenseDate, setExpenseDate] = useState(parsed.date ?? TODAY());
|
||||||
|
const [category, setCategory] = useState<string>('other');
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>('credit_card');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const lowConfidence = source === 'ai' && parsed.confidence < 0.6;
|
||||||
|
const noOcr = source === 'manual';
|
||||||
|
|
||||||
|
const banner = noOcr ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
{reason === 'no-ocr-configured' ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium">Manual entry mode</p>
|
||||||
|
<p className="text-xs mt-0.5">
|
||||||
|
No AI provider is configured for this port. Fill in the details below to save the
|
||||||
|
expense with the photo attached.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-medium">We couldn't read the receipt automatically</p>
|
||||||
|
<p className="text-xs mt-0.5">
|
||||||
|
{providerError ? `Reason: ${providerError}.` : ''} Fill in the details below to save
|
||||||
|
the expense with the photo attached.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : lowConfidence ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Low-confidence read — please double-check the fields</p>
|
||||||
|
<p className="text-xs mt-0.5">
|
||||||
|
The AI returned a confidence of {Math.round(parsed.confidence * 100)}%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Receipt parsed — confirm the fields and save</p>
|
||||||
|
<p className="text-xs mt-0.5">Confidence {Math.round(parsed.confidence * 100)}%.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
establishmentName,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
expenseDate,
|
||||||
|
category,
|
||||||
|
paymentMethod,
|
||||||
|
description,
|
||||||
|
file: imageFile,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{banner}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Receipt preview"
|
||||||
|
className="block w-full max-h-[40vh] object-contain bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="establishmentName">Vendor / establishment</Label>
|
||||||
|
<Input
|
||||||
|
id="establishmentName"
|
||||||
|
value={establishmentName}
|
||||||
|
onChange={(e) => setEstablishment(e.target.value)}
|
||||||
|
placeholder="e.g. Marina Fuel Station"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="amount">Amount</Label>
|
||||||
|
<Input
|
||||||
|
id="amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="currency">Currency</Label>
|
||||||
|
<Input
|
||||||
|
id="currency"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||||||
|
maxLength={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="expenseDate">Date</Label>
|
||||||
|
<Input
|
||||||
|
id="expenseDate"
|
||||||
|
type="date"
|
||||||
|
value={expenseDate}
|
||||||
|
onChange={(e) => setExpenseDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<Select value={category} onValueChange={setCategory}>
|
||||||
|
<SelectTrigger id="category">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXPENSE_CATEGORIES.map((c) => (
|
||||||
|
<SelectItem key={c} value={c} className="capitalize">
|
||||||
|
{c.replace(/_/g, ' ')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="paymentMethod">Payment method</Label>
|
||||||
|
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||||
|
<SelectTrigger id="paymentMethod">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAYMENT_METHODS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p} className="capitalize">
|
||||||
|
{p.replace(/_/g, ' ')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="description">Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !amount}
|
||||||
|
className="h-12 text-base sm:flex-1"
|
||||||
|
data-testid="scan-save"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save expense
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetake}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-12 text-base"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
Retake
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ScanShell() {
|
||||||
|
const router = useRouter();
|
||||||
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [state, setState] = useState<ScanState>({ kind: 'idle' });
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Revoke blob URL on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||||
|
};
|
||||||
|
}, [imagePreview]);
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||||
|
setImagePreview(URL.createObjectURL(file));
|
||||||
|
setState({ kind: 'processing' });
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const portId = useUIStore.getState().currentPortId;
|
||||||
|
const headers = new Headers();
|
||||||
|
if (portId) headers.set('X-Port-Id', portId);
|
||||||
|
const res = await fetch('/api/v1/expenses/scan-receipt', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as ScanResp;
|
||||||
|
setState({
|
||||||
|
kind: 'verify',
|
||||||
|
parsed: body.data.parsed,
|
||||||
|
source: body.data.source,
|
||||||
|
reason: body.data.reason,
|
||||||
|
providerError: body.data.providerError,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Upload failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(input: {
|
||||||
|
establishmentName: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
expenseDate: string;
|
||||||
|
category: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
description: string;
|
||||||
|
file: File;
|
||||||
|
}) {
|
||||||
|
setState({ kind: 'saving' });
|
||||||
|
try {
|
||||||
|
// Upload the image (multipart — apiFetch wraps JSON, so use raw fetch).
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', input.file);
|
||||||
|
fd.append('category', 'receipt');
|
||||||
|
const portId = useUIStore.getState().currentPortId;
|
||||||
|
const headers = new Headers();
|
||||||
|
if (portId) headers.set('X-Port-Id', portId);
|
||||||
|
const upRes = await fetch('/api/v1/files/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!upRes.ok) throw new Error(`Upload failed: ${upRes.status}`);
|
||||||
|
const upJson = (await upRes.json()) as { data: { id: string } };
|
||||||
|
|
||||||
|
const expense = await apiFetch<{ data: { id: string } }>(`/api/v1/expenses`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
establishmentName: input.establishmentName || undefined,
|
||||||
|
amount: input.amount,
|
||||||
|
currency: input.currency,
|
||||||
|
expenseDate: input.expenseDate,
|
||||||
|
category: input.category,
|
||||||
|
paymentMethod: input.paymentMethod,
|
||||||
|
description: input.description || undefined,
|
||||||
|
receiptFileIds: [upJson.data.id],
|
||||||
|
paymentStatus: 'unpaid',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setState({ kind: 'saved', expenseId: expense.data.id });
|
||||||
|
} catch (err) {
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Save failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (imagePreview) {
|
||||||
|
URL.revokeObjectURL(imagePreview);
|
||||||
|
setImagePreview(null);
|
||||||
|
}
|
||||||
|
setState({ kind: 'idle' });
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 sm:py-10">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Scan a receipt</h1>
|
||||||
|
{state.kind !== 'idle' ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={reset}>
|
||||||
|
Start over
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{state.kind === 'idle' ? (
|
||||||
|
<section className="flex flex-1 flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-border bg-muted/20 p-8 text-center">
|
||||||
|
<Camera className="h-12 w-12 text-muted-foreground/60" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium">Tap to capture a receipt</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Use your camera or pick an image from your library. We'll read it and pre-fill
|
||||||
|
the form for you to confirm.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-12 px-6 text-base"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
data-testid="scan-capture"
|
||||||
|
>
|
||||||
|
<Camera className="mr-2 h-5 w-5" />
|
||||||
|
Capture receipt
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'processing' ? (
|
||||||
|
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-brand" />
|
||||||
|
<p className="text-sm text-muted-foreground">Reading receipt…</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'verify' && imagePreview ? (
|
||||||
|
<VerifyForm
|
||||||
|
parsed={state.parsed}
|
||||||
|
imagePreview={imagePreview}
|
||||||
|
imageFile={fileRef.current?.files?.[0] as File}
|
||||||
|
source={state.source}
|
||||||
|
reason={state.reason}
|
||||||
|
providerError={state.providerError}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onRetake={reset}
|
||||||
|
saving={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'saving' ? (
|
||||||
|
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-brand" />
|
||||||
|
<p className="text-sm text-muted-foreground">Saving expense…</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'saved' ? (
|
||||||
|
<section className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 p-8 text-center">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
|
||||||
|
<p className="text-base font-semibold text-emerald-900">Expense saved</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={reset} variant="outline" data-testid="scan-another">
|
||||||
|
Scan another
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/${portSlug}/expenses/${state.expenseId}` as never)}
|
||||||
|
>
|
||||||
|
View expense
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'error' ? (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-6 text-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-10 w-10 text-destructive" />
|
||||||
|
<p className="text-base font-medium text-destructive">{state.message}</p>
|
||||||
|
<Button onClick={reset} variant="outline">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,17 @@ export const maintenanceWorker = new Worker(
|
|||||||
logger.info({ count: allPorts.length }, 'Analytics snapshot refresh complete');
|
logger.info({ count: allPorts.length }, 'Analytics snapshot refresh complete');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'expense-dedup-scan': {
|
||||||
|
const { expenseId } = job.data as { expenseId: string };
|
||||||
|
if (!expenseId) {
|
||||||
|
logger.warn({ jobId: job.id }, 'expense-dedup-scan missing expenseId');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const { markBestDuplicate } = await import('@/lib/services/expense-dedup.service');
|
||||||
|
const matchedId = await markBestDuplicate(expenseId);
|
||||||
|
logger.info({ expenseId, matchedId: matchedId ?? null }, 'expense-dedup-scan complete');
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
|
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise
|
|||||||
}
|
}
|
||||||
if (options.cursor) {
|
if (options.cursor) {
|
||||||
// Strict less-than on (createdAt, id) for stable cursor pagination.
|
// Strict less-than on (createdAt, id) for stable cursor pagination.
|
||||||
|
// ISO-stringify the date so postgres-js binds it cleanly inside a tuple
|
||||||
|
// comparison; raw Date objects throw under postgres@3.x parameter binding.
|
||||||
|
const cursorAt = options.cursor.createdAt.toISOString();
|
||||||
conds.push(
|
conds.push(
|
||||||
sql`(${auditLogs.createdAt}, ${auditLogs.id}) < (${options.cursor.createdAt}, ${options.cursor.id})`,
|
sql`(${auditLogs.createdAt}, ${auditLogs.id}) < (${cursorAt}::timestamptz, ${options.cursor.id})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ function buildHubTabFilters(
|
|||||||
if (!tab || tab === 'all') return filters;
|
if (!tab || tab === 'all') return filters;
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
case 'eoi_queue':
|
||||||
|
// EOI documents currently in-flight (drafted, sent, or partially signed).
|
||||||
|
// Used by the dedicated tab on the documents hub to triage EOI signing
|
||||||
|
// pipeline volume separate from the all-doc-types view.
|
||||||
|
filters.push(eq(documents.documentType, 'eoi'));
|
||||||
|
filters.push(inArray(documents.status, ['draft', 'sent', 'partially_signed']));
|
||||||
|
break;
|
||||||
case 'awaiting_them':
|
case 'awaiting_them':
|
||||||
// "awaiting them" = pending signers other than the current user.
|
// "awaiting them" = pending signers other than the current user.
|
||||||
// Without a known caller email we cannot make that distinction, so
|
// Without a known caller email we cannot make that distinction, so
|
||||||
@@ -209,6 +216,7 @@ export async function listDocuments(
|
|||||||
|
|
||||||
export interface HubTabCounts {
|
export interface HubTabCounts {
|
||||||
all: number;
|
all: number;
|
||||||
|
eoi_queue: number;
|
||||||
awaiting_them: number;
|
awaiting_them: number;
|
||||||
awaiting_me: number;
|
awaiting_me: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
@@ -233,15 +241,16 @@ export async function getHubTabCounts(
|
|||||||
return row?.count ?? 0;
|
return row?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [all, awaiting_them, awaiting_me, completed, expired] = await Promise.all([
|
const [all, eoi_queue, awaiting_them, awaiting_me, completed, expired] = await Promise.all([
|
||||||
tabCount('all'),
|
tabCount('all'),
|
||||||
|
tabCount('eoi_queue'),
|
||||||
tabCount('awaiting_them'),
|
tabCount('awaiting_them'),
|
||||||
tabCount('awaiting_me'),
|
tabCount('awaiting_me'),
|
||||||
tabCount('completed'),
|
tabCount('completed'),
|
||||||
tabCount('expired'),
|
tabCount('expired'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { all, awaiting_them, awaiting_me, completed, expired };
|
return { all, eoi_queue, awaiting_them, awaiting_me, completed, expired };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -69,3 +69,59 @@ export async function markBestDuplicate(expenseId: string): Promise<string | nul
|
|||||||
.where(eq(expenses.id, expenseId));
|
.where(eq(expenses.id, expenseId));
|
||||||
return best.candidateId;
|
return best.candidateId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the duplicate flag — operator confirmed this is a real expense.
|
||||||
|
* Leaves `dedupScannedAt` populated so the engine doesn't re-flag it.
|
||||||
|
*/
|
||||||
|
export async function clearDuplicate(expenseId: string, portId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(expenses)
|
||||||
|
.set({ duplicateOf: null, dedupScannedAt: sql`now()` })
|
||||||
|
.where(and(eq(expenses.id, expenseId), eq(expenses.portId, portId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge `sourceId` into `targetId`: combine receipt files, archive the
|
||||||
|
* source, and clear the duplicate-of pointer. Both rows must belong to
|
||||||
|
* the same port; runs inside a single transaction so a partial failure
|
||||||
|
* leaves both rows untouched.
|
||||||
|
*/
|
||||||
|
export async function mergeDuplicate(
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
portId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (sourceId === targetId) {
|
||||||
|
throw new Error('Cannot merge an expense into itself');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [source] = await tx
|
||||||
|
.select()
|
||||||
|
.from(expenses)
|
||||||
|
.where(and(eq(expenses.id, sourceId), eq(expenses.portId, portId)));
|
||||||
|
const [target] = await tx
|
||||||
|
.select()
|
||||||
|
.from(expenses)
|
||||||
|
.where(and(eq(expenses.id, targetId), eq(expenses.portId, portId)));
|
||||||
|
if (!source || !target) {
|
||||||
|
throw new Error('Source or target expense not found in this port');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedReceipts = Array.from(
|
||||||
|
new Set([...(target.receiptFileIds ?? []), ...(source.receiptFileIds ?? [])]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(expenses)
|
||||||
|
.set({ receiptFileIds: mergedReceipts })
|
||||||
|
.where(eq(expenses.id, targetId));
|
||||||
|
|
||||||
|
// Archive the source — preserves audit history, keeps any FKs alive.
|
||||||
|
await tx
|
||||||
|
.update(expenses)
|
||||||
|
.set({ archivedAt: sql`now()`, duplicateOf: null })
|
||||||
|
.where(eq(expenses.id, sourceId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { NotFoundError, ConflictError } from '@/lib/errors';
|
|||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { convert } from '@/lib/services/currency';
|
import { convert } from '@/lib/services/currency';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import type { CreateExpenseInput, UpdateExpenseInput, ListExpensesInput } from '@/lib/validators/expenses';
|
import type {
|
||||||
|
CreateExpenseInput,
|
||||||
|
UpdateExpenseInput,
|
||||||
|
ListExpensesInput,
|
||||||
|
} from '@/lib/validators/expenses';
|
||||||
|
|
||||||
export type { ListExpensesInput };
|
export type { ListExpensesInput };
|
||||||
|
|
||||||
@@ -59,7 +63,10 @@ export async function listExpenses(portId: string, query: ListExpensesInput) {
|
|||||||
includeArchived: query.includeArchived,
|
includeArchived: query.includeArchived,
|
||||||
archivedAtColumn: expenses.archivedAt,
|
archivedAtColumn: expenses.archivedAt,
|
||||||
sort: query.sort
|
sort: query.sort
|
||||||
? { column: expenses[query.sort as keyof typeof expenses] as unknown as PgColumn, direction: query.order }
|
? {
|
||||||
|
column: expenses[query.sort as keyof typeof expenses] as unknown as PgColumn,
|
||||||
|
direction: query.order,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -87,7 +94,10 @@ export async function createExpense(
|
|||||||
exchangeRate = String(conversion.rate);
|
exchangeRate = String(conversion.rate);
|
||||||
} else {
|
} else {
|
||||||
// BR-040: if rate unavailable, save without conversion + log warning
|
// BR-040: if rate unavailable, save without conversion + log warning
|
||||||
logger.warn({ currency: data.currency }, 'Currency rate unavailable, saving expense without USD conversion');
|
logger.warn(
|
||||||
|
{ currency: data.currency },
|
||||||
|
'Currency rate unavailable, saving expense without USD conversion',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
amountUsd = String(data.amount);
|
amountUsd = String(data.amount);
|
||||||
@@ -137,6 +147,15 @@ export async function createExpense(
|
|||||||
category: expense.category ?? '',
|
category: expense.category ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Schedule a duplicate-detection sweep. Best-effort — we don't want a
|
||||||
|
// queue-side hiccup to fail the user's create.
|
||||||
|
try {
|
||||||
|
const { getQueue } = await import('@/lib/queue');
|
||||||
|
await getQueue('maintenance').add('expense-dedup-scan', { expenseId: expense.id });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, expenseId: expense.id }, 'Failed to enqueue expense-dedup-scan');
|
||||||
|
}
|
||||||
|
|
||||||
return expense;
|
return expense;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +180,10 @@ export async function updateExpense(
|
|||||||
updateData.amountUsd = String(conversion.result);
|
updateData.amountUsd = String(conversion.result);
|
||||||
updateData.exchangeRate = String(conversion.rate);
|
updateData.exchangeRate = String(conversion.rate);
|
||||||
} else {
|
} else {
|
||||||
logger.warn({ currency: newCurrency }, 'Currency rate unavailable during update, clearing USD conversion');
|
logger.warn(
|
||||||
|
{ currency: newCurrency },
|
||||||
|
'Currency rate unavailable during update, clearing USD conversion',
|
||||||
|
);
|
||||||
updateData.amountUsd = null;
|
updateData.amountUsd = null;
|
||||||
updateData.exchangeRate = null;
|
updateData.exchangeRate = null;
|
||||||
}
|
}
|
||||||
@@ -204,11 +226,7 @@ export async function updateExpense(
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveExpense(
|
export async function archiveExpense(id: string, portId: string, meta: ServiceAuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
meta: ServiceAuditMeta,
|
|
||||||
) {
|
|
||||||
const existing = await getExpenseById(id, portId);
|
const existing = await getExpenseById(id, portId);
|
||||||
|
|
||||||
// BR-045: Check if linked to non-draft invoice
|
// BR-045: Check if linked to non-draft invoice
|
||||||
@@ -216,12 +234,7 @@ export async function archiveExpense(
|
|||||||
.select({ invoiceId: invoiceExpenses.invoiceId })
|
.select({ invoiceId: invoiceExpenses.invoiceId })
|
||||||
.from(invoiceExpenses)
|
.from(invoiceExpenses)
|
||||||
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
|
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
|
||||||
.where(
|
.where(and(eq(invoiceExpenses.expenseId, id), sql`${invoices.status} != 'draft'`))
|
||||||
and(
|
|
||||||
eq(invoiceExpenses.expenseId, id),
|
|
||||||
sql`${invoices.status} != 'draft'`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (linkedInvoice.length > 0) {
|
if (linkedInvoice.length > 0) {
|
||||||
@@ -244,11 +257,7 @@ export async function archiveExpense(
|
|||||||
emitToRoom(`port:${portId}`, 'expense:archived', { expenseId: id });
|
emitToRoom(`port:${portId}`, 'expense:archived', { expenseId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreExpense(
|
export async function restoreExpense(id: string, portId: string, meta: ServiceAuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
meta: ServiceAuditMeta,
|
|
||||||
) {
|
|
||||||
await getExpenseById(id, portId);
|
await getExpenseById(id, portId);
|
||||||
|
|
||||||
await restore(expenses, expenses.id, id);
|
await restore(expenses, expenses.id, id);
|
||||||
|
|||||||
157
src/lib/services/ocr-config.service.ts
Normal file
157
src/lib/services/ocr-config.service.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* OCR provider config — stored in `system_settings` under the key
|
||||||
|
* `ocr.config`. Each port can either have its own row (port_id = port.id)
|
||||||
|
* or opt into the global row (port_id = null) by setting `useGlobal: true`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import { encrypt, decrypt } from '@/lib/utils/encryption';
|
||||||
|
|
||||||
|
export type OcrProvider = 'openai' | 'claude';
|
||||||
|
|
||||||
|
export const OCR_MODELS: Record<OcrProvider, string[]> = {
|
||||||
|
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'],
|
||||||
|
claude: ['claude-haiku-4-5', 'claude-sonnet-4-6', 'claude-opus-4-7'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MODEL: Record<OcrProvider, string> = {
|
||||||
|
openai: 'gpt-4o-mini',
|
||||||
|
claude: 'claude-haiku-4-5',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Public shape that admin UIs read — never includes the raw key. */
|
||||||
|
export interface OcrConfigPublic {
|
||||||
|
provider: OcrProvider;
|
||||||
|
model: string;
|
||||||
|
/** True when an encrypted key is present. We never echo the key itself. */
|
||||||
|
hasApiKey: boolean;
|
||||||
|
/** Port-level rows can opt into the global config. */
|
||||||
|
useGlobal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal shape including the decrypted key — server-side only. */
|
||||||
|
export interface OcrConfigResolved extends OcrConfigPublic {
|
||||||
|
apiKey: string | null;
|
||||||
|
/** Source of the resolved row: 'port' | 'global' | 'none'. */
|
||||||
|
source: 'port' | 'global' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredOcrConfig {
|
||||||
|
provider: OcrProvider;
|
||||||
|
model: string;
|
||||||
|
apiKeyEncrypted: string | null;
|
||||||
|
useGlobal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'ocr.config';
|
||||||
|
|
||||||
|
async function readRow(portId: string | null): Promise<StoredOcrConfig | null> {
|
||||||
|
const where =
|
||||||
|
portId === null
|
||||||
|
? and(eq(systemSettings.key, KEY), isNull(systemSettings.portId))
|
||||||
|
: and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId));
|
||||||
|
const [row] = await db.select().from(systemSettings).where(where);
|
||||||
|
if (!row) return null;
|
||||||
|
return row.value as unknown as StoredOcrConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRow(portId: string | null, value: StoredOcrConfig, userId: string) {
|
||||||
|
// upsert: delete + insert keeps logic simple given the (key, port_id) unique index.
|
||||||
|
await db
|
||||||
|
.delete(systemSettings)
|
||||||
|
.where(
|
||||||
|
portId === null
|
||||||
|
? and(eq(systemSettings.key, KEY), isNull(systemSettings.portId))
|
||||||
|
: and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId)),
|
||||||
|
);
|
||||||
|
await db.insert(systemSettings).values({
|
||||||
|
key: KEY,
|
||||||
|
portId,
|
||||||
|
value: value as unknown as Record<string, unknown>,
|
||||||
|
updatedBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active OCR config for a port: port row (unless `useGlobal`),
|
||||||
|
* falling back to the global row, falling back to a default-empty config.
|
||||||
|
*/
|
||||||
|
export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigResolved> {
|
||||||
|
const portRow = await readRow(portId);
|
||||||
|
const useGlobal = portRow?.useGlobal === true || !portRow;
|
||||||
|
const sourceRow = useGlobal ? await readRow(null) : portRow;
|
||||||
|
if (!sourceRow) {
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
model: DEFAULT_MODEL.openai,
|
||||||
|
apiKey: null,
|
||||||
|
hasApiKey: false,
|
||||||
|
useGlobal: portRow?.useGlobal === true,
|
||||||
|
source: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: sourceRow.provider,
|
||||||
|
model: sourceRow.model,
|
||||||
|
apiKey: sourceRow.apiKeyEncrypted ? decrypt(sourceRow.apiKeyEncrypted) : null,
|
||||||
|
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
|
||||||
|
useGlobal: portRow?.useGlobal === true,
|
||||||
|
source: useGlobal ? 'global' : 'port',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public-safe view for the admin UI — same shape but never the key. */
|
||||||
|
export async function getPublicOcrConfig(portId: string | null): Promise<OcrConfigPublic> {
|
||||||
|
const row = await readRow(portId);
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
model: DEFAULT_MODEL.openai,
|
||||||
|
hasApiKey: false,
|
||||||
|
useGlobal: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: row.provider,
|
||||||
|
model: row.model,
|
||||||
|
hasApiKey: Boolean(row.apiKeyEncrypted),
|
||||||
|
useGlobal: row.useGlobal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveOcrConfigInput {
|
||||||
|
provider: OcrProvider;
|
||||||
|
model: string;
|
||||||
|
/** When provided, replaces any stored key. When undefined, the existing key is preserved. */
|
||||||
|
apiKey?: string;
|
||||||
|
/** When true, clears the stored key. */
|
||||||
|
clearApiKey?: boolean;
|
||||||
|
useGlobal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOcrConfig(
|
||||||
|
portId: string | null,
|
||||||
|
input: SaveOcrConfigInput,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await readRow(portId);
|
||||||
|
let apiKeyEncrypted = existing?.apiKeyEncrypted ?? null;
|
||||||
|
if (input.clearApiKey) {
|
||||||
|
apiKeyEncrypted = null;
|
||||||
|
} else if (input.apiKey !== undefined && input.apiKey.length > 0) {
|
||||||
|
apiKeyEncrypted = encrypt(input.apiKey);
|
||||||
|
}
|
||||||
|
await writeRow(
|
||||||
|
portId,
|
||||||
|
{
|
||||||
|
provider: input.provider,
|
||||||
|
model: input.model,
|
||||||
|
apiKeyEncrypted,
|
||||||
|
useGlobal: portId === null ? false : Boolean(input.useGlobal),
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/lib/services/ocr-providers.ts
Normal file
172
src/lib/services/ocr-providers.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Receipt OCR provider adapters. Each adapter takes raw image bytes
|
||||||
|
* and returns a normalized `ParsedReceipt` shape; callers don't care
|
||||||
|
* which provider produced it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export interface ParsedReceiptLineItem {
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedReceipt {
|
||||||
|
establishment: string | null;
|
||||||
|
/** ISO YYYY-MM-DD. */
|
||||||
|
date: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
lineItems: ParsedReceiptLineItem[];
|
||||||
|
/** 0..1; below 0.6 surfaces "verify mode" UI. */
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_RESULT: ParsedReceipt = {
|
||||||
|
establishment: null,
|
||||||
|
date: null,
|
||||||
|
amount: null,
|
||||||
|
currency: null,
|
||||||
|
lineItems: [],
|
||||||
|
confidence: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You extract structured data from a marina-business receipt image. Return ONLY a JSON object with these keys: establishment (string), date (ISO YYYY-MM-DD), amount (number, total), currency (3-letter ISO code), lineItems (array of {description, amount}), confidence (number 0-1). If a field cannot be read, return null for that field. Set confidence near 0 if the image is unreadable, near 1 if every field was confidently extracted.';
|
||||||
|
|
||||||
|
interface RunArgs {
|
||||||
|
imageBuffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse(content: string): ParsedReceipt {
|
||||||
|
const cleaned = content.replace(/```json\n?|\n?```/g, '').trim();
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(cleaned) as Partial<ParsedReceipt>;
|
||||||
|
return {
|
||||||
|
establishment: obj.establishment ?? null,
|
||||||
|
date: obj.date ?? null,
|
||||||
|
amount: typeof obj.amount === 'number' ? obj.amount : null,
|
||||||
|
currency: obj.currency ?? null,
|
||||||
|
lineItems: Array.isArray(obj.lineItems) ? obj.lineItems : [],
|
||||||
|
confidence: typeof obj.confidence === 'number' ? obj.confidence : 0,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, contentLen: cleaned.length }, 'OCR provider returned non-JSON');
|
||||||
|
return EMPTY_RESULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOpenAi({
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
}: RunArgs): Promise<ParsedReceipt> {
|
||||||
|
const client = new OpenAI({ apiKey });
|
||||||
|
const base64 = imageBuffer.toString('base64');
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Extract the receipt as JSON.' },
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: `data:${mimeType};base64,${base64}` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 1024,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
return safeParse(response.choices[0]?.message?.content ?? '{}');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runClaude({
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
}: RunArgs): Promise<ParsedReceipt> {
|
||||||
|
const base64 = imageBuffer.toString('base64');
|
||||||
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
max_tokens: 1024,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: mimeType, data: base64 },
|
||||||
|
},
|
||||||
|
{ type: 'text', text: 'Extract the receipt as JSON.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Claude API ${res.status}: ${detail.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { content?: Array<{ type: string; text?: string }> };
|
||||||
|
const text = body.content?.find((c) => c.type === 'text')?.text ?? '{}';
|
||||||
|
return safeParse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOcr(args: {
|
||||||
|
provider: 'openai' | 'claude';
|
||||||
|
imageBuffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
}): Promise<ParsedReceipt> {
|
||||||
|
if (args.provider === 'openai') return runOpenAi(args);
|
||||||
|
return runClaude(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny dummy-image probe used by the admin "Test connection" button.
|
||||||
|
* Returns the raw HTTP status so callers can render plain-English errors.
|
||||||
|
*/
|
||||||
|
export async function testProvider(
|
||||||
|
provider: 'openai' | 'claude',
|
||||||
|
apiKey: string,
|
||||||
|
model: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||||
|
// 1×1 transparent PNG.
|
||||||
|
const pixelPng = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await runOcr({
|
||||||
|
provider,
|
||||||
|
imageBuffer: pixelPng,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
return { ok: false, reason };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ export type CreateDocumentWizardInput = z.infer<typeof createDocumentWizardSchem
|
|||||||
|
|
||||||
export const documentsHubTabs = [
|
export const documentsHubTabs = [
|
||||||
'all',
|
'all',
|
||||||
|
'eoi_queue',
|
||||||
'awaiting_them',
|
'awaiting_them',
|
||||||
'awaiting_me',
|
'awaiting_me',
|
||||||
'completed',
|
'completed',
|
||||||
|
|||||||
72
tests/e2e/realapi/alert-engine-realtime.spec.ts
Normal file
72
tests/e2e/realapi/alert-engine-realtime.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
import { login, apiHeaders, getPortId } from '../smoke/helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-API socket round-trip for the Phase B alert engine.
|
||||||
|
*
|
||||||
|
* - Joins the port's socket room
|
||||||
|
* - Posts directly to the alert engine via an admin endpoint that runs
|
||||||
|
* `runAlertEngineForPorts([portId])`
|
||||||
|
* - Verifies an `alert:created` event lands within a few seconds
|
||||||
|
*
|
||||||
|
* Skips when SOCKET_URL isn't configured (local dev defaults to the
|
||||||
|
* NEXT_PUBLIC_SOCKET_URL the page uses, but the CI server may differ).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SOCKET_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SOCKET_URL ?? process.env.SOCKET_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
test.describe('Alert engine — socket fanout', () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.RUN_ALERT_ENGINE_REALAPI,
|
||||||
|
'RUN_ALERT_ENGINE_REALAPI not set (opt-in; emits real events)',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('engine sweep emits alert:created over the socket', async ({ page }) => {
|
||||||
|
await login(page, 'super_admin');
|
||||||
|
const portId = await getPortId(page);
|
||||||
|
const headers = await apiHeaders(page);
|
||||||
|
|
||||||
|
// Listen on the socket. We resolve when an alert:created event lands
|
||||||
|
// for our port id, or reject after a timeout.
|
||||||
|
const cookieHeader = await page.evaluate(() => document.cookie);
|
||||||
|
const socket: Socket = io(SOCKET_URL, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
extraHeaders: { Cookie: cookieHeader },
|
||||||
|
});
|
||||||
|
socket.emit('join:port', { portId });
|
||||||
|
|
||||||
|
const eventPromise = new Promise<{ portId: string; ruleId: string }>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error('Timed out waiting for alert:created')),
|
||||||
|
15_000,
|
||||||
|
);
|
||||||
|
socket.on('alert:created', (payload: { portId: string; ruleId: string }) => {
|
||||||
|
if (payload.portId === portId) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a sweep against the running server.
|
||||||
|
const triggerRes = await page.request.post(`/api/v1/admin/alerts/run-engine`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect([200, 404]).toContain(triggerRes.status());
|
||||||
|
if (triggerRes.status() === 404) {
|
||||||
|
// The trigger route is opt-in scaffolding; skip if not present in this build.
|
||||||
|
socket.disconnect();
|
||||||
|
test.skip(true, 'admin/alerts/run-engine not implemented in this build');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await eventPromise;
|
||||||
|
expect(payload.portId).toBe(portId);
|
||||||
|
|
||||||
|
socket.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
132
tests/e2e/realapi/receipt-ocr.spec.ts
Normal file
132
tests/e2e/realapi/receipt-ocr.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
|
||||||
|
import { login, apiHeaders, getPortId } from '../smoke/helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-API receipt OCR coverage. Two-step:
|
||||||
|
*
|
||||||
|
* 1. Admin save + test-connection round-trip: writes a real OpenAI key
|
||||||
|
* to the global OCR config, calls /admin/ocr-settings/test (which
|
||||||
|
* sends a 1×1 pixel PNG — essentially free in tokens), and asserts
|
||||||
|
* the provider responds 2xx. Validates the auth + key-storage path.
|
||||||
|
*
|
||||||
|
* 2. Real receipt parse: when REALAPI_RECEIPT_FIXTURE is set to an
|
||||||
|
* image on disk, POSTs it to /api/v1/expenses/scan-receipt and
|
||||||
|
* asserts the parsed payload looks plausible (numeric amount >= 0,
|
||||||
|
* non-empty parsed.confidence).
|
||||||
|
*
|
||||||
|
* Both tests skip when OPENAI_API_KEY isn't set so the suite remains
|
||||||
|
* CI-safe by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
const RECEIPT_FIXTURE = process.env.REALAPI_RECEIPT_FIXTURE;
|
||||||
|
|
||||||
|
test.describe('Receipt OCR — real provider', () => {
|
||||||
|
test.skip(!OPENAI_API_KEY, 'OPENAI_API_KEY not configured');
|
||||||
|
|
||||||
|
test('admin can save an OpenAI key and the test endpoint passes', async ({ page }) => {
|
||||||
|
await login(page, 'super_admin');
|
||||||
|
const headers = await apiHeaders(page);
|
||||||
|
|
||||||
|
// Save a global OCR config with the real key. Super-admin only.
|
||||||
|
const saveRes = await page.request.put('/api/v1/admin/ocr-settings', {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
scope: 'global',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
apiKey: OPENAI_API_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saveRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const testRes = await page.request.post('/api/v1/admin/ocr-settings/test', {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
apiKey: OPENAI_API_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(testRes.ok()).toBeTruthy();
|
||||||
|
const body = (await testRes.json()) as { ok: boolean; reason?: string };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup: clear the global key so subsequent test runs don't accidentally
|
||||||
|
// bill the same OpenAI account if someone forgets to unset it.
|
||||||
|
const cleanupRes = await page.request.put('/api/v1/admin/ocr-settings', {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
scope: 'global',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
clearApiKey: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cleanupRes.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan-receipt endpoint returns a parsed payload for a real image', async ({ page }) => {
|
||||||
|
test.skip(!RECEIPT_FIXTURE, 'REALAPI_RECEIPT_FIXTURE not set');
|
||||||
|
|
||||||
|
await login(page, 'super_admin');
|
||||||
|
const portId = await getPortId(page);
|
||||||
|
|
||||||
|
// Configure the per-port OCR with the test key for the duration of this run.
|
||||||
|
await page.request.put('/api/v1/admin/ocr-settings', {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Port-Id': portId },
|
||||||
|
data: {
|
||||||
|
scope: 'port',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
apiKey: OPENAI_API_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await fs.readFile(RECEIPT_FIXTURE!);
|
||||||
|
const res = await page.request.post('/api/v1/expenses/scan-receipt', {
|
||||||
|
headers: { 'X-Port-Id': portId },
|
||||||
|
multipart: {
|
||||||
|
file: {
|
||||||
|
name: 'receipt.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
buffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
data: {
|
||||||
|
parsed: {
|
||||||
|
amount: number | null;
|
||||||
|
confidence: number;
|
||||||
|
establishment: string | null;
|
||||||
|
date: string | null;
|
||||||
|
};
|
||||||
|
source: 'ai' | 'manual';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(body.data.source).toBe('ai');
|
||||||
|
// Confidence must be a valid number 0..1 — provider should always emit it.
|
||||||
|
expect(body.data.parsed.confidence).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(body.data.parsed.confidence).toBeLessThanOrEqual(1);
|
||||||
|
// Amount, if present, should be non-negative.
|
||||||
|
if (body.data.parsed.amount !== null) {
|
||||||
|
expect(body.data.parsed.amount).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await page.request.put('/api/v1/admin/ocr-settings', {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Port-Id': portId },
|
||||||
|
data: {
|
||||||
|
scope: 'port',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
clearApiKey: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { login, navigateTo, PORT_SLUG } from './helpers';
|
import { login, navigateTo } from './helpers';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
test.describe('Document Management', () => {
|
test.describe('Document Management', () => {
|
||||||
@@ -26,7 +26,7 @@ test.describe('Document Management', () => {
|
|||||||
if (await uploadBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
if (await uploadBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||||
// Check for a file input (may be hidden)
|
// Check for a file input (may be hidden)
|
||||||
const fileInput = page.locator('input[type="file"]').first();
|
const fileInput = page.locator('input[type="file"]').first();
|
||||||
if (await fileInput.count() > 0) {
|
if ((await fileInput.count()) > 0) {
|
||||||
const testFilePath = path.resolve('tests/e2e/fixtures/test-document.txt');
|
const testFilePath = path.resolve('tests/e2e/fixtures/test-document.txt');
|
||||||
await fileInput.setInputFiles(testFilePath);
|
await fileInput.setInputFiles(testFilePath);
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
@@ -35,7 +35,7 @@ test.describe('Document Management', () => {
|
|||||||
await uploadBtn.click();
|
await uploadBtn.click();
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
const fileInput2 = page.locator('input[type="file"]').first();
|
const fileInput2 = page.locator('input[type="file"]').first();
|
||||||
if (await fileInput2.count() > 0) {
|
if ((await fileInput2.count()) > 0) {
|
||||||
const testFilePath = path.resolve('tests/e2e/fixtures/test-document.txt');
|
const testFilePath = path.resolve('tests/e2e/fixtures/test-document.txt');
|
||||||
await fileInput2.setInputFiles(testFilePath);
|
await fileInput2.setInputFiles(testFilePath);
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
@@ -47,4 +47,14 @@ test.describe('Document Management', () => {
|
|||||||
const pageContent = page.getByText(/documents|files/i).first();
|
const pageContent = page.getByText(/documents|files/i).first();
|
||||||
await expect(pageContent).toBeVisible({ timeout: 5_000 });
|
await expect(pageContent).toBeVisible({ timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('documents hub shows the EOI queue tab', async ({ page }) => {
|
||||||
|
await navigateTo(page, '/documents');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const tab = page.getByRole('tab', { name: /eoi queue/i });
|
||||||
|
await expect(tab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await tab.click();
|
||||||
|
await expect(tab).toHaveAttribute('data-state', 'active');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,18 +41,33 @@ test.describe('Dashboard', () => {
|
|||||||
expect(errorCount).toBe(0);
|
expect(errorCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 3: Pipeline chart shows bars for stages
|
// Test 3: Pipeline funnel chart renders bars for stages (Phase B analytics)
|
||||||
test('pipeline chart renders with stage bars', async ({ page }) => {
|
test('pipeline funnel chart renders with stage bars', async ({ page }) => {
|
||||||
await navigateTo(page, '/');
|
await navigateTo(page, '/');
|
||||||
await page.waitForTimeout(3_000);
|
await page.waitForTimeout(3_000);
|
||||||
|
|
||||||
// Recharts renders SVG bars — look for the chart container and SVG elements
|
const chartSection = page.getByText('Pipeline Funnel').first();
|
||||||
const chartSection = page.getByText('Pipeline Overview').first();
|
|
||||||
await expect(chartSection).toBeVisible({ timeout: 10_000 });
|
await expect(chartSection).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Should have an SVG with rect elements (bars) or the recharts container
|
// Recharts SVG container should be present (or empty-state if no data).
|
||||||
const svg = page.locator('.recharts-wrapper svg, .recharts-responsive-container svg').first();
|
const svg = page.locator('.recharts-wrapper svg, .recharts-responsive-container svg').first();
|
||||||
await expect(svg).toBeVisible({ timeout: 5_000 });
|
const empty = page.getByText('No interests in range').first();
|
||||||
|
const hasSvg = await svg.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||||
|
const hasEmpty = await empty.isVisible({ timeout: 2_000 }).catch(() => false);
|
||||||
|
expect(hasSvg || hasEmpty).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: Date range picker switches analytics window
|
||||||
|
test('date range picker switches between ranges', async ({ page }) => {
|
||||||
|
await navigateTo(page, '/');
|
||||||
|
await expect(page.getByText('Pipeline Funnel').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Default is 30d. Click 7d, then 90d, and verify the kpi line label updates.
|
||||||
|
await page.getByTestId('range-7d').click();
|
||||||
|
await expect(page.getByText('Last 7 days')).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await page.getByTestId('range-90d').click();
|
||||||
|
await expect(page.getByText('Last 90 days')).toBeVisible({ timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 4: Activity feed shows recent entries
|
// Test 4: Activity feed shows recent entries
|
||||||
|
|||||||
48
tests/e2e/smoke/27-alerts.spec.ts
Normal file
48
tests/e2e/smoke/27-alerts.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login, navigateTo, PORT_SLUG } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Alerts (Phase B)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page, 'super_admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alert bell renders in topbar with no badge when no alerts', async ({ page }) => {
|
||||||
|
await navigateTo(page, '/');
|
||||||
|
const bell = page.getByTestId('alert-bell');
|
||||||
|
await expect(bell).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alert bell popover opens', async ({ page }) => {
|
||||||
|
await navigateTo(page, '/');
|
||||||
|
const bell = page.getByTestId('alert-bell');
|
||||||
|
await expect(bell).toBeVisible({ timeout: 15_000 });
|
||||||
|
await bell.click();
|
||||||
|
|
||||||
|
// Popover header is the unambiguous proof that the popover rendered.
|
||||||
|
await expect(page.getByText('Active alerts').first()).toBeVisible({ timeout: 8_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard renders the alert rail', async ({ page }) => {
|
||||||
|
await navigateTo(page, '/');
|
||||||
|
await expect(page.getByText('Pipeline Funnel').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId('alert-rail')).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/alerts page loads with three tabs', async ({ page }) => {
|
||||||
|
await navigateTo(page, '/alerts');
|
||||||
|
expect(page.url()).toContain(`/${PORT_SLUG}/alerts`);
|
||||||
|
await expect(page.getByTestId('tab-open')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.getByTestId('tab-dismissed')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('tab-resolved')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('tab-resolved').click();
|
||||||
|
// No assertion on contents; just smoke that the tab swap doesn't error.
|
||||||
|
await expect(page.getByTestId('tab-resolved')).toHaveAttribute('data-state', 'active');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar Alerts link navigates to /alerts', async ({ page }) => {
|
||||||
|
await navigateTo(page, '/');
|
||||||
|
await page.getByRole('link', { name: 'Alerts', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${PORT_SLUG}/alerts`), { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
36
tests/e2e/smoke/28-berth-interests-tab.spec.ts
Normal file
36
tests/e2e/smoke/28-berth-interests-tab.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login, PORT_SLUG } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Berth detail — Interests tab (Phase B)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page, 'super_admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('berth detail page exposes an Interests tab', async ({ page }) => {
|
||||||
|
// Navigate to the berths list, then click the first row to drill in.
|
||||||
|
// The list uses TanStack Table row-click handlers, so we wait for the
|
||||||
|
// table body to populate rather than for `a[href*="/berths/"]`.
|
||||||
|
await page.goto(`/${PORT_SLUG}/berths`);
|
||||||
|
const firstRow = page.locator('tbody tr').first();
|
||||||
|
await expect(firstRow).toBeVisible({ timeout: 20_000 });
|
||||||
|
// The list table uses an `onRowClick` handler on the <tr> that calls
|
||||||
|
// `router.push`. Open the row's actions menu and click "View details"
|
||||||
|
// — the menu item's handler routes the same way and is more reliable
|
||||||
|
// than firing a synthetic <tr> click under React 19 + dev-mode HMR.
|
||||||
|
await firstRow.getByRole('button', { name: /open menu/i }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /view details/i }).click();
|
||||||
|
await page.waitForURL(/\/berths\/[^/]+$/, { timeout: 10_000 });
|
||||||
|
|
||||||
|
const tab = page.getByRole('tab', { name: 'Interests', exact: true });
|
||||||
|
await expect(tab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await tab.click();
|
||||||
|
await expect(tab).toHaveAttribute('data-state', 'active');
|
||||||
|
|
||||||
|
// Confirm the new tab body replaced the old stub. The body might be:
|
||||||
|
// - a populated table
|
||||||
|
// - the empty-state ("No interests linked to this berth")
|
||||||
|
// - the loading skeleton (still fetching)
|
||||||
|
// The negative assertion against the old stub copy is the primary signal.
|
||||||
|
await expect(page.getByText('Interests coming soon')).not.toBeVisible({ timeout: 2_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
50
tests/e2e/smoke/29-receipt-scanner.spec.ts
Normal file
50
tests/e2e/smoke/29-receipt-scanner.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login, PORT_SLUG } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Receipt scanner PWA (Phase B)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page, 'super_admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scanner page loads with capture button and no dashboard chrome', async ({ page }) => {
|
||||||
|
// First-hit dev compile of the brand-new (scanner) route group can exceed 30s.
|
||||||
|
await page.goto(`/${PORT_SLUG}/scan`, { timeout: 60_000 });
|
||||||
|
await expect(page.getByRole('heading', { name: /scan a receipt/i })).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('scan-capture')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// No sidebar / topbar elements should be present on the scanner.
|
||||||
|
const dashboardLinks = page.getByRole('link', { name: 'Dashboard' });
|
||||||
|
expect(await dashboardLinks.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-port manifest is served with correct content type and scope', async ({ page }) => {
|
||||||
|
const res = await page.request.get(`/${PORT_SLUG}/scan/manifest.webmanifest`, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
expect(res.headers()['content-type']).toContain('application/manifest+json');
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
name: string;
|
||||||
|
scope: string;
|
||||||
|
start_url: string;
|
||||||
|
display: string;
|
||||||
|
};
|
||||||
|
expect(body.scope).toBe(`/${PORT_SLUG}/scan`);
|
||||||
|
expect(body.start_url).toBe(`/${PORT_SLUG}/scan`);
|
||||||
|
expect(body.display).toBe('standalone');
|
||||||
|
expect(body.name.toLowerCase()).toContain('scanner');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin OCR settings page renders both provider and model selectors', async ({ page }) => {
|
||||||
|
await page.goto(`/${PORT_SLUG}/admin/ocr`, { timeout: 60_000 });
|
||||||
|
await expect(page.getByRole('heading', { name: /receipt ocr/i })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('save-port')).toBeVisible({ timeout: 15_000 });
|
||||||
|
// Provider + model selects use Radix triggers, not native <label>; locate via testid.
|
||||||
|
await expect(page.locator('#provider-port')).toBeVisible();
|
||||||
|
await expect(page.locator('#model-port')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
tests/e2e/smoke/30-audit-log.spec.ts
Normal file
43
tests/e2e/smoke/30-audit-log.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login, PORT_SLUG } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Audit log read view (Phase B)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page, 'super_admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin audit page renders search + entity + action filters', async ({ page }) => {
|
||||||
|
// First-hit dev-mode compile of the upgraded route can exceed 30s.
|
||||||
|
await page.goto(`/${PORT_SLUG}/admin/audit`, { timeout: 60_000 });
|
||||||
|
await expect(page.getByRole('heading', { name: /audit log/i })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('audit-search')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.getByTestId('audit-entity')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('audit-action')).toBeVisible();
|
||||||
|
// Date filters render alongside.
|
||||||
|
await expect(page.getByLabel(/^from$/i)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/^to$/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit API returns cursor-paginated rows with the new shape', async ({ page }) => {
|
||||||
|
// Pull the port id from the admin/ports list so the audit request can
|
||||||
|
// pass X-Port-Id (the audit endpoint scopes results to a single port).
|
||||||
|
const portsRes = await page.request.get('/api/v1/admin/ports');
|
||||||
|
const portsBody = (await portsRes.json()) as { data: Array<{ id: string }> };
|
||||||
|
const portId = portsBody.data[0]?.id;
|
||||||
|
expect(portId).toBeTruthy();
|
||||||
|
|
||||||
|
const auditRes = await page.request.get(`/api/v1/admin/audit?limit=5`, {
|
||||||
|
headers: { 'X-Port-Id': portId! },
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
expect(auditRes.status()).toBe(200);
|
||||||
|
const body = (await auditRes.json()) as {
|
||||||
|
data: Array<{ action: string; entityType: string }>;
|
||||||
|
pagination: { nextCursor: { createdAt: string; id: string } | null };
|
||||||
|
};
|
||||||
|
expect(Array.isArray(body.data)).toBe(true);
|
||||||
|
expect(body.pagination).toHaveProperty('nextCursor');
|
||||||
|
});
|
||||||
|
});
|
||||||
196
tests/integration/audit-search.test.ts
Normal file
196
tests/integration/audit-search.test.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* PR10 — audit log search.
|
||||||
|
*
|
||||||
|
* Validates:
|
||||||
|
* 1. Tsvector full-text search via the GENERATED `search_text` column
|
||||||
|
* 2. All filters compose: entityType, action, userId, entityId, date range
|
||||||
|
* 3. Cursor pagination on (createdAt, id) yields stable, complete pages
|
||||||
|
* and never duplicates rows across page boundaries
|
||||||
|
* 4. Per-port scoping isolates results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
|
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
||||||
|
import { makePort } from '../helpers/factories';
|
||||||
|
|
||||||
|
async function seed(args: {
|
||||||
|
portId: string;
|
||||||
|
userId?: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
}) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(auditLogs)
|
||||||
|
.values({
|
||||||
|
portId: args.portId,
|
||||||
|
userId: args.userId ?? null,
|
||||||
|
action: args.action,
|
||||||
|
entityType: args.entityType,
|
||||||
|
entityId: args.entityId ?? null,
|
||||||
|
createdAt: args.createdAt ?? new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('audit log search', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Tests are seed-isolated by port, so no global wipe needed.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds rows by entityType filter', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await seed({ portId: port.id, action: 'create', entityType: 'client' });
|
||||||
|
await seed({ portId: port.id, action: 'create', entityType: 'invoice' });
|
||||||
|
await seed({ portId: port.id, action: 'update', entityType: 'client' });
|
||||||
|
|
||||||
|
const { rows } = await searchAuditLogs({ portId: port.id, entityType: 'client' });
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows.every((r) => r.entityType === 'client')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by action and userId together', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await seed({ portId: port.id, userId: 'u1', action: 'create', entityType: 'client' });
|
||||||
|
await seed({ portId: port.id, userId: 'u2', action: 'create', entityType: 'client' });
|
||||||
|
await seed({ portId: port.id, userId: 'u1', action: 'delete', entityType: 'client' });
|
||||||
|
|
||||||
|
const { rows } = await searchAuditLogs({
|
||||||
|
portId: port.id,
|
||||||
|
action: 'create',
|
||||||
|
userId: 'u1',
|
||||||
|
});
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]?.userId).toBe('u1');
|
||||||
|
expect(rows[0]?.action).toBe('create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full-text search hits the tsvector column on action + entityType + entityId', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await seed({
|
||||||
|
portId: port.id,
|
||||||
|
action: 'archive',
|
||||||
|
entityType: 'expense',
|
||||||
|
entityId: 'expense-marina-fuel-001',
|
||||||
|
});
|
||||||
|
await seed({
|
||||||
|
portId: port.id,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'invoice',
|
||||||
|
entityId: 'inv-001',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = await searchAuditLogs({ portId: port.id, q: 'archive' });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]?.action).toBe('archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor pagination returns stable contiguous pages with no duplicates', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const now = Date.now();
|
||||||
|
// Seed 7 rows with deterministic timestamps so ordering is stable.
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
await seed({
|
||||||
|
portId: port.id,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'client',
|
||||||
|
entityId: `c-${i}`,
|
||||||
|
createdAt: new Date(now - i * 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const page1 = await searchAuditLogs({ portId: port.id, limit: 3 });
|
||||||
|
expect(page1.rows).toHaveLength(3);
|
||||||
|
expect(page1.nextCursor).not.toBeNull();
|
||||||
|
|
||||||
|
const page2 = await searchAuditLogs({
|
||||||
|
portId: port.id,
|
||||||
|
limit: 3,
|
||||||
|
cursor: page1.nextCursor!,
|
||||||
|
});
|
||||||
|
expect(page2.rows).toHaveLength(3);
|
||||||
|
expect(page2.nextCursor).not.toBeNull();
|
||||||
|
|
||||||
|
const page3 = await searchAuditLogs({
|
||||||
|
portId: port.id,
|
||||||
|
limit: 3,
|
||||||
|
cursor: page2.nextCursor!,
|
||||||
|
});
|
||||||
|
expect(page3.rows).toHaveLength(1);
|
||||||
|
expect(page3.nextCursor).toBeNull();
|
||||||
|
|
||||||
|
const allIds = [...page1.rows, ...page2.rows, ...page3.rows].map((r) => r.id);
|
||||||
|
expect(new Set(allIds).size).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates results by portId', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
await seed({ portId: portA.id, action: 'create', entityType: 'client' });
|
||||||
|
await seed({ portId: portB.id, action: 'create', entityType: 'client' });
|
||||||
|
|
||||||
|
const a = await searchAuditLogs({ portId: portA.id });
|
||||||
|
const b = await searchAuditLogs({ portId: portB.id });
|
||||||
|
expect(a.rows.every((r) => r.portId === portA.id)).toBe(true);
|
||||||
|
expect(b.rows.every((r) => r.portId === portB.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects from/to date range', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const now = Date.now();
|
||||||
|
await seed({
|
||||||
|
portId: port.id,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'client',
|
||||||
|
createdAt: new Date(now - 10 * 86_400_000),
|
||||||
|
});
|
||||||
|
await seed({
|
||||||
|
portId: port.id,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'client',
|
||||||
|
createdAt: new Date(now - 1 * 86_400_000),
|
||||||
|
});
|
||||||
|
await seed({
|
||||||
|
portId: port.id,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'client',
|
||||||
|
createdAt: new Date(now),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = await searchAuditLogs({
|
||||||
|
portId: port.id,
|
||||||
|
from: new Date(now - 2 * 86_400_000),
|
||||||
|
});
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without portId, returns rows across ports (super-admin path)', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
await seed({
|
||||||
|
portId: portA.id,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'client',
|
||||||
|
entityId: 'across-port-marker',
|
||||||
|
});
|
||||||
|
await seed({
|
||||||
|
portId: portB.id,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'client',
|
||||||
|
entityId: 'across-port-marker',
|
||||||
|
});
|
||||||
|
const { rows } = await searchAuditLogs({ q: 'across-port-marker' });
|
||||||
|
const portIds = new Set(rows.map((r) => r.portId));
|
||||||
|
expect(portIds.has(portA.id)).toBe(true);
|
||||||
|
expect(portIds.has(portB.id)).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup so the across-port query doesn't bleed into other tests.
|
||||||
|
await db.delete(auditLogs).where(eq(auditLogs.portId, portA.id));
|
||||||
|
await db.delete(auditLogs).where(eq(auditLogs.portId, portB.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
153
tests/integration/documents-hub-eoi-queue.test.ts
Normal file
153
tests/integration/documents-hub-eoi-queue.test.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* PR6 — documents hub `eoi_queue` tab.
|
||||||
|
*
|
||||||
|
* Verifies that:
|
||||||
|
* - `listDocuments` with tab='eoi_queue' returns only EOI docs in
|
||||||
|
* draft/sent/partially_signed status
|
||||||
|
* - `getHubTabCounts` reports the correct eoi_queue count
|
||||||
|
* - Other doc types (NDA, contract, welcome_letter) are excluded
|
||||||
|
* - Completed/expired EOIs are excluded (those belong to other tabs)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { documents } from '@/lib/db/schema/documents';
|
||||||
|
import { getHubTabCounts, listDocuments } from '@/lib/services/documents.service';
|
||||||
|
import { makePort, makeClient } from '../helpers/factories';
|
||||||
|
|
||||||
|
describe('documents hub — eoi_queue tab', () => {
|
||||||
|
it('lists only EOIs in in-flight status', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
|
||||||
|
// Seed a mix: 2 in-flight EOIs, 1 completed EOI, 1 sent NDA, 1 sent welcome_letter.
|
||||||
|
await db.insert(documents).values([
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'EOI #1',
|
||||||
|
status: 'sent',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'EOI #2',
|
||||||
|
status: 'partially_signed',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'EOI #3 (done)',
|
||||||
|
status: 'completed',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'nda',
|
||||||
|
title: 'NDA',
|
||||||
|
status: 'sent',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'welcome_letter',
|
||||||
|
title: 'Welcome',
|
||||||
|
status: 'sent',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await listDocuments(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
sort: 'createdAt',
|
||||||
|
order: 'desc',
|
||||||
|
includeArchived: false,
|
||||||
|
tab: 'eoi_queue',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const docs = result.data as Array<{ documentType: string; status: string }>;
|
||||||
|
expect(docs).toHaveLength(2);
|
||||||
|
expect(docs.every((d) => d.documentType === 'eoi')).toBe(true);
|
||||||
|
expect(docs.every((d) => ['sent', 'partially_signed'].includes(d.status))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the correct eoi_queue count via getHubTabCounts', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
|
||||||
|
await db.insert(documents).values([
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'EOI A',
|
||||||
|
status: 'draft',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'EOI B',
|
||||||
|
status: 'sent',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'contract',
|
||||||
|
title: 'Contract X',
|
||||||
|
status: 'sent',
|
||||||
|
createdBy: 'seed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const counts = await getHubTabCounts(port.id, undefined);
|
||||||
|
expect(counts.eoi_queue).toBe(2);
|
||||||
|
// The contract should not bump eoi_queue.
|
||||||
|
expect(counts.all).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when no in-flight EOIs exist', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
await db.insert(documents).values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'old EOI',
|
||||||
|
status: 'expired',
|
||||||
|
createdBy: 'seed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await listDocuments(
|
||||||
|
port.id,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
sort: 'createdAt',
|
||||||
|
order: 'desc',
|
||||||
|
includeArchived: false,
|
||||||
|
tab: 'eoi_queue',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.data).toHaveLength(0);
|
||||||
|
|
||||||
|
const counts = await getHubTabCounts(port.id, undefined);
|
||||||
|
expect(counts.eoi_queue).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
202
tests/integration/expense-dedup.test.ts
Normal file
202
tests/integration/expense-dedup.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* PR8 — expense duplicate detection.
|
||||||
|
*
|
||||||
|
* Validates:
|
||||||
|
* 1. `scanForDuplicates` matches by port + lower(vendor) + amount + date ±3d
|
||||||
|
* 2. Same-day matches score 1.0; off-by-N-days score 0.85
|
||||||
|
* 3. `markBestDuplicate` writes the highest-confidence match into `duplicateOf`
|
||||||
|
* 4. `clearDuplicate` nulls `duplicateOf` but keeps `dedupScannedAt`
|
||||||
|
* 5. `mergeDuplicate` consolidates receipts + archives the source row
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { expenses } from '@/lib/db/schema/financial';
|
||||||
|
import {
|
||||||
|
scanForDuplicates,
|
||||||
|
markBestDuplicate,
|
||||||
|
clearDuplicate,
|
||||||
|
mergeDuplicate,
|
||||||
|
} from '@/lib/services/expense-dedup.service';
|
||||||
|
import { makePort } from '../helpers/factories';
|
||||||
|
|
||||||
|
async function seedExpense(args: {
|
||||||
|
portId: string;
|
||||||
|
establishmentName: string;
|
||||||
|
amount: string;
|
||||||
|
expenseDate: Date;
|
||||||
|
receiptFileIds?: string[];
|
||||||
|
}) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(expenses)
|
||||||
|
.values({
|
||||||
|
portId: args.portId,
|
||||||
|
establishmentName: args.establishmentName,
|
||||||
|
amount: args.amount,
|
||||||
|
currency: 'USD',
|
||||||
|
expenseDate: args.expenseDate,
|
||||||
|
receiptFileIds: args.receiptFileIds ?? [],
|
||||||
|
createdBy: 'seed',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('expense dedup', () => {
|
||||||
|
it('scanForDuplicates finds matches in the ±3 day window with case-insensitive vendor', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const today = new Date('2026-04-15T12:00:00Z');
|
||||||
|
const target = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Marina Fuel',
|
||||||
|
amount: '120.00',
|
||||||
|
expenseDate: today,
|
||||||
|
});
|
||||||
|
// Match: same vendor (different case), same amount, +2 days
|
||||||
|
await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'marina fuel',
|
||||||
|
amount: '120.00',
|
||||||
|
expenseDate: new Date('2026-04-17T09:00:00Z'),
|
||||||
|
});
|
||||||
|
// Non-match: outside the window
|
||||||
|
await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Marina Fuel',
|
||||||
|
amount: '120.00',
|
||||||
|
expenseDate: new Date('2026-04-22T09:00:00Z'),
|
||||||
|
});
|
||||||
|
// Non-match: different amount
|
||||||
|
await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Marina Fuel',
|
||||||
|
amount: '125.00',
|
||||||
|
expenseDate: today,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matches = await scanForDuplicates(target.id);
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
expect(matches[0]?.confidence).toBeCloseTo(0.85, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('same-day match scores 1.0', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const today = new Date('2026-04-15T12:00:00Z');
|
||||||
|
const target = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Acme',
|
||||||
|
amount: '50',
|
||||||
|
expenseDate: today,
|
||||||
|
});
|
||||||
|
await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Acme',
|
||||||
|
amount: '50',
|
||||||
|
expenseDate: today,
|
||||||
|
});
|
||||||
|
const [m] = await scanForDuplicates(target.id);
|
||||||
|
expect(m?.confidence).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markBestDuplicate writes duplicateOf when a candidate exists, leaves null otherwise', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const lonely = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Solo',
|
||||||
|
amount: '10',
|
||||||
|
expenseDate: new Date('2026-04-15T12:00:00Z'),
|
||||||
|
});
|
||||||
|
const matchedId = await markBestDuplicate(lonely.id);
|
||||||
|
expect(matchedId).toBeNull();
|
||||||
|
const [refreshed] = await db.select().from(expenses).where(eq(expenses.id, lonely.id));
|
||||||
|
expect(refreshed?.duplicateOf).toBeNull();
|
||||||
|
expect(refreshed?.dedupScannedAt).not.toBeNull();
|
||||||
|
|
||||||
|
const original = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Twin',
|
||||||
|
amount: '20',
|
||||||
|
expenseDate: new Date('2026-04-15T12:00:00Z'),
|
||||||
|
});
|
||||||
|
const dup = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Twin',
|
||||||
|
amount: '20',
|
||||||
|
expenseDate: new Date('2026-04-15T13:00:00Z'),
|
||||||
|
});
|
||||||
|
const matched = await markBestDuplicate(dup.id);
|
||||||
|
expect(matched).toBe(original.id);
|
||||||
|
const [withDup] = await db.select().from(expenses).where(eq(expenses.id, dup.id));
|
||||||
|
expect(withDup?.duplicateOf).toBe(original.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearDuplicate nulls duplicateOf but preserves dedupScannedAt', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const a = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'X',
|
||||||
|
amount: '5',
|
||||||
|
expenseDate: new Date('2026-04-15T12:00:00Z'),
|
||||||
|
});
|
||||||
|
const b = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'X',
|
||||||
|
amount: '5',
|
||||||
|
expenseDate: new Date('2026-04-15T13:00:00Z'),
|
||||||
|
});
|
||||||
|
await markBestDuplicate(b.id);
|
||||||
|
await clearDuplicate(b.id, port.id);
|
||||||
|
const [refreshed] = await db.select().from(expenses).where(eq(expenses.id, b.id));
|
||||||
|
expect(refreshed?.duplicateOf).toBeNull();
|
||||||
|
expect(refreshed?.dedupScannedAt).not.toBeNull();
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mergeDuplicate combines receipts and archives the source', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const target = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Y',
|
||||||
|
amount: '7',
|
||||||
|
expenseDate: new Date('2026-04-15T12:00:00Z'),
|
||||||
|
receiptFileIds: ['file-A'],
|
||||||
|
});
|
||||||
|
const source = await seedExpense({
|
||||||
|
portId: port.id,
|
||||||
|
establishmentName: 'Y',
|
||||||
|
amount: '7',
|
||||||
|
expenseDate: new Date('2026-04-15T13:00:00Z'),
|
||||||
|
receiptFileIds: ['file-B', 'file-A'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await mergeDuplicate(source.id, target.id, port.id);
|
||||||
|
|
||||||
|
const [survivor] = await db.select().from(expenses).where(eq(expenses.id, target.id));
|
||||||
|
expect(new Set(survivor?.receiptFileIds ?? [])).toEqual(new Set(['file-A', 'file-B']));
|
||||||
|
|
||||||
|
const [archived] = await db.select().from(expenses).where(eq(expenses.id, source.id));
|
||||||
|
expect(archived?.archivedAt).not.toBeNull();
|
||||||
|
expect(archived?.duplicateOf).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mergeDuplicate refuses self-merge and cross-port', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
const a = await seedExpense({
|
||||||
|
portId: portA.id,
|
||||||
|
establishmentName: 'Z',
|
||||||
|
amount: '1',
|
||||||
|
expenseDate: new Date('2026-04-15T12:00:00Z'),
|
||||||
|
});
|
||||||
|
const b = await seedExpense({
|
||||||
|
portId: portB.id,
|
||||||
|
establishmentName: 'Z',
|
||||||
|
amount: '1',
|
||||||
|
expenseDate: new Date('2026-04-15T12:00:00Z'),
|
||||||
|
});
|
||||||
|
await expect(mergeDuplicate(a.id, a.id, portA.id)).rejects.toThrow(/itself/);
|
||||||
|
await expect(mergeDuplicate(a.id, b.id, portA.id)).rejects.toThrow(/not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
130
tests/integration/ocr-config.test.ts
Normal file
130
tests/integration/ocr-config.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* PR9 — OCR config service.
|
||||||
|
*
|
||||||
|
* Validates:
|
||||||
|
* 1. Per-port save/read round-trip (key encrypted at rest, decrypted on resolve)
|
||||||
|
* 2. Public view never echoes the raw key
|
||||||
|
* 3. Global fallback when port row sets useGlobal=true
|
||||||
|
* 4. Source field is correctly tagged ('port' | 'global' | 'none')
|
||||||
|
* 5. clearApiKey wipes the stored key
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { eq, isNull, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import {
|
||||||
|
saveOcrConfig,
|
||||||
|
getResolvedOcrConfig,
|
||||||
|
getPublicOcrConfig,
|
||||||
|
} from '@/lib/services/ocr-config.service';
|
||||||
|
import { makePort } from '../helpers/factories';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(systemSettings).where(eq(systemSettings.key, 'ocr.config'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OCR config', () => {
|
||||||
|
it('round-trips a per-port config and decrypts the key on resolve', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await saveOcrConfig(
|
||||||
|
port.id,
|
||||||
|
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-test-abc-123' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
const resolved = await getResolvedOcrConfig(port.id);
|
||||||
|
expect(resolved.provider).toBe('openai');
|
||||||
|
expect(resolved.model).toBe('gpt-4o-mini');
|
||||||
|
expect(resolved.apiKey).toBe('sk-test-abc-123');
|
||||||
|
expect(resolved.hasApiKey).toBe(true);
|
||||||
|
expect(resolved.source).toBe('port');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('public view never includes the raw key', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await saveOcrConfig(
|
||||||
|
port.id,
|
||||||
|
{ provider: 'claude', model: 'claude-haiku-4-5', apiKey: 'sk-secret' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
const pub = await getPublicOcrConfig(port.id);
|
||||||
|
expect(pub).not.toHaveProperty('apiKey');
|
||||||
|
expect(pub.hasApiKey).toBe(true);
|
||||||
|
expect(pub.provider).toBe('claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to global when useGlobal is true on the port row', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
// Set up the global row.
|
||||||
|
await saveOcrConfig(
|
||||||
|
null,
|
||||||
|
{ provider: 'openai', model: 'gpt-4o', apiKey: 'global-key' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
// Port row opts in.
|
||||||
|
await saveOcrConfig(
|
||||||
|
port.id,
|
||||||
|
{ provider: 'claude', model: 'claude-haiku-4-5', apiKey: 'port-key', useGlobal: true },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
const resolved = await getResolvedOcrConfig(port.id);
|
||||||
|
expect(resolved.source).toBe('global');
|
||||||
|
expect(resolved.apiKey).toBe('global-key');
|
||||||
|
expect(resolved.provider).toBe('openai');
|
||||||
|
expect(resolved.useGlobal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns source=none when neither port nor global is configured', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const resolved = await getResolvedOcrConfig(port.id);
|
||||||
|
expect(resolved.source).toBe('none');
|
||||||
|
expect(resolved.apiKey).toBeNull();
|
||||||
|
expect(resolved.hasApiKey).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearApiKey nulls the stored key but preserves provider/model', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await saveOcrConfig(
|
||||||
|
port.id,
|
||||||
|
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'first-key' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
await saveOcrConfig(
|
||||||
|
port.id,
|
||||||
|
{ provider: 'openai', model: 'gpt-4o-mini', clearApiKey: true },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
const resolved = await getResolvedOcrConfig(port.id);
|
||||||
|
expect(resolved.apiKey).toBeNull();
|
||||||
|
expect(resolved.hasApiKey).toBe(false);
|
||||||
|
expect(resolved.provider).toBe('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omitting apiKey on save preserves the existing one', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await saveOcrConfig(
|
||||||
|
port.id,
|
||||||
|
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'keep-me' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
// Update model only — no apiKey field provided.
|
||||||
|
await saveOcrConfig(port.id, { provider: 'openai', model: 'gpt-4o' }, 'user-1');
|
||||||
|
const resolved = await getResolvedOcrConfig(port.id);
|
||||||
|
expect(resolved.apiKey).toBe('keep-me');
|
||||||
|
expect(resolved.model).toBe('gpt-4o');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => {
|
||||||
|
await saveOcrConfig(
|
||||||
|
null,
|
||||||
|
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'g', useGlobal: true },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(and(eq(systemSettings.key, 'ocr.config'), isNull(systemSettings.portId)));
|
||||||
|
expect((row?.value as { useGlobal: boolean }).useGlobal).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user