diff --git a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx index 33fbf67b..402e76ef 100644 --- a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { Bot, Receipt, FileText, Brain, ExternalLink } from 'lucide-react'; +import { Bot, FileText, Brain, ExternalLink } from 'lucide-react'; import { SettingsFormCard, @@ -7,6 +7,7 @@ import { } from '@/components/admin/shared/settings-form-card'; import { PageHeader } from '@/components/shared/page-header'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { OcrSettingsForm } from '@/components/admin/ocr-settings-form'; const MASTER_FIELDS: SettingFieldDef[] = [ { @@ -59,13 +60,6 @@ interface FeatureLink { } const FEATURE_LINKS: FeatureLink[] = [ - { - href: '../ocr', - icon: Receipt, - title: 'Receipt OCR settings', - description: - 'Provider, model, and confidence thresholds for the receipt scanner. AI fallback only runs when the on-device parser is uncertain.', - }, { href: '../berth-pdf-parser', icon: FileText, @@ -103,6 +97,21 @@ export default function AiAdminPage() { fields={PROVIDER_FIELDS} /> + + + + Receipt OCR + + + Provider, model, and confidence thresholds for the receipt scanner. AI fallback only + runs when the on-device parser is uncertain. + + + + + + + diff --git a/src/app/(dashboard)/[portSlug]/admin/qualification-criteria/page.tsx b/src/app/(dashboard)/[portSlug]/admin/qualification-criteria/page.tsx new file mode 100644 index 00000000..ebb42832 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/qualification-criteria/page.tsx @@ -0,0 +1,15 @@ +import { QualificationCriteriaAdmin } from '@/components/admin/qualification-criteria-admin'; +import { PageHeader } from '@/components/shared/page-header'; + +export default function QualificationCriteriaPage() { + return ( +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/expenses/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/page.tsx index 203dc19d..8a48b7cd 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useParams } from 'next/navigation'; import { Plus, Download, FileText, FileSpreadsheet } from 'lucide-react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { @@ -21,7 +21,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog import { PermissionGate } from '@/components/shared/permission-gate'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; import { ExpenseCard } from '@/components/expenses/expense-card'; -import { expenseFilterDefinitions } from '@/components/expenses/expense-filters'; +import { buildExpenseFilterDefinitions } from '@/components/expenses/expense-filters'; import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns'; import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; @@ -33,6 +33,18 @@ export default function ExpensesPage() { const portSlug = params?.portSlug ?? ''; const queryClient = useQueryClient(); + // Per-port category override. Falls back to shipped defaults until the + // vocab call resolves, so the filter bar always renders something. + const { data: vocab } = useQuery<{ data: Record }>({ + queryKey: ['vocabularies'], + queryFn: () => apiFetch('/api/v1/vocabularies'), + staleTime: 5 * 60_000, + }); + const filterDefs = useMemo( + () => buildExpenseFilterDefinitions(vocab?.data?.expense_categories), + [vocab], + ); + const [createOpen, setCreateOpen] = useState(false); useCreateFromUrl(() => setCreateOpen(true)); const [editExpense, setEditExpense] = useState(null); @@ -53,7 +65,7 @@ export default function ExpensesPage() { } = usePaginatedQuery({ queryKey: ['expenses'], endpoint: '/api/v1/expenses', - filterDefinitions: expenseFilterDefinitions, + filterDefinitions: filterDefs, }); useRealtimeInvalidation({ @@ -132,7 +144,7 @@ export default function ExpensesPage() { /> = { - open: 'secondary', - details_sent: 'secondary', - in_communication: 'default', - eoi_sent: 'default', - eoi_signed: 'default', - deposit_10pct: 'default', - contract_sent: 'default', - contract_signed: 'default', - completed: 'outline', + enquiry: 'secondary', + qualified: 'secondary', + nurturing: 'secondary', + eoi: 'default', + reservation: 'default', + deposit_paid: 'default', + contract: 'outline', }; export default async function PortalInterestsPage() { diff --git a/src/app/api/v1/admin/qualification-criteria/[id]/route.ts b/src/app/api/v1/admin/qualification-criteria/[id]/route.ts new file mode 100644 index 00000000..00c10817 --- /dev/null +++ b/src/app/api/v1/admin/qualification-criteria/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { updateQualificationCriterionSchema } from '@/lib/validators/qualification'; +import { deleteCriterion, updateCriterion } from '@/lib/services/qualification.service'; + +export const PATCH = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx, params) => { + try { + const body = await parseBody(req, updateQualificationCriterionSchema); + const row = await updateCriterion(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: row }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx, params) => { + try { + await deleteCriterion(params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/qualification-criteria/route.ts b/src/app/api/v1/admin/qualification-criteria/route.ts new file mode 100644 index 00000000..3aeb471d --- /dev/null +++ b/src/app/api/v1/admin/qualification-criteria/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createQualificationCriterionSchema } from '@/lib/validators/qualification'; +import { createCriterion, listCriteriaForPort } from '@/lib/services/qualification.service'; + +export const GET = withAuth(async (_req, ctx) => { + try { + const data = await listCriteriaForPort(ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } +}); + +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const body = await parseBody(req, createQualificationCriterionSchema); + const row = await createCriterion(ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: row }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/contact-log/[id]/route.ts b/src/app/api/v1/contact-log/[id]/route.ts index 37345b58..ff2b4458 100644 --- a/src/app/api/v1/contact-log/[id]/route.ts +++ b/src/app/api/v1/contact-log/[id]/route.ts @@ -15,6 +15,8 @@ export const PATCH = withAuth( channel: body.channel, direction: body.direction, summary: body.summary, + voiceTranscript: body.voiceTranscript, + templateUsed: body.templateUsed, followUpAt: body.followUpAt, }); return NextResponse.json({ data: entry }); diff --git a/src/app/api/v1/interests/[id]/contact-log/route.ts b/src/app/api/v1/interests/[id]/contact-log/route.ts index d95ab102..4cbddba2 100644 --- a/src/app/api/v1/interests/[id]/contact-log/route.ts +++ b/src/app/api/v1/interests/[id]/contact-log/route.ts @@ -27,6 +27,8 @@ export const POST = withAuth( channel: body.channel, direction: body.direction, summary: body.summary, + voiceTranscript: body.voiceTranscript ?? null, + templateUsed: body.templateUsed ?? null, followUpAt: body.followUpAt ?? null, }); return NextResponse.json({ data: entry }, { status: 201 }); diff --git a/src/app/api/v1/interests/[id]/payments/route.ts b/src/app/api/v1/interests/[id]/payments/route.ts new file mode 100644 index 00000000..c1a00d09 --- /dev/null +++ b/src/app/api/v1/interests/[id]/payments/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createPaymentSchema } from '@/lib/validators/payments'; +import { + createPayment, + getDepositTotalForInterest, + listPaymentsForInterest, +} from '@/lib/services/payments.service'; + +export const GET = withAuth( + withPermission('interests', 'view', async (_req, ctx, params) => { + try { + const interestId = params.id!; + const [payments, depositTotal] = await Promise.all([ + listPaymentsForInterest(interestId, ctx.portId), + getDepositTotalForInterest(interestId, ctx.portId), + ]); + return NextResponse.json({ data: { payments, depositTotal } }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('invoices', 'record_payment', async (req, ctx, params) => { + try { + // Body's interestId must match the URL param — defense-in-depth against + // a client that sends one ID in the URL but another in the body. + const body = await parseBody(req, createPaymentSchema); + if (body.interestId !== params.id) { + return NextResponse.json( + { error: 'interestId in body must match URL parameter' }, + { status: 400 }, + ); + } + const payment = await createPayment(ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: payment }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/interests/[id]/qualifications/route.ts b/src/app/api/v1/interests/[id]/qualifications/route.ts new file mode 100644 index 00000000..465e4945 --- /dev/null +++ b/src/app/api/v1/interests/[id]/qualifications/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { setInterestQualificationSchema } from '@/lib/validators/qualification'; +import { + isInterestFullyQualified, + listInterestQualifications, + setInterestQualification, +} from '@/lib/services/qualification.service'; + +export const GET = withAuth( + withPermission('interests', 'view', async (_req, ctx, params) => { + try { + const interestId = params.id!; + const [criteria, fullyQualified] = await Promise.all([ + listInterestQualifications(interestId, ctx.portId), + isInterestFullyQualified(interestId, ctx.portId), + ]); + return NextResponse.json({ data: { criteria, fullyQualified } }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const PUT = withAuth( + withPermission('interests', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, setInterestQualificationSchema); + const criteria = await setInterestQualification(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + const fullyQualified = criteria.every((c) => c.confirmed); + return NextResponse.json({ data: { criteria, fullyQualified } }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/payments/[id]/route.ts b/src/app/api/v1/payments/[id]/route.ts new file mode 100644 index 00000000..1da09aa8 --- /dev/null +++ b/src/app/api/v1/payments/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { updatePaymentSchema } from '@/lib/validators/payments'; +import { deletePayment, updatePayment } from '@/lib/services/payments.service'; + +export const PATCH = withAuth( + withPermission('invoices', 'record_payment', async (req, ctx, params) => { + try { + const body = await parseBody(req, updatePaymentSchema); + const payment = await updatePayment(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: payment }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('invoices', 'record_payment', async (_req, ctx, params) => { + try { + await deletePayment(params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx index 2f6001f5..7656edcb 100644 --- a/src/components/admin/admin-sections-browser.tsx +++ b/src/components/admin/admin-sections-browser.tsx @@ -3,27 +3,34 @@ import { useMemo, useState } from 'react'; import Link from 'next/link'; import { - Bell, + Activity, + BarChart3, + BellRing, BookOpen, - Briefcase, - Database, + ClipboardList, + CopyCheck, + DatabaseBackup, + FilePen, + FileSignature, FileText, - Globe, - HardDrive, + FileUp, Inbox, - Key, - LayoutDashboard, + ListChecks, Mail, - Palette, + MailPlus, + Paintbrush, ScrollText, Search, + Send, + Server, Settings, Shield, - Sliders, + Ship, + SlidersHorizontal, + Sparkles, Tag, - Upload, + TrendingUp, Users, - UsersRound, Webhook, X, } from 'lucide-react'; @@ -83,7 +90,7 @@ const GROUPS: AdminGroup[] = [ href: 'invitations', label: 'Invitations', description: 'Send invitations, track pending invites, and resend or revoke them.', - icon: Mail, + icon: MailPlus, }, { href: 'roles', @@ -108,19 +115,19 @@ const GROUPS: AdminGroup[] = [ label: 'EOI signing service', description: 'API credentials, EOI template, and default in-app vs external signing pathway.', - icon: FileText, + icon: FileSignature, }, { href: 'reminders', label: 'Reminders', description: 'Default reminder behaviour and the daily-digest delivery window.', - icon: Bell, + icon: BellRing, }, { href: 'branding', label: 'Branding', description: 'App name, logo, primary color, and email header/footer HTML.', - icon: Palette, + icon: Paintbrush, }, { href: 'settings', @@ -183,7 +190,7 @@ const GROUPS: AdminGroup[] = [ href: 'forms', label: 'Forms', description: 'Form templates used by client-facing inquiry and intake flows.', - icon: Sliders, + icon: ClipboardList, }, { href: 'templates', @@ -195,7 +202,7 @@ const GROUPS: AdminGroup[] = [ href: 'email-templates', label: 'Email Templates', description: 'Customize subject lines for transactional emails (portal, inquiry, invite).', - icon: Mail, + icon: FilePen, }, { href: 'tags', @@ -214,7 +221,7 @@ const GROUPS: AdminGroup[] = [ href: 'custom-fields', label: 'Custom Fields', description: 'Tenant-defined fields for clients, yachts, and reservations.', - icon: Key, + icon: SlidersHorizontal, }, ], }, @@ -233,19 +240,19 @@ const GROUPS: AdminGroup[] = [ href: 'sends', label: 'Send Log', description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.', - icon: Mail, + icon: Send, }, { href: 'duplicates', label: 'Duplicates', description: 'Review queue of suspected duplicate clients flagged by the dedup engine.', - icon: UsersRound, + icon: CopyCheck, }, { href: 'import', label: 'Bulk Import', description: 'CSV-driven imports for clients, yachts, and reservations.', - icon: Upload, + icon: FileUp, }, { href: 'audit', @@ -263,26 +270,26 @@ const GROUPS: AdminGroup[] = [ href: 'reports', label: 'Reports', description: 'Saved analytics views and ad-hoc query results.', - icon: LayoutDashboard, + icon: BarChart3, }, { href: 'monitoring', label: 'Queue Monitoring', description: 'BullMQ queue health, throughput, and retry diagnostics.', - icon: Database, + icon: Activity, }, { href: 'backup', label: 'Backup & Restore', description: 'Backup posture + retention policy (read-only).', - icon: HardDrive, + icon: DatabaseBackup, }, { href: 'storage', label: 'Storage Backend', description: 'Choose between S3-compatible object store or local filesystem; migrate between them.', - icon: HardDrive, + icon: Server, }, ], }, @@ -294,14 +301,14 @@ const GROUPS: AdminGroup[] = [ href: 'ports', label: 'Ports', description: 'Manage the marinas/ports this installation serves.', - icon: Briefcase, + icon: Ship, }, { href: 'onboarding', label: 'Onboarding checklist', description: 'Step-by-step setup checklist for fresh ports — auto-detects what you’ve configured and lets you mark manual steps complete.', - icon: LayoutDashboard, + icon: ListChecks, }, ], }, @@ -313,22 +320,28 @@ const GROUPS: AdminGroup[] = [ href: 'ai', label: 'AI configuration', description: - 'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).', - icon: ScrollText, - keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'], - }, - { - href: 'ocr', - label: 'Receipt OCR (per-feature)', - description: 'Provider, model, and confidence thresholds for the receipt scanner.', - icon: ScrollText, - keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'], + 'Master switch, provider credentials, and the Receipt OCR settings in one place. Per-feature pages (berth-PDF parser, recommender) link out from here.', + icon: Sparkles, + keywords: [ + 'openai', + 'anthropic', + 'gpt', + 'claude', + 'llm', + 'api key', + 'embeddings', + 'receipt', + 'scan', + 'tesseract', + 'ocr', + 'expense scanner', + ], }, { href: 'website-analytics', label: 'Website analytics (Umami)', description: 'Per-port Umami URL, API token, and Website ID.', - icon: Globe, + icon: TrendingUp, keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'], }, { @@ -336,9 +349,17 @@ const GROUPS: AdminGroup[] = [ label: 'Residential pipeline stages', description: 'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.', - icon: ScrollText, + icon: ListChecks, keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'], }, + { + href: 'qualification-criteria', + label: 'Qualification criteria', + description: + 'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the soft "ready to qualify" hint on the interest detail.', + icon: ListChecks, + keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'], + }, ], }, ]; diff --git a/src/components/admin/ocr-settings-form.tsx b/src/components/admin/ocr-settings-form.tsx index 53c6acae..4d5bcff7 100644 --- a/src/components/admin/ocr-settings-form.tsx +++ b/src/components/admin/ocr-settings-form.tsx @@ -314,17 +314,19 @@ function SettingsBlockBody({ ); } -export function OcrSettingsForm() { +export function OcrSettingsForm({ embedded = false }: { embedded?: boolean } = {}) { const { isSuperAdmin } = usePermissions(); return (
- + {embedded ? null : ( + + )} ({ + queryKey: ['qualification-criteria'], + queryFn: () => apiFetch('/api/v1/admin/qualification-criteria'), + }); + const criteria = data?.data ?? []; + + const toggleEnabled = useMutation({ + mutationFn: async (vars: { id: string; enabled: boolean }) => + apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, { + method: 'PATCH', + body: { enabled: vars.enabled }, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), + onError: (err) => toastError(err), + }); + + const reorder = useMutation({ + mutationFn: async (vars: { id: string; displayOrder: number }) => + apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, { + method: 'PATCH', + body: { displayOrder: vars.displayOrder }, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), + onError: (err) => toastError(err), + }); + + const deleteCriterion = useMutation({ + mutationFn: async (id: string) => + apiFetch(`/api/v1/admin/qualification-criteria/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), + onError: (err) => toastError(err), + }); + + if (isLoading) { + return
Loading criteria…
; + } + + return ( +
+
+

+ {criteria.length} criteria configured · {criteria.filter((c) => c.enabled).length} enabled +

+ +
+ + {criteria.length === 0 ? ( +
+

No criteria configured yet.

+

+ Add the first criterion the rep needs to confirm before a deal can be qualified. +

+
+ ) : ( +
    + {criteria.map((c, idx) => { + const isFirst = idx === 0; + const isLast = idx === criteria.length - 1; + return ( +
  • +
    + + +
    + toggleEnabled.mutate({ id: c.id, enabled })} + /> + +
  • + ); + })} +
+ )} + + +
+ ); +} + +function CriterionEditableRow({ + criterion, + onToggleEnabled, +}: { + criterion: CriterionRow; + onToggleEnabled: (enabled: boolean) => void; +}) { + const queryClient = useQueryClient(); + const [label, setLabel] = useState(criterion.label); + const [description, setDescription] = useState(criterion.description ?? ''); + const isDirty = + label.trim() !== criterion.label || (description.trim() || null) !== criterion.description; + + const save = useMutation({ + mutationFn: async () => + apiFetch(`/api/v1/admin/qualification-criteria/${criterion.id}`, { + method: 'PATCH', + body: { label: label.trim(), description: description.trim() || null }, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }), + onError: (err) => toastError(err), + }); + + return ( +
+
+ setLabel(e.target.value)} + className="h-7 max-w-md text-sm font-medium" + /> + + {criterion.key} + +
+ {isDirty ? ( + + ) : null} + +
+
+