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}
-
@@ -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 && (
{/* 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,