From 4dc0bdd8c4fa3d208d1b6f67ed7c1ad0c9585fa3 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 18 Jun 2026 21:42:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(crm):=20client-meeting=20batch=20=E2=80=94?= =?UTF-8?q?=20contact-pill=20cleanup,=20assignment=20toggle,=20receipt=20m?= =?UTF-8?q?anual=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest detail headers; relocate GDPR export into the client-header action cluster as a compact icon. Keeps the interest "Log contact" quick action. CM-5: gate the interest assignment feature behind a per-port `assignment_enabled` setting (default OFF for single-rep ports). Hides the AssignedToChip + residential assigned-to row and skips tier-2/3 auto-assign on create; the column + data are preserved and reversible. Tests cover the auto-assign guard. CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form). Threaded through ocr-config.service, the admin OCR form, the scan-receipt route, and the scanner shell (skips Tesseract + the server call). Tests cover the save/resolve round-trip. Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(scanner)/[portSlug]/scan/page.tsx | 12 +- src/app/api/v1/admin/ocr-settings/route.ts | 2 + src/app/api/v1/expenses/scan-receipt/route.ts | 8 + src/components/admin/ocr-settings-form.tsx | 24 ++- .../admin/settings/settings-manager.tsx | 8 + .../clients/client-detail-header.tsx | 75 ++------- src/components/clients/gdpr-export-button.tsx | 29 +++- .../interests/interest-detail-header.tsx | 146 +++++------------- .../residential/residential-interest-tabs.tsx | 24 +-- src/components/scan/scan-shell.tsx | 24 ++- src/lib/services/interests.service.ts | 7 +- src/lib/services/ocr-config.service.ts | 22 ++- .../interests-assignment-toggle.test.ts | 94 +++++++++++ tests/integration/ocr-config.test.ts | 54 +++++++ 14 files changed, 339 insertions(+), 190 deletions(-) create mode 100644 tests/integration/interests-assignment-toggle.test.ts diff --git a/src/app/(scanner)/[portSlug]/scan/page.tsx b/src/app/(scanner)/[portSlug]/scan/page.tsx index 46d09045..34d18e38 100644 --- a/src/app/(scanner)/[portSlug]/scan/page.tsx +++ b/src/app/(scanner)/[portSlug]/scan/page.tsx @@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell'; import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { getPortBrandingConfig } from '@/lib/services/port-config'; +import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service'; export const metadata: Metadata = { title: 'Scan receipt', @@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug: const { portSlug } = await params; const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) }); const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null; - return ; + // CM-6: manual-entry mode is resolved server-side so the client can skip + // on-device parsing entirely (no wasted Tesseract pass) and open an empty form. + const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null; + return ( + + ); } diff --git a/src/app/api/v1/admin/ocr-settings/route.ts b/src/app/api/v1/admin/ocr-settings/route.ts index df756662..a36529ff 100644 --- a/src/app/api/v1/admin/ocr-settings/route.ts +++ b/src/app/api/v1/admin/ocr-settings/route.ts @@ -15,6 +15,7 @@ const saveSchema = z.object({ clearApiKey: z.boolean().optional(), useGlobal: z.boolean().optional(), aiEnabled: z.boolean().optional(), + manualEntry: z.boolean().optional(), }); // Only role tiers that hold `admin.manage_settings` (director / super_admin) @@ -58,6 +59,7 @@ export const PUT = withAuth( clearApiKey: body.clearApiKey, useGlobal: body.useGlobal, aiEnabled: body.aiEnabled, + manualEntry: body.manualEntry, }, ctx.userId, ); diff --git a/src/app/api/v1/expenses/scan-receipt/route.ts b/src/app/api/v1/expenses/scan-receipt/route.ts index ab6d0ea6..63c8178d 100644 --- a/src/app/api/v1/expenses/scan-receipt/route.ts +++ b/src/app/api/v1/expenses/scan-receipt/route.ts @@ -48,6 +48,14 @@ export const POST = withAuth( } const config = await getResolvedOcrConfig(ctx.portId); + // CM-6: manual-entry mode short-circuits ALL parsing - the operator + // types the details by hand. The client should skip this route entirely + // in manual mode, but we guard server-side too. + if (config.manualEntry) { + return NextResponse.json({ + data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' }, + }); + } // Tesseract.js (in-browser) is the default. The server only invokes // an AI provider when (a) the port admin has flipped `aiEnabled` on // and (b) a key resolves. Otherwise the client falls back to its diff --git a/src/components/admin/ocr-settings-form.tsx b/src/components/admin/ocr-settings-form.tsx index 4d5bcff7..3f790c0f 100644 --- a/src/components/admin/ocr-settings-form.tsx +++ b/src/components/admin/ocr-settings-form.tsx @@ -30,6 +30,7 @@ interface ConfigResp { hasApiKey: boolean; useGlobal: boolean; aiEnabled: boolean; + manualEntry: boolean; }; models: Record; } @@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) { // Key the body on the loaded payload so useState initializers seed // from server values cleanly. const sig = data?.data - ? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}` + ? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}` : 'loading'; return ( ( null, ); @@ -105,6 +107,7 @@ function SettingsBlockBody({ clearApiKey: Boolean(clearApiKey), useGlobal: scope === 'global' ? false : useGlobal, aiEnabled: scope === 'global' ? false : aiEnabled, + manualEntry: scope === 'global' ? false : manualEntry, }, }), onSuccess: () => { @@ -190,6 +193,25 @@ function SettingsBlockBody({ ) : null} + {scope === 'port' ? ( +
+ setManualEntry(v === true)} + /> +
+ +

+ When on, staff just attach a receipt photo and type the details by hand - no + on-device or AI parsing runs. Takes precedence over AI parsing above. +

+
+
+ ) : null} +
diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index c91c592f..e6793eee 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{ type: 'boolean', defaultValue: true, }, + { + key: 'assignment_enabled', + label: 'Interest Assignment', + description: + 'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.', + type: 'boolean', + defaultValue: false, + }, { key: 'tenancies_module_enabled', label: 'Tenancies Module', diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx index b1e4d755..c160a8cf 100644 --- a/src/components/clients/client-detail-header.tsx +++ b/src/components/clients/client-detail-header.tsx @@ -3,11 +3,9 @@ import { useParams, useRouter } from 'next/navigation'; import type { Route } from 'next'; import { useState } from 'react'; -import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react'; -import { WhatsAppIcon } from '@/components/icons/whatsapp'; +import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react'; import { format } from 'date-fns'; -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { TagBadge } from '@/components/shared/tag-badge'; import { PermissionGate } from '@/components/shared/permission-gate'; @@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? client.contacts?.find((c) => c.channel === 'email')?.value; - const primaryPhoneContact = - client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ?? - client.contacts?.find((c) => c.channel === 'phone'); - const primaryPhone = primaryPhoneContact?.value; - // wa.me requires the E.164 number without the leading "+". Strip from the - // canonical E.164 form when available; otherwise strip non-digits from the - // display value as a best-effort fallback. - const whatsappNumber = primaryPhoneContact?.valueE164 - ? primaryPhoneContact.valueE164.replace(/^\+/, '') - : primaryPhoneContact?.value - ? primaryPhoneContact.value.replace(/[^\d]/g, '') - : null; const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null; const addedLabel = client.createdAt @@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {

) : null} -
- {primaryEmail ? ( - - ) : null} - {primaryPhone ? ( - - ) : null} - {whatsappNumber ? ( - - ) : null} - {!isArchived && client.clientPortalEnabled === true ? ( + {/* CM-4: Email/Call/WhatsApp deep-link pills removed at client + request. GDPR export moved to the top-right action cluster. + Portal-invite stays as the one primary CTA here. */} + {!isArchived && client.clientPortalEnabled === true ? ( +
- ) : null} -
-
-
+ ) : null} {client.tags && client.tags.length > 0 && (
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { right perm) permanently-delete. Destructive actions sit out of the primary action flow. */}
+ {/* CM-4: GDPR export relocated here as a compact icon trigger, + alongside reminder/archive/delete. Self-gates on permission. */} + {isArchived && ( + {variant === 'icon' ? ( + + ) : ( + + )} diff --git a/src/components/interests/interest-detail-header.tsx b/src/components/interests/interest-detail-header.tsx index 3044942e..577983ff 100644 --- a/src/components/interests/interest-detail-header.tsx +++ b/src/components/interests/interest-detail-header.tsx @@ -11,14 +11,11 @@ import { Trophy, XCircle, RefreshCcw, - Mail, MessageSquarePlus, - Phone, AlarmClock, User, } from 'lucide-react'; import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab'; -import { WhatsAppIcon } from '@/components/icons/whatsapp'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; @@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip'; import { MultiEoiChip } from '@/components/interests/multi-eoi-chip'; import { DealPulseChip } from '@/components/interests/deal-pulse-chip'; import { apiFetch } from '@/lib/api/client'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { formatOutcome } from '@/lib/constants'; import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label'; import { cn } from '@/lib/utils'; @@ -74,9 +72,9 @@ interface InterestDetailHeaderProps { id: string; clientId: string; clientName: string | null; - /** Primary contact channels resolved from the linked client. The header - * uses these to render Email / Call / WhatsApp buttons so the rep - * doesn't have to navigate to the client page just to reach out. */ + /** Primary contact channels resolved from the linked client. The + * Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload + * for downstream reuse (e.g. proxy comms routing, CM-9). */ clientPrimaryEmail?: string | null; clientPrimaryPhone?: string | null; clientPrimaryPhoneE164?: string | null; @@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade const [logContactOpen, setLogContactOpen] = useState(false); const [reminderOpen, setReminderOpen] = useState(false); // (Upload-paper-signed-EOI dialog moved to the EOI tab.) + // CM-5: assignment UI is hidden when the per-port toggle is off (default). + const assignmentEnabled = useFeatureFlag('assignment_enabled', false); const isArchived = !!interest.archivedAt; const outcomeBadge = resolveOutcomeBadge(interest.outcome); const isClosed = !!interest.outcome; - // Contact deep-links - resolved from the linked client's primary channels. - // wa.me requires the digits-only E.164 number (no leading "+"); fall back to - // stripping non-digits from the display value when the canonical form is - // missing. - const whatsappNumber = interest.clientPrimaryPhoneE164 - ? interest.clientPrimaryPhoneE164.replace(/^\+/, '') - : interest.clientPrimaryPhone - ? interest.clientPrimaryPhone.replace(/[^\d]/g, '') - : null; - const reopenMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }), @@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade {interest.activeReminderCount} ) : null} - - - + {assignmentEnabled ? ( + + + + ) : null} )} - {/* Contact deep-links - let the rep email / call / WhatsApp the - client without leaving the interest workspace. Resolved from - the linked client's primary contact channels (server-side - fetch in getInterestById). */} - {interest.clientPrimaryEmail || - interest.clientPrimaryPhone || - whatsappNumber || - interest.clientId ? ( -
- {interest.clientId ? ( - - ) : null} - {interest.clientPrimaryEmail ? ( - - ) : null} - {interest.clientPrimaryPhone ? ( - - ) : null} - {whatsappNumber ? ( - - ) : null} + {/* CM-4: Email/Call/WhatsApp deep-links removed at client request. + Client-page link + Log-contact action stay - the rep can still + jump to the client and record outreach without leaving here. */} +
+ {interest.clientId ? ( -
- ) : null} + ) : null} + +
{/* Top-right actions. Won/Lost are sales-critical and read as text diff --git a/src/components/residential/residential-interest-tabs.tsx b/src/components/residential/residential-interest-tabs.tsx index 33926e7b..846fb306 100644 --- a/src/components/residential/residential-interest-tabs.tsx +++ b/src/components/residential/residential-interest-tabs.tsx @@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { NotesList } from '@/components/shared/notes-list'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { apiFetch } from '@/lib/api/client'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { SOURCES } from '@/lib/constants'; interface ResidentialInterest { @@ -95,6 +96,8 @@ function OverviewTab({ stageOptions: Array<{ value: string; label: string }>; }) { const update = useInterestPatch(interestId); + // CM-5: residential assignment row hidden when the per-port toggle is off. + const assignmentEnabled = useFeatureFlag('assignment_enabled', false); const save = (field: string) => async (next: string | null) => { await update.mutateAsync({ [field]: next }); }; @@ -105,6 +108,7 @@ function OverviewTab({ }>({ queryKey: ['residential-assignable-users'], queryFn: () => apiFetch('/api/v1/residential/assignable-users'), + enabled: assignmentEnabled, }); const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({ value: u.id, @@ -132,15 +136,17 @@ function OverviewTab({ onSave={save('source')} /> - - - + {assignmentEnabled ? ( + + + + ) : null}
diff --git a/src/components/scan/scan-shell.tsx b/src/components/scan/scan-shell.tsx index a73e81ca..46a459c8 100644 --- a/src/components/scan/scan-shell.tsx +++ b/src/components/scan/scan-shell.tsx @@ -320,9 +320,11 @@ interface ScanShellProps { * imagery. */ logoUrl?: string | null; portName?: string | null; + /** CM-6: when true, skip ALL parsing - open an empty form for manual entry. */ + manualEntry?: boolean; } -export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) { +export function ScanShell({ logoUrl, portName, manualEntry = false }: ScanShellProps = {}) { const router = useRouter(); const portSlug = useUIStore((s) => s.currentPortSlug); const fileRef = useRef(null); @@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) { if (imagePreview) URL.revokeObjectURL(imagePreview); setImagePreview(URL.createObjectURL(file)); setCurrentFile(file); + + // CM-6: manual-entry mode - the port admin disabled scanning. Skip + // Tesseract AND the server call entirely; go straight to an empty form. + if (manualEntry) { + setState({ + kind: 'verify', + parsed: { + establishment: null, + date: null, + amount: null, + currency: null, + lineItems: [], + confidence: 0, + }, + source: 'manual', + reason: 'manual-mode', + }); + return; + } + setState({ kind: 'processing', engine: 'tesseract' }); // Always run Tesseract first - it's free, on-device, and gives us a diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index ca1aa06c..06cc0a9c 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -833,7 +833,12 @@ export async function createInterest(portId: string, data: CreateInterestInput, // every new lead. Falls back to null (Unassigned) when none of // the above resolve. let resolvedAssignedTo = interestData.assignedTo ?? null; - if (resolvedAssignedTo === null && !('assignedTo' in interestData)) { + // CM-5: tiers 2 & 3 (port default-owner + auto-assign-to-creator) only run + // when the per-port assignment feature is enabled. Tier 1 (an explicit + // assignedTo from the caller) is always honored. Default is OFF. + const assignmentSetting = await getSetting('assignment_enabled', portId); + const assignmentEnabled = assignmentSetting?.value === true; + if (assignmentEnabled && resolvedAssignedTo === null && !('assignedTo' in interestData)) { const defaultOwner = await getSetting('default_new_interest_owner', portId); const v = defaultOwner?.value as { userId?: string } | null | undefined; if (v?.userId) { diff --git a/src/lib/services/ocr-config.service.ts b/src/lib/services/ocr-config.service.ts index 6f2374b5..c7b7b5e6 100644 --- a/src/lib/services/ocr-config.service.ts +++ b/src/lib/services/ocr-config.service.ts @@ -37,6 +37,12 @@ export interface OcrConfigPublic { * provider is never called even if a key is configured. */ aiEnabled: boolean; + /** + * CM-6: manual-entry mode. When true the scanner skips ALL parsing + * (Tesseract + AI) and presents an empty form for the operator to fill in + * by hand. Per-port; takes precedence over `aiEnabled`. Default false. + */ + manualEntry: boolean; } /** Internal shape including the decrypted key - server-side only. */ @@ -52,6 +58,7 @@ interface StoredOcrConfig { apiKeyEncrypted: string | null; useGlobal: boolean; aiEnabled?: boolean; + manualEntry?: boolean; } const KEY = 'ocr.config'; @@ -106,12 +113,14 @@ export async function getResolvedOcrConfig(portId: string): Promise { + let createInterest: typeof import('@/lib/services/interests.service').createInterest; + let makePort: typeof import('../helpers/factories').makePort; + let makeClient: typeof import('../helpers/factories').makeClient; + let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta; + + beforeAll(async () => { + const svc = await import('@/lib/services/interests.service'); + createInterest = svc.createInterest; + const factories = await import('../helpers/factories'); + makePort = factories.makePort; + makeClient = factories.makeClient; + makeAuditMeta = factories.makeAuditMeta; + // Idempotent owner profile - left in place (created interests reference it, + // so we never delete it in teardown). + await db + .insert(userProfiles) + .values({ userId: OWNER, displayName: 'CM5 Default Owner' }) + .onConflictDoNothing(); + }); + + afterEach(async () => { + await db.delete(systemSettings).where(eq(systemSettings.key, 'assignment_enabled')); + await db.delete(systemSettings).where(eq(systemSettings.key, 'default_new_interest_owner')); + }); + + async function setSetting(portId: string, key: string, value: unknown) { + await db.insert(systemSettings).values({ key, portId, value: value as never }); + } + + it('does NOT auto-assign the port default owner when assignment is disabled (default)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + // A default owner IS configured, but the feature is OFF - the guard must + // skip tier-2 entirely and leave the interest unassigned. + await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER }); + + const interest = await createInterest( + port.id, + { clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false }, + makeAuditMeta({ portId: port.id }), + ); + expect(interest.assignedTo).toBeNull(); + }); + + it('auto-assigns the port default owner when assignment is enabled', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await setSetting(port.id, 'assignment_enabled', true); + await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER }); + + const interest = await createInterest( + port.id, + { clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false }, + makeAuditMeta({ portId: port.id }), + ); + expect(interest.assignedTo).toBe(OWNER); + }); + + it('always honors an explicit assignedTo regardless of the toggle', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + // Feature off, but the caller explicitly picked an owner - tier 1 wins. + const interest = await createInterest( + port.id, + { + clientId: client.id, + assignedTo: OWNER, + pipelineStage: 'enquiry', + tagIds: [], + reminderEnabled: false, + }, + makeAuditMeta({ portId: port.id }), + ); + expect(interest.assignedTo).toBe(OWNER); + }); +}); diff --git a/tests/integration/ocr-config.test.ts b/tests/integration/ocr-config.test.ts index 40c6d3d1..22ec00f0 100644 --- a/tests/integration/ocr-config.test.ts +++ b/tests/integration/ocr-config.test.ts @@ -147,6 +147,60 @@ describe('OCR config', () => { expect(resolved.aiEnabled).toBe(false); }); + // CM-6: manual-entry mode (skip all parsing) - mirrors the aiEnabled contract. + it('manualEntry defaults to false and round-trips when toggled', async () => { + const port = await makePort(); + await saveOcrConfig( + port.id, + { provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-y' }, + 'user-1', + ); + let resolved = await getResolvedOcrConfig(port.id); + expect(resolved.manualEntry).toBe(false); + + await saveOcrConfig( + port.id, + { provider: 'openai', model: 'gpt-4o-mini', manualEntry: true }, + 'user-1', + ); + resolved = await getResolvedOcrConfig(port.id); + expect(resolved.manualEntry).toBe(true); + expect(resolved.apiKey).toBe('sk-y'); // toggling the mode never wipes the key + }); + + it('manualEntry is preserved when other fields change', async () => { + const port = await makePort(); + await saveOcrConfig( + port.id, + { provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-z', manualEntry: true }, + 'user-1', + ); + // Update the model only - manualEntry must survive (mirrors aiEnabled). + await saveOcrConfig(port.id, { provider: 'openai', model: 'gpt-4o' }, 'user-1'); + const resolved = await getResolvedOcrConfig(port.id); + expect(resolved.manualEntry).toBe(true); + expect(resolved.model).toBe('gpt-4o'); + }); + + it('manualEntry shows on the public view and is forced false at global scope', async () => { + await saveOcrConfig( + null, + { provider: 'openai', model: 'gpt-4o-mini', apiKey: 'g', manualEntry: true }, + 'user-1', + ); + const port = await makePort(); + const resolved = await getResolvedOcrConfig(port.id); + expect(resolved.manualEntry).toBe(false); // per-port, never inherited from global + + await saveOcrConfig( + port.id, + { provider: 'openai', model: 'gpt-4o-mini', manualEntry: true }, + 'user-1', + ); + const pub = await getPublicOcrConfig(port.id); + expect(pub.manualEntry).toBe(true); + }); + it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => { await saveOcrConfig( null,