diff --git a/src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx b/src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx new file mode 100644 index 0000000..c77238b --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx @@ -0,0 +1,5 @@ +import { OcrSettingsForm } from '@/components/admin/ocr-settings-form'; + +export default function OcrSettingsPage() { + return ; +} diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx index f91014e..51f9976 100644 --- a/src/app/(dashboard)/[portSlug]/admin/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx @@ -149,6 +149,12 @@ const SECTIONS: AdminSection[] = [ description: 'Initial-setup wizard for fresh ports.', 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({ diff --git a/src/app/(dashboard)/[portSlug]/alerts/page.tsx b/src/app/(dashboard)/[portSlug]/alerts/page.tsx new file mode 100644 index 0000000..cc069a2 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/alerts/page.tsx @@ -0,0 +1,5 @@ +import { AlertsPageShell } from '@/components/alerts/alerts-page-shell'; + +export default function AlertsPage() { + return ; +} diff --git a/src/app/(scanner)/[portSlug]/scan/layout.tsx b/src/app/(scanner)/[portSlug]/scan/layout.tsx new file mode 100644 index 0000000..6c65708 --- /dev/null +++ b/src/app/(scanner)/[portSlug]/scan/layout.tsx @@ -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 ( + + + + + + + + + + + +
{children}
+
+
+ ); +} diff --git a/src/app/(scanner)/[portSlug]/scan/manifest.webmanifest/route.ts b/src/app/(scanner)/[portSlug]/scan/manifest.webmanifest/route.ts new file mode 100644 index 0000000..f1f0dd0 --- /dev/null +++ b/src/app/(scanner)/[portSlug]/scan/manifest.webmanifest/route.ts @@ -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 `//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', + }, + }); +} diff --git a/src/app/(scanner)/[portSlug]/scan/page.tsx b/src/app/(scanner)/[portSlug]/scan/page.tsx new file mode 100644 index 0000000..4bb6ac7 --- /dev/null +++ b/src/app/(scanner)/[portSlug]/scan/page.tsx @@ -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 ; +} diff --git a/src/app/api/v1/admin/alerts/run-engine/route.ts b/src/app/api/v1/admin/alerts/run-engine/route.ts new file mode 100644 index 0000000..3fef6d1 --- /dev/null +++ b/src/app/api/v1/admin/alerts/run-engine/route.ts @@ -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); + } +}); diff --git a/src/app/api/v1/admin/audit/route.ts b/src/app/api/v1/admin/audit/route.ts index f9d34d6..a2afa46 100644 --- a/src/app/api/v1/admin/audit/route.ts +++ b/src/app/api/v1/admin/audit/route.ts @@ -1,29 +1,76 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; +import { inArray } from 'drizzle-orm'; import { withAuth, withPermission } from '@/lib/api/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'; const auditQuerySchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(50), + limit: z.coerce.number().int().min(1).max(200).default(50), entityType: z.string().optional(), action: z.string().optional(), userId: z.string().optional(), entityId: z.string().optional(), dateFrom: z.string().optional(), dateTo: z.string().optional(), + /** Free-text query against the tsvector `search_text` column. */ search: z.string().optional(), + /** Cursor pair from the previous page's response. */ + cursorAt: z.string().optional(), + cursorId: z.string().optional(), }); export const GET = withAuth( withPermission('admin', 'view_audit_log', async (req, ctx) => { try { const query = parseQuery(req, auditQuerySchema); - const result = await listAuditLogs(ctx.portId, query); - return NextResponse.json(result); + const cursor = + 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) { return errorResponse(error); } diff --git a/src/app/api/v1/admin/ocr-settings/route.ts b/src/app/api/v1/admin/ocr-settings/route.ts new file mode 100644 index 0000000..b84558c --- /dev/null +++ b/src/app/api/v1/admin/ocr-settings/route.ts @@ -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); + } +}); diff --git a/src/app/api/v1/admin/ocr-settings/test/route.ts b/src/app/api/v1/admin/ocr-settings/test/route.ts new file mode 100644 index 0000000..2a733c4 --- /dev/null +++ b/src/app/api/v1/admin/ocr-settings/test/route.ts @@ -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); + } +}); diff --git a/src/app/api/v1/alerts/[id]/acknowledge/route.ts b/src/app/api/v1/alerts/[id]/acknowledge/route.ts new file mode 100644 index 0000000..a54f1f5 --- /dev/null +++ b/src/app/api/v1/alerts/[id]/acknowledge/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/alerts/[id]/dismiss/route.ts b/src/app/api/v1/alerts/[id]/dismiss/route.ts new file mode 100644 index 0000000..807f077 --- /dev/null +++ b/src/app/api/v1/alerts/[id]/dismiss/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/alerts/count/route.ts b/src/app/api/v1/alerts/count/route.ts new file mode 100644 index 0000000..13c436d --- /dev/null +++ b/src/app/api/v1/alerts/count/route.ts @@ -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`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; + let total = 0; + for (const r of rows) { + bySeverity[r.severity] = r.count; + total += r.count; + } + return NextResponse.json({ total, bySeverity }); +}); diff --git a/src/app/api/v1/alerts/route.ts b/src/app/api/v1/alerts/route.ts new file mode 100644 index 0000000..b280632 --- /dev/null +++ b/src/app/api/v1/alerts/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/analytics/route.ts b/src/app/api/v1/analytics/route.ts new file mode 100644 index 0000000..15ff292 --- /dev/null +++ b/src/app/api/v1/analytics/route.ts @@ -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 Promise> = { + 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 }); +}); diff --git a/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts b/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts new file mode 100644 index 0000000..2093666 --- /dev/null +++ b/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/expenses/[id]/merge/route.ts b/src/app/api/v1/expenses/[id]/merge/route.ts new file mode 100644 index 0000000..b071bc2 --- /dev/null +++ b/src/app/api/v1/expenses/[id]/merge/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/expenses/scan-receipt/route.ts b/src/app/api/v1/expenses/scan-receipt/route.ts index 810a0f2..44b4966 100644 --- a/src/app/api/v1/expenses/scan-receipt/route.ts +++ b/src/app/api/v1/expenses/scan-receipt/route.ts @@ -2,24 +2,62 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; 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( - withPermission('expenses', 'create', async (req, _ctx) => { + withPermission('expenses', 'create', async (req, ctx) => { try { const formData = await req.formData(); const file = formData.get('file') as File | null; - if (!file) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }); } - const buffer = Buffer.from(await file.arrayBuffer()); 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) { return errorResponse(error); } diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index d4daaff..f74c37d 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -1,14 +1,16 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { formatDistanceToNow } from 'date-fns'; -import { Search } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import { DataTable } from '@/components/shared/data-table'; import { PageHeader } from '@/components/shared/page-header'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; import { Select, SelectContent, @@ -23,13 +25,19 @@ interface AuditEntry { userId: string | null; action: string; entityType: string; - entityId: string; + entityId: string | null; fieldChanged: string | null; oldValue: Record | null; newValue: Record | null; metadata: Record | null; ipAddress: string | null; 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 = { @@ -40,6 +48,8 @@ const ACTION_COLORS: Record = { restore: 'bg-teal-500', login: 'bg-gray-500', permission_denied: 'bg-red-800', + merge: 'bg-purple-500', + revert: 'bg-amber-500', }; const ENTITY_TYPES = [ @@ -58,40 +68,96 @@ const ENTITY_TYPES = [ 'webhook', ]; +function useDebounced(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() { const [entries, setEntries] = useState([]); + const [nextCursor, setNextCursor] = useState<{ + createdAt: string; + id: string; + } | null>(null); const [loading, setLoading] = useState(true); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [entityTypeFilter, setEntityTypeFilter] = useState('all'); - const [actionFilter, setActionFilter] = useState('all'); - const [search, setSearch] = useState(''); + const [loadingMore, setLoadingMore] = useState(false); - const fetchLogs = useCallback(async () => { + // Filter state — debounce text inputs. + const [search, setSearch] = useState(''); + const [entityType, setEntityType] = useState('all'); + const [action, setAction] = useState('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); try { - const params = new URLSearchParams({ - 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}`); + const res = await apiFetch(`/api/v1/admin/audit?${queryString}`); setEntries(res.data); - setTotal(res.pagination.total); + setNextCursor(res.pagination.nextCursor); } finally { 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(`/api/v1/admin/audit?${params}`); + setEntries((prev) => [...prev, ...res.data]); + setNextCursor(res.pagination.nextCursor); + } finally { + setLoadingMore(false); + } + }, [queryString, nextCursor]); useEffect(() => { - void fetchLogs(); - }, [fetchLogs]); + void fetchFirstPage(); + }, [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[] = [ { @@ -117,7 +183,7 @@ export function AuditLogList() { {row.original.action} ), - size: 100, + size: 110, }, { accessorKey: 'entityType', @@ -125,9 +191,11 @@ export function AuditLogList() { cell: ({ row }) => (
{row.original.entityType} - - {row.original.entityId.slice(0, 8)}... - + {row.original.entityId ? ( + + {row.original.entityId.slice(0, 8)}… + + ) : null}
), }, @@ -150,108 +218,166 @@ export function AuditLogList() { }, }, { - accessorKey: 'userId', - header: 'User', - cell: ({ row }) => ( - - {row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'} - - ), - size: 100, + id: 'actor', + header: 'Actor', + cell: ({ row }) => { + const { actor, userId: rawId } = row.original; + if (actor) { + return ( +
+
{actor.name}
+
{actor.email}
+
+ ); + } + if (rawId) { + return {rawId.slice(0, 8)}…; + } + return system; + }, + size: 200, }, ]; return (
- - -
-
- - { - setSearch(e.target.value); - setPage(1); - }} - /> -
- - -
- - row.id} - emptyState={ -
-

No audit log entries found.

-
- } + - {total > 50 && ( -
- - - Page {page} of {Math.ceil(total / 50)} - - +
+
+ +
+ + setSearch(e.target.value)} + data-testid="audit-search" + /> +
- )} + +
+ + +
+ +
+ + +
+ +
+ + setUserId(e.target.value)} + /> +
+ +
+ + setDateFrom(e.target.value)} + /> +
+ +
+ + setDateTo(e.target.value)} + /> +
+ + {hasActiveFilter ? ( + + ) : null} +
+ +
+ row.id} + emptyState={ +
+

No audit log entries found.

+
+ } + /> +
+ + {nextCursor ? ( +
+ +
+ ) : null}
); } diff --git a/src/components/admin/ocr-settings-form.tsx b/src/components/admin/ocr-settings-form.tsx new file mode 100644 index 0000000..85ac249 --- /dev/null +++ b/src/components/admin/ocr-settings-form.tsx @@ -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; +} + +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({ + queryKey, + queryFn: () => apiFetch(`/api/v1/admin/ocr-settings?scope=${scope}`), + }); + + const [provider, setProvider] = useState('openai'); + const [model, setModel] = useState('gpt-4o-mini'); + const [apiKey, setApiKey] = useState(''); + const [showKey, setShowKey] = useState(false); + const [useGlobal, setUseGlobal] = useState(false); + const [testStatus, setTestStatus] = useState( + 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 ( + + + {title} + + + Loading… + + + ); + } + + return ( + + + {title} +

{description}

+
+ + {showUseGlobal ? ( +
+ setUseGlobal(v === true)} + /> +
+ +

+ When enabled, this port falls back to the system-wide OCR settings. Per-port + provider/model/key are ignored. +

+
+
+ ) : null} + +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ { + setApiKey(e.target.value); + setTestStatus(null); + }} + /> + +
+

+ Stored encrypted at rest. Never re-displayed after saving. +

+
+ +
+ + + {hasKey ? ( + + ) : null} + + {testStatus?.ok ? ( + + + Connection OK + + ) : null} + {testStatus && !testStatus.ok ? ( + + + {testStatus.reason} + + ) : null} +
+
+
+ ); +} + +export function OcrSettingsForm() { + const { isSuperAdmin } = usePermissions(); + + return ( +
+ + + + + {isSuperAdmin ? ( + + ) : null} +
+ ); +} diff --git a/src/components/alerts/alert-bell.tsx b/src/components/alerts/alert-bell.tsx new file mode 100644 index 0000000..603c820 --- /dev/null +++ b/src/components/alerts/alert-bell.tsx @@ -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 ( + + + + + +
+

Active alerts

+ + View all + +
+ + + {isLoading ? ( +
Loading…
+ ) : top.length === 0 ? ( +
+ +
+ ) : ( +
+ {top.map((a) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/alerts/alert-card.tsx b/src/components/alerts/alert-card.tsx new file mode 100644 index 0000000..aaa89a1 --- /dev/null +++ b/src/components/alerts/alert-card.tsx @@ -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 = { + 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 ( +
+ + +
+
+

{alert.title}

+ {acknowledged ? ( + ack + ) : null} +
+ {alert.body ? ( +

{alert.body}

+ ) : null} +
+ {fired} + · + {alert.ruleId} +
+
+ {!readOnly ? ( +
+ {!acknowledged ? ( + + ) : null} + + {alert.link ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +export function AlertCardEmpty() { + return ( +
+ +

All clear

+

No active alerts right now.

+
+ ); +} diff --git a/src/components/alerts/alert-rail.tsx b/src/components/alerts/alert-rail.tsx new file mode 100644 index 0000000..fabdfc5 --- /dev/null +++ b/src/components/alerts/alert-rail.tsx @@ -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 ( +
+
+

Alerts

+ + View all + + +
+ + {isLoading ? ( +
+ + + +
+ ) : visible.length === 0 ? ( + + ) : ( +
+ {visible.map((a) => ( + + ))} + {overflow > 0 ? ( + + +{overflow} more — view all + + ) : null} +
+ )} +
+ ); +} diff --git a/src/components/alerts/alerts-page-shell.tsx b/src/components/alerts/alerts-page-shell.tsx new file mode 100644 index 0000000..f0eb2ef --- /dev/null +++ b/src/components/alerts/alerts-page-shell.tsx @@ -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('open'); + const { data: count } = useAlertCount(); + const { data, isLoading } = useAlertList(tab); + useAlertRealtime(); + + const total = count?.total ?? 0; + const alerts = data?.data ?? []; + + return ( +
+ + + {total} active + + } + variant="gradient" + /> + + setTab(v as AlertStatus)}> + + + Active{total > 0 ? ` · ${total}` : ''} + + + Dismissed + + + Resolved + + + + + {isLoading ? ( +
+ + + +
+ ) : alerts.length === 0 ? ( + + ) : ( + alerts.map((a) => ) + )} +
+
+
+ ); +} diff --git a/src/components/alerts/types.ts b/src/components/alerts/types.ts new file mode 100644 index 0000000..58d4916 --- /dev/null +++ b/src/components/alerts/types.ts @@ -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'; diff --git a/src/components/alerts/use-alerts.ts b/src/components/alerts/use-alerts.ts new file mode 100644 index 0000000..016ae06 --- /dev/null +++ b/src/components/alerts/use-alerts.ts @@ -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({ + queryKey: ['alerts', status], + queryFn: () => apiFetch(`/api/v1/alerts?status=${status}`), + staleTime: 30_000, + enabled, + }); +} + +export function useAlertCount() { + return useQuery({ + queryKey: ['alerts', 'count'], + queryFn: () => apiFetch('/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']], + }); +} diff --git a/src/components/berths/berth-interests-tab.tsx b/src/components/berths/berth-interests-tab.tsx new file mode 100644 index 0000000..b965baa --- /dev/null +++ b/src/components/berths/berth-interests-tab.tsx @@ -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 = { + 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 = { + 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 = { + hot_lead: 0, + specific_qualified: 1, + general_interest: 2, +}; + +const CATEGORY_LABELS: Record = { + hot_lead: 'Hot Lead', + specific_qualified: 'Specific Qualified', + general_interest: 'General Interest', +}; + +const SOURCE_LABELS: Record = { + 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('all'); + const [sortMode, setSortMode] = useState('newest'); + + const { data, isLoading } = useQuery({ + queryKey: ['interests', 'by-berth', berthId], + queryFn: () => apiFetch(`/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(() => { + 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 ; + + if ((data?.data ?? []).length === 0) { + return ( + + ); + } + + return ( +
+
+
+ {rows.length} of {data?.total ?? 0} interest{(data?.total ?? 0) === 1 ? '' : 's'} +
+
+ + +
+
+ +
+ + + + + + + + + + + + {rows.map((i) => ( + + + + + + + + + ))} + +
ClientStageCategorySourceLast activity +
+ + {i.clientName ?? '—'} + + + + {STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage} + + + {i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '—'} + + {i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '—'} + + {new Date(i.createdAt).toLocaleDateString()} + + +
+
+
+ ); +} diff --git a/src/components/berths/berth-tabs.tsx b/src/components/berths/berth-tabs.tsx index cbe71f4..3ea6b9e 100644 --- a/src/components/berths/berth-tabs.tsx +++ b/src/components/berths/berth-tabs.tsx @@ -4,6 +4,7 @@ import { type DetailTab } from '@/components/shared/detail-layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { TagBadge } from '@/components/shared/tag-badge'; import { BerthReservationsTab } from './berth-reservations-tab'; +import { BerthInterestsTab } from './berth-interests-tab'; type BerthData = { id: string; @@ -181,7 +182,7 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] { { id: 'interests', label: 'Interests', - content: , + content: , }, { id: 'reservations', diff --git a/src/components/dashboard/chart-card.tsx b/src/components/dashboard/chart-card.tsx new file mode 100644 index 0000000..aca14a3 --- /dev/null +++ b/src/components/dashboard/chart-card.tsx @@ -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((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(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 ( + + +
+ {title} + {description ?

{description}

: null} +
+ + + + + + {toCsv ? ( + + + Download CSV + + ) : null} + + + Download PNG + + + +
+ +
{children}
+
+
+ ); +} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 7324d7b..9c21c8e 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -1,22 +1,40 @@ 'use client'; +import { useState } from 'react'; + import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { PageHeader } from '@/components/shared/page-header'; import { KpiCardsWithBoundary } from './kpi-cards'; -import { PipelineChart } from './pipeline-chart'; -import { RevenueForecast } from './revenue-forecast'; 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 = { + today: 'Today', + '7d': 'Last 7 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', +}; export function DashboardShell() { + const [range, setRange] = useState('30d'); + useRealtimeInvalidation({ 'interest:stageChanged': [ - ['dashboard', 'pipeline'], - ['dashboard', 'forecast'], + ['analytics', 'pipeline_funnel', range], + ['analytics', 'lead_source_attribution', range], + ['dashboard', 'kpis'], ], 'client:created': [['dashboard', 'kpis']], 'berth:statusChanged': [ + ['analytics', 'occupancy_timeline', range], ['dashboard', 'kpis'], - ['dashboard', 'forecast'], ], }); @@ -26,26 +44,37 @@ export function DashboardShell() { title="Dashboard" eyebrow="Overview" description="Live snapshot of your marina activity" - kpiLine={Last 30 days} + kpiLine={{RANGE_LABELS[range]}} variant="gradient" + actions={} /> - {/* Row 1: KPI cards */}
- {/* Row 2: Pipeline chart + Revenue forecast */} -
-
- -
-
- +
+
+ + + + + + + + + + + +
+
- {/* Row 3: Activity feed */}
); diff --git a/src/components/dashboard/date-range-picker.tsx b/src/components/dashboard/date-range-picker.tsx new file mode 100644 index 0000000..6ac3143 --- /dev/null +++ b/src/components/dashboard/date-range-picker.tsx @@ -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 ( +
+ {OPTIONS.map((opt) => { + const active = opt.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/dashboard/lead-source-chart.tsx b/src/components/dashboard/lead-source-chart.tsx new file mode 100644 index 0000000..c04d64d --- /dev/null +++ b/src/components/dashboard/lead-source-chart.tsx @@ -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 = { + 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 ( + + {isLoading ? ( + + ) : !slices.length ? ( + + ) : ( + + + + {chartData.map((_, i) => ( + + ))} + + + + + + )} + + ); +} diff --git a/src/components/dashboard/occupancy-timeline-chart.tsx b/src/components/dashboard/occupancy-timeline-chart.tsx new file mode 100644 index 0000000..f779829 --- /dev/null +++ b/src/components/dashboard/occupancy-timeline-chart.tsx @@ -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 ( + + {isLoading ? ( + + ) : noBerths ? ( + + ) : ( + + ({ ...p, label: shortDate(p.date) }))} + margin={{ top: 8, right: 8, left: -16, bottom: 8 }} + > + + + + + + + + + `${v}%`} + /> + { + const p = item?.payload as { occupied?: number; total?: number } | undefined; + return [`${value}% (${p?.occupied ?? 0}/${p?.total ?? 0})`, 'Occupancy']; + }} + /> + + + + )} + + ); +} diff --git a/src/components/dashboard/pipeline-funnel-chart.tsx b/src/components/dashboard/pipeline-funnel-chart.tsx new file mode 100644 index 0000000..038ebcf --- /dev/null +++ b/src/components/dashboard/pipeline-funnel-chart.tsx @@ -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 = { + 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 ( + + {isLoading ? ( + + ) : allZero ? ( + + ) : ( + + + + + + { + const pct = (item?.payload as { conversionPct?: number } | undefined) + ?.conversionPct; + return [`${value} (${pct ?? 0}%)`, 'Count']; + }} + /> + + + + )} + + ); +} diff --git a/src/components/dashboard/revenue-breakdown-chart.tsx b/src/components/dashboard/revenue-breakdown-chart.tsx new file mode 100644 index 0000000..0d7759b --- /dev/null +++ b/src/components/dashboard/revenue-breakdown-chart.tsx @@ -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 = { + 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 ( + + {isLoading ? ( + + ) : !bars.length ? ( + + ) : ( + + + + + + { + const c = (item?.payload as { currency?: string } | undefined)?.currency ?? ''; + const num = typeof value === 'number' ? value : Number(value); + return [`${num.toLocaleString()} ${c}`, 'Amount']; + }} + /> + + + + )} + + ); +} diff --git a/src/components/dashboard/use-analytics.ts b/src/components/dashboard/use-analytics.ts new file mode 100644 index 0000000..9946694 --- /dev/null +++ b/src/components/dashboard/use-analytics.ts @@ -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 { + metric: MetricBase; + range: DateRange; + data: T; +} + +export function useAnalyticsMetric(metric: MetricBase, range: DateRange) { + return useQuery({ + queryKey: ['analytics', metric, range], + queryFn: async () => { + const res = await apiFetch>( + `/api/v1/analytics?metric=${metric}&range=${range}`, + ); + return res.data; + }, + staleTime: 60_000, + retry: 2, + }); +} + +export const useFunnel = (range: DateRange) => + useAnalyticsMetric('pipeline_funnel', range); +export const useOccupancy = (range: DateRange) => + useAnalyticsMetric('occupancy_timeline', range); +export const useRevenue = (range: DateRange) => + useAnalyticsMetric('revenue_breakdown', range); +export const useLeadSource = (range: DateRange) => + useAnalyticsMetric('lead_source_attribution', range); diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index 76e6f70..8d201c7 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -35,6 +35,7 @@ interface HubDoc { interface HubCounts { all: number; + eoi_queue: number; awaiting_them: number; awaiting_me: number; completed: number; @@ -43,6 +44,7 @@ interface HubCounts { const TAB_LABELS: Record = { all: 'All', + eoi_queue: 'EOI queue', awaiting_them: 'Awaiting them', awaiting_me: 'Awaiting me', completed: 'Completed', @@ -118,6 +120,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) { const counts: HubCounts = countsResp?.data ?? { all: 0, + eoi_queue: 0, awaiting_them: 0, awaiting_me: 0, completed: 0, diff --git a/src/components/expenses/expense-columns.tsx b/src/components/expenses/expense-columns.tsx index 37c1f09..d02d22e 100644 --- a/src/components/expenses/expense-columns.tsx +++ b/src/components/expenses/expense-columns.tsx @@ -29,6 +29,9 @@ export interface ExpenseRow { receiptFileIds: string[] | null; archivedAt: string | null; 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 = { @@ -94,7 +97,8 @@ export function getExpenseColumns({ cell: ({ row }) => row.original.amountUsd ? ( - ${Number(row.original.amountUsd).toLocaleString('en-US', { + $ + {Number(row.original.amountUsd).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} @@ -125,10 +129,7 @@ export function getExpenseColumns({ const status = (getValue() as string | null) ?? 'unpaid'; const colorClass = PAYMENT_STATUS_COLORS[status] ?? ''; return ( - + {status} ); @@ -162,10 +163,7 @@ export function getExpenseColumns({ Edit - onArchive(row.original)} - > + onArchive(row.original)}> Archive diff --git a/src/components/expenses/expense-detail.tsx b/src/components/expenses/expense-detail.tsx index a09954e..7e77485 100644 --- a/src/components/expenses/expense-detail.tsx +++ b/src/components/expenses/expense-detail.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { apiFetch } from '@/lib/api/client'; import type { ExpenseRow } from './expense-columns'; +import { ExpenseDuplicateBanner } from './expense-duplicate-banner'; const PAYMENT_STATUS_COLORS: Record = { 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) { return ( -
- Failed to load expense details. -
+
Failed to load expense details.
); } @@ -64,6 +63,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr return (
+

@@ -107,10 +107,12 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr

{expense.amountUsd && expense.currency !== 'USD' && (

- ≈ ${Number(expense.amountUsd).toLocaleString('en-US', { + ≈ $ + {Number(expense.amountUsd).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, - })} USD + })}{' '} + USD

)} @@ -121,10 +123,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr Payment Status - + {status} @@ -138,15 +137,11 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
Category -

- {expense.category?.replace(/_/g, ' ') ?? '—'} -

+

{expense.category?.replace(/_/g, ' ') ?? '—'}

Payment Method -

- {expense.paymentMethod?.replace(/_/g, ' ') ?? '—'} -

+

{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}

Payer diff --git a/src/components/expenses/expense-duplicate-banner.tsx b/src/components/expenses/expense-duplicate-banner.tsx new file mode 100644 index 0000000..f5e8b05 --- /dev/null +++ b/src/components/expenses/expense-duplicate-banner.tsx @@ -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 ( +
+
+ +
+

Looks like a duplicate

+

+ This expense matches{' '} + + {candidateLabel} + + + . Merge to consolidate, or mark as not a duplicate to keep them separate. +

+
+
+
+ + +
+
+ ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index ea849d9..a73a739 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -15,6 +15,8 @@ import { FolderOpen, Mail, Bell, + Camera, + ShieldAlert, Settings, Shield, Home, @@ -69,6 +71,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { marinaRequired: true, items: [ { href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard }, + { href: `${base}/alerts`, label: 'Alerts', icon: ShieldAlert }, { href: `${base}/clients`, label: 'Clients', icon: Users }, { href: `${base}/yachts`, label: 'Yachts', icon: Ship }, { href: `${base}/companies`, label: 'Companies', icon: Building2 }, @@ -105,6 +108,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { marinaRequired: true, items: [ { href: `${base}/expenses`, label: 'Expenses', icon: Receipt }, + { href: `${base}/scan`, label: 'Scan receipt', icon: Camera }, { href: `${base}/invoices`, label: 'Invoices', icon: FileText }, ], }, diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 8529547..51ee6f7 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -19,6 +19,7 @@ import { PortSwitcher } from '@/components/layout/port-switcher'; import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { CommandSearch } from '@/components/search/command-search'; import { NotificationBell } from '@/components/notifications/notification-bell'; +import { AlertBell } from '@/components/alerts/alert-bell'; import type { Port } from '@/lib/db/schema/ports'; interface TopbarProps { @@ -87,6 +88,9 @@ export function Topbar({ ports, user }: TopbarProps) { + {/* Phase B operational alerts — distinct from user notifications */} + + {/* Notification bell — real-time via socket */} diff --git a/src/components/scan/scan-shell.tsx b/src/components/scan/scan-shell.tsx new file mode 100644 index 0000000..5812b71 --- /dev/null +++ b/src/components/scan/scan-shell.tsx @@ -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('other'); + const [paymentMethod, setPaymentMethod] = useState('credit_card'); + const [description, setDescription] = useState(''); + + const lowConfidence = source === 'ai' && parsed.confidence < 0.6; + const noOcr = source === 'manual'; + + const banner = noOcr ? ( +
+ +
+ {reason === 'no-ocr-configured' ? ( + <> +

Manual entry mode

+

+ No AI provider is configured for this port. Fill in the details below to save the + expense with the photo attached. +

+ + ) : ( + <> +

We couldn't read the receipt automatically

+

+ {providerError ? `Reason: ${providerError}.` : ''} Fill in the details below to save + the expense with the photo attached. +

+ + )} +
+
+ ) : lowConfidence ? ( +
+ +
+

Low-confidence read — please double-check the fields

+

+ The AI returned a confidence of {Math.round(parsed.confidence * 100)}%. +

+
+
+ ) : ( +
+ +
+

Receipt parsed — confirm the fields and save

+

Confidence {Math.round(parsed.confidence * 100)}%.

+
+
+ ); + + return ( +
{ + e.preventDefault(); + onSubmit({ + establishmentName, + amount, + currency, + expenseDate, + category, + paymentMethod, + description, + file: imageFile, + }); + }} + > + {banner} + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Receipt preview +
+ +
+
+ + setEstablishment(e.target.value)} + placeholder="e.g. Marina Fuel Station" + /> +
+
+ + setAmount(e.target.value)} + required + /> +
+
+ + setCurrency(e.target.value.toUpperCase())} + maxLength={3} + required + /> +
+
+ + setExpenseDate(e.target.value)} + required + /> +
+
+ + +
+
+ + +
+
+ +