feat(crm): client-meeting batch — contact-pill cleanup, assignment toggle, receipt manual mode
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
|
||||
// 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 (
|
||||
<ScanShell
|
||||
logoUrl={branding?.logoUrl ?? null}
|
||||
portName={port?.name ?? null}
|
||||
manualEntry={ocr?.manualEntry ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user