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 { db } from '@/lib/db';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||||
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Scan receipt',
|
title: 'Scan receipt',
|
||||||
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
|
|||||||
const { portSlug } = await params;
|
const { portSlug } = await params;
|
||||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||||
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
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(),
|
clearApiKey: z.boolean().optional(),
|
||||||
useGlobal: z.boolean().optional(),
|
useGlobal: z.boolean().optional(),
|
||||||
aiEnabled: z.boolean().optional(),
|
aiEnabled: z.boolean().optional(),
|
||||||
|
manualEntry: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
||||||
@@ -58,6 +59,7 @@ export const PUT = withAuth(
|
|||||||
clearApiKey: body.clearApiKey,
|
clearApiKey: body.clearApiKey,
|
||||||
useGlobal: body.useGlobal,
|
useGlobal: body.useGlobal,
|
||||||
aiEnabled: body.aiEnabled,
|
aiEnabled: body.aiEnabled,
|
||||||
|
manualEntry: body.manualEntry,
|
||||||
},
|
},
|
||||||
ctx.userId,
|
ctx.userId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export const POST = withAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await getResolvedOcrConfig(ctx.portId);
|
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
|
// Tesseract.js (in-browser) is the default. The server only invokes
|
||||||
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
||||||
// and (b) a key resolves. Otherwise the client falls back to its
|
// and (b) a key resolves. Otherwise the client falls back to its
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface ConfigResp {
|
|||||||
hasApiKey: boolean;
|
hasApiKey: boolean;
|
||||||
useGlobal: boolean;
|
useGlobal: boolean;
|
||||||
aiEnabled: boolean;
|
aiEnabled: boolean;
|
||||||
|
manualEntry: boolean;
|
||||||
};
|
};
|
||||||
models: Record<Provider, string[]>;
|
models: Record<Provider, string[]>;
|
||||||
}
|
}
|
||||||
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
|
|||||||
// Key the body on the loaded payload so useState initializers seed
|
// Key the body on the loaded payload so useState initializers seed
|
||||||
// from server values cleanly.
|
// from server values cleanly.
|
||||||
const sig = data?.data
|
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';
|
: 'loading';
|
||||||
return (
|
return (
|
||||||
<SettingsBlockBody
|
<SettingsBlockBody
|
||||||
@@ -89,6 +90,7 @@ function SettingsBlockBody({
|
|||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
|
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
|
||||||
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
|
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
|
||||||
|
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
|
||||||
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
|
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -105,6 +107,7 @@ function SettingsBlockBody({
|
|||||||
clearApiKey: Boolean(clearApiKey),
|
clearApiKey: Boolean(clearApiKey),
|
||||||
useGlobal: scope === 'global' ? false : useGlobal,
|
useGlobal: scope === 'global' ? false : useGlobal,
|
||||||
aiEnabled: scope === 'global' ? false : aiEnabled,
|
aiEnabled: scope === 'global' ? false : aiEnabled,
|
||||||
|
manualEntry: scope === 'global' ? false : manualEntry,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -190,6 +193,25 @@ function SettingsBlockBody({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{scope === 'port' ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`manualEntry-${scope}`}
|
||||||
|
checked={manualEntry}
|
||||||
|
onCheckedChange={(v) => setManualEntry(v === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
|
||||||
|
Manual entry only (skip receipt scanning)
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: true,
|
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',
|
key: 'tenancies_module_enabled',
|
||||||
label: 'Tenancies Module',
|
label: 'Tenancies Module',
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
|
import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
const primaryEmail =
|
const primaryEmail =
|
||||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
||||||
client.contacts?.find((c) => c.channel === 'email')?.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 country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||||
const addedLabel = client.createdAt
|
const addedLabel = client.createdAt
|
||||||
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
{/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
|
||||||
{primaryEmail ? (
|
request. GDPR export moved to the top-right action cluster.
|
||||||
<Button
|
Portal-invite stays as the one primary CTA here. */}
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
|
||||||
<Mail />
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{primaryPhone ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
|
||||||
<Phone />
|
|
||||||
Call
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{whatsappNumber ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`https://wa.me/${whatsappNumber}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
|
||||||
>
|
|
||||||
<WhatsAppIcon className="h-4 w-4" />
|
|
||||||
WhatsApp
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{!isArchived && client.clientPortalEnabled === true ? (
|
{!isArchived && client.clientPortalEnabled === true ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
<div className="hidden sm:inline-flex">
|
<div className="hidden sm:inline-flex">
|
||||||
<PortalInviteButton
|
<PortalInviteButton
|
||||||
clientId={client.id}
|
clientId={client.id}
|
||||||
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
defaultEmail={primaryEmail}
|
defaultEmail={primaryEmail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="hidden sm:inline-flex">
|
|
||||||
<GdprExportButton clientId={client.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{client.tags && client.tags.length > 0 && (
|
{client.tags && client.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
right perm) permanently-delete. Destructive actions sit out
|
right perm) permanently-delete. Destructive actions sit out
|
||||||
of the primary action flow. */}
|
of the primary action flow. */}
|
||||||
<div className="flex items-start gap-1">
|
<div className="flex items-start gap-1">
|
||||||
|
{/* CM-4: GDPR export relocated here as a compact icon trigger,
|
||||||
|
alongside reminder/archive/delete. Self-gates on permission. */}
|
||||||
|
<GdprExportButton clientId={client.id} variant="icon" />
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
<PermissionGate resource="admin" action="permanently_delete_clients">
|
<PermissionGate resource="admin" action="permanently_delete_clients">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -48,7 +48,15 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
|
|||||||
failed: 'destructive',
|
failed: 'destructive',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GdprExportButton({ clientId }: { clientId: string }) {
|
export function GdprExportButton({
|
||||||
|
clientId,
|
||||||
|
variant = 'button',
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
/** `button` = standalone outline button (default). `icon` = compact icon-only
|
||||||
|
* trigger for the detail-header top-right action cluster (CM-4). */
|
||||||
|
variant?: 'button' | 'icon';
|
||||||
|
}) {
|
||||||
const { can, isSuperAdmin } = usePermissions();
|
const { can, isSuperAdmin } = usePermissions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
{variant === 'icon' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="GDPR export"
|
||||||
|
title="GDPR export"
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FileDown className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<Button variant="outline" size="sm" className="h-8">
|
<Button variant="outline" size="sm" className="h-8">
|
||||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||||
GDPR export
|
GDPR export
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ import {
|
|||||||
Trophy,
|
Trophy,
|
||||||
XCircle,
|
XCircle,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Mail,
|
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Phone,
|
|
||||||
AlarmClock,
|
AlarmClock,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
|
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
|
||||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
||||||
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||||
import { formatOutcome } from '@/lib/constants';
|
import { formatOutcome } from '@/lib/constants';
|
||||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
/** Primary contact channels resolved from the linked client. The header
|
/** Primary contact channels resolved from the linked client. The
|
||||||
* uses these to render Email / Call / WhatsApp buttons so the rep
|
* Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
|
||||||
* doesn't have to navigate to the client page just to reach out. */
|
* for downstream reuse (e.g. proxy comms routing, CM-9). */
|
||||||
clientPrimaryEmail?: string | null;
|
clientPrimaryEmail?: string | null;
|
||||||
clientPrimaryPhone?: string | null;
|
clientPrimaryPhone?: string | null;
|
||||||
clientPrimaryPhoneE164?: string | null;
|
clientPrimaryPhoneE164?: string | null;
|
||||||
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
const [logContactOpen, setLogContactOpen] = useState(false);
|
const [logContactOpen, setLogContactOpen] = useState(false);
|
||||||
const [reminderOpen, setReminderOpen] = useState(false);
|
const [reminderOpen, setReminderOpen] = useState(false);
|
||||||
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
|
// (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 isArchived = !!interest.archivedAt;
|
||||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||||
const isClosed = !!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({
|
const reopenMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||||
@@ -285,6 +275,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
{interest.activeReminderCount}
|
{interest.activeReminderCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{assignmentEnabled ? (
|
||||||
<PermissionGate resource="interests" action="edit">
|
<PermissionGate resource="interests" action="edit">
|
||||||
<AssignedToChip
|
<AssignedToChip
|
||||||
interestId={interest.id}
|
interestId={interest.id}
|
||||||
@@ -292,6 +283,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
currentAssignedToName={interest.assignedToName ?? null}
|
currentAssignedToName={interest.assignedToName ?? null}
|
||||||
/>
|
/>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
|
) : null}
|
||||||
<MultiEoiChip interestId={interest.id} />
|
<MultiEoiChip interestId={interest.id} />
|
||||||
<DealPulseChip
|
<DealPulseChip
|
||||||
interest={{
|
interest={{
|
||||||
@@ -340,14 +332,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contact deep-links - let the rep email / call / WhatsApp the
|
{/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
|
||||||
client without leaving the interest workspace. Resolved from
|
Client-page link + Log-contact action stay - the rep can still
|
||||||
the linked client's primary contact channels (server-side
|
jump to the client and record outreach without leaving here. */}
|
||||||
fetch in getInterestById). */}
|
|
||||||
{interest.clientPrimaryEmail ||
|
|
||||||
interest.clientPrimaryPhone ||
|
|
||||||
whatsappNumber ||
|
|
||||||
interest.clientId ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
{interest.clientId ? (
|
{interest.clientId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -366,56 +353,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{interest.clientPrimaryEmail ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`mailto:${interest.clientPrimaryEmail}`}
|
|
||||||
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
|
||||||
>
|
|
||||||
<Mail />
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{interest.clientPrimaryPhone ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`tel:${interest.clientPrimaryPhone}`}
|
|
||||||
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
|
||||||
>
|
|
||||||
<Phone />
|
|
||||||
Call
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{whatsappNumber ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`https://wa.me/${whatsappNumber}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`Message on WhatsApp`}
|
|
||||||
>
|
|
||||||
<WhatsAppIcon className="h-4 w-4" />
|
|
||||||
WhatsApp
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -427,7 +364,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
Log contact
|
Log contact
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
|||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||||
import { SOURCES } from '@/lib/constants';
|
import { SOURCES } from '@/lib/constants';
|
||||||
|
|
||||||
interface ResidentialInterest {
|
interface ResidentialInterest {
|
||||||
@@ -95,6 +96,8 @@ function OverviewTab({
|
|||||||
stageOptions: Array<{ value: string; label: string }>;
|
stageOptions: Array<{ value: string; label: string }>;
|
||||||
}) {
|
}) {
|
||||||
const update = useInterestPatch(interestId);
|
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) => {
|
const save = (field: string) => async (next: string | null) => {
|
||||||
await update.mutateAsync({ [field]: next });
|
await update.mutateAsync({ [field]: next });
|
||||||
};
|
};
|
||||||
@@ -105,6 +108,7 @@ function OverviewTab({
|
|||||||
}>({
|
}>({
|
||||||
queryKey: ['residential-assignable-users'],
|
queryKey: ['residential-assignable-users'],
|
||||||
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
|
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
|
||||||
|
enabled: assignmentEnabled,
|
||||||
});
|
});
|
||||||
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
|
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
|
||||||
value: u.id,
|
value: u.id,
|
||||||
@@ -132,6 +136,7 @@ function OverviewTab({
|
|||||||
onSave={save('source')}
|
onSave={save('source')}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
{assignmentEnabled ? (
|
||||||
<Row label="Assigned to">
|
<Row label="Assigned to">
|
||||||
<InlineEditableField
|
<InlineEditableField
|
||||||
variant="select"
|
variant="select"
|
||||||
@@ -141,6 +146,7 @@ function OverviewTab({
|
|||||||
placeholder="Unassigned"
|
placeholder="Unassigned"
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -320,9 +320,11 @@ interface ScanShellProps {
|
|||||||
* imagery. */
|
* imagery. */
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
portName?: 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 router = useRouter();
|
||||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
|
|||||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||||
setImagePreview(URL.createObjectURL(file));
|
setImagePreview(URL.createObjectURL(file));
|
||||||
setCurrentFile(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' });
|
setState({ kind: 'processing', engine: 'tesseract' });
|
||||||
|
|
||||||
// Always run Tesseract first - it's free, on-device, and gives us a
|
// Always run Tesseract first - it's free, on-device, and gives us a
|
||||||
|
|||||||
@@ -833,7 +833,12 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
|||||||
// every new lead. Falls back to null (Unassigned) when none of
|
// every new lead. Falls back to null (Unassigned) when none of
|
||||||
// the above resolve.
|
// the above resolve.
|
||||||
let resolvedAssignedTo = interestData.assignedTo ?? null;
|
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 defaultOwner = await getSetting('default_new_interest_owner', portId);
|
||||||
const v = defaultOwner?.value as { userId?: string } | null | undefined;
|
const v = defaultOwner?.value as { userId?: string } | null | undefined;
|
||||||
if (v?.userId) {
|
if (v?.userId) {
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export interface OcrConfigPublic {
|
|||||||
* provider is never called even if a key is configured.
|
* provider is never called even if a key is configured.
|
||||||
*/
|
*/
|
||||||
aiEnabled: boolean;
|
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. */
|
/** Internal shape including the decrypted key - server-side only. */
|
||||||
@@ -52,6 +58,7 @@ interface StoredOcrConfig {
|
|||||||
apiKeyEncrypted: string | null;
|
apiKeyEncrypted: string | null;
|
||||||
useGlobal: boolean;
|
useGlobal: boolean;
|
||||||
aiEnabled?: boolean;
|
aiEnabled?: boolean;
|
||||||
|
manualEntry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = 'ocr.config';
|
const KEY = 'ocr.config';
|
||||||
@@ -106,12 +113,14 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
|
|||||||
hasApiKey: false,
|
hasApiKey: false,
|
||||||
useGlobal: portRow?.useGlobal === true,
|
useGlobal: portRow?.useGlobal === true,
|
||||||
aiEnabled: false,
|
aiEnabled: false,
|
||||||
|
manualEntry: portRow?.manualEntry === true,
|
||||||
source: 'none',
|
source: 'none',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// The aiEnabled flag is per-port: even if the port falls back to a global
|
// The aiEnabled / manualEntry flags are per-port: even if the port falls back
|
||||||
// key, the port admin still has to flip the switch on this port.
|
// to a global key, the port admin still has to flip these on this port.
|
||||||
const aiEnabled = portRow?.aiEnabled === true;
|
const aiEnabled = portRow?.aiEnabled === true;
|
||||||
|
const manualEntry = portRow?.manualEntry === true;
|
||||||
return {
|
return {
|
||||||
provider: sourceRow.provider,
|
provider: sourceRow.provider,
|
||||||
model: sourceRow.model,
|
model: sourceRow.model,
|
||||||
@@ -119,6 +128,7 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
|
|||||||
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
|
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
|
||||||
useGlobal: portRow?.useGlobal === true,
|
useGlobal: portRow?.useGlobal === true,
|
||||||
aiEnabled,
|
aiEnabled,
|
||||||
|
manualEntry,
|
||||||
source: useGlobal ? 'global' : 'port',
|
source: useGlobal ? 'global' : 'port',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,6 +143,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
|
|||||||
hasApiKey: false,
|
hasApiKey: false,
|
||||||
useGlobal: false,
|
useGlobal: false,
|
||||||
aiEnabled: false,
|
aiEnabled: false,
|
||||||
|
manualEntry: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -141,6 +152,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
|
|||||||
hasApiKey: Boolean(row.apiKeyEncrypted),
|
hasApiKey: Boolean(row.apiKeyEncrypted),
|
||||||
useGlobal: row.useGlobal,
|
useGlobal: row.useGlobal,
|
||||||
aiEnabled: row.aiEnabled === true,
|
aiEnabled: row.aiEnabled === true,
|
||||||
|
manualEntry: row.manualEntry === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +166,8 @@ export interface SaveOcrConfigInput {
|
|||||||
useGlobal?: boolean;
|
useGlobal?: boolean;
|
||||||
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */
|
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */
|
||||||
aiEnabled?: boolean;
|
aiEnabled?: boolean;
|
||||||
|
/** Per-port toggle: manual entry (skip all parsing). Defaults to false. */
|
||||||
|
manualEntry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveOcrConfig(
|
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
|
// 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).
|
// caller didn't pass one (so toggling provider/model doesn't re-disable AI).
|
||||||
const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false);
|
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(
|
await writeRow(
|
||||||
portId,
|
portId,
|
||||||
{
|
{
|
||||||
@@ -179,6 +196,7 @@ export async function saveOcrConfig(
|
|||||||
apiKeyEncrypted,
|
apiKeyEncrypted,
|
||||||
useGlobal: portId === null ? false : Boolean(input.useGlobal),
|
useGlobal: portId === null ? false : Boolean(input.useGlobal),
|
||||||
aiEnabled,
|
aiEnabled,
|
||||||
|
manualEntry,
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|||||||
94
tests/integration/interests-assignment-toggle.test.ts
Normal file
94
tests/integration/interests-assignment-toggle.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* CM-5: interest assignment is gated behind the per-port `assignment_enabled`
|
||||||
|
* setting. When off (the default), createInterest must NOT auto-assign an owner
|
||||||
|
* even when a `default_new_interest_owner` is configured. When on, the existing
|
||||||
|
* tier-2 (port default-owner) auto-assign fires. An explicit `assignedTo` from
|
||||||
|
* the caller (tier 1) is always honored regardless of the toggle.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import { userProfiles } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
// interests.assigned_to FKs to user_profiles(user_id); the owner must exist.
|
||||||
|
const OWNER = 'cm5-default-owner';
|
||||||
|
|
||||||
|
describe('interests.service - assignment_enabled gate (CM-5)', () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,6 +147,60 @@ describe('OCR config', () => {
|
|||||||
expect(resolved.aiEnabled).toBe(false);
|
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 () => {
|
it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => {
|
||||||
await saveOcrConfig(
|
await saveOcrConfig(
|
||||||
null,
|
null,
|
||||||
|
|||||||
Reference in New Issue
Block a user