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:
@@ -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) {
|
||||
|
||||
@@ -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<OcrConfigRes
|
||||
hasApiKey: false,
|
||||
useGlobal: portRow?.useGlobal === true,
|
||||
aiEnabled: false,
|
||||
manualEntry: portRow?.manualEntry === true,
|
||||
source: 'none',
|
||||
};
|
||||
}
|
||||
// The aiEnabled flag is per-port: even if the port falls back to a global
|
||||
// key, the port admin still has to flip the switch on this port.
|
||||
// The aiEnabled / manualEntry flags are per-port: even if the port falls back
|
||||
// to a global key, the port admin still has to flip these on this port.
|
||||
const aiEnabled = portRow?.aiEnabled === true;
|
||||
const manualEntry = portRow?.manualEntry === true;
|
||||
return {
|
||||
provider: sourceRow.provider,
|
||||
model: sourceRow.model,
|
||||
@@ -119,6 +128,7 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
|
||||
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
|
||||
useGlobal: portRow?.useGlobal === true,
|
||||
aiEnabled,
|
||||
manualEntry,
|
||||
source: useGlobal ? 'global' : 'port',
|
||||
};
|
||||
}
|
||||
@@ -133,6 +143,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
|
||||
hasApiKey: false,
|
||||
useGlobal: false,
|
||||
aiEnabled: false,
|
||||
manualEntry: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -141,6 +152,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
|
||||
hasApiKey: Boolean(row.apiKeyEncrypted),
|
||||
useGlobal: row.useGlobal,
|
||||
aiEnabled: row.aiEnabled === true,
|
||||
manualEntry: row.manualEntry === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +166,8 @@ export interface SaveOcrConfigInput {
|
||||
useGlobal?: boolean;
|
||||
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */
|
||||
aiEnabled?: boolean;
|
||||
/** Per-port toggle: manual entry (skip all parsing). Defaults to false. */
|
||||
manualEntry?: boolean;
|
||||
}
|
||||
|
||||
export async function saveOcrConfig(
|
||||
@@ -171,6 +185,9 @@ export async function saveOcrConfig(
|
||||
// AI is meaningful only at the port scope. Preserve the existing flag if the
|
||||
// caller didn't pass one (so toggling provider/model doesn't re-disable AI).
|
||||
const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false);
|
||||
// Manual entry is also port-only; preserve when the caller omits it.
|
||||
const manualEntry =
|
||||
portId === null ? false : (input.manualEntry ?? existing?.manualEntry ?? false);
|
||||
await writeRow(
|
||||
portId,
|
||||
{
|
||||
@@ -179,6 +196,7 @@ export async function saveOcrConfig(
|
||||
apiKeyEncrypted,
|
||||
useGlobal: portId === null ? false : Boolean(input.useGlobal),
|
||||
aiEnabled,
|
||||
manualEntry,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user