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
|
||||
|
||||
@@ -30,6 +30,7 @@ interface ConfigResp {
|
||||
hasApiKey: boolean;
|
||||
useGlobal: boolean;
|
||||
aiEnabled: boolean;
|
||||
manualEntry: boolean;
|
||||
};
|
||||
models: Record<Provider, string[]>;
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsBlockBody
|
||||
@@ -89,6 +90,7 @@ function SettingsBlockBody({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? 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 }>(
|
||||
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({
|
||||
</div>
|
||||
) : 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="space-y-1.5">
|
||||
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{primaryEmail ? (
|
||||
<Button
|
||||
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 ? (
|
||||
{/* 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 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<div className="hidden sm:inline-flex">
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
defaultEmail={primaryEmail}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hidden sm:inline-flex">
|
||||
<GdprExportButton clientId={client.id} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
right perm) permanently-delete. Destructive actions sit out
|
||||
of the primary action flow. */}
|
||||
<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 && (
|
||||
<PermissionGate resource="admin" action="permanently_delete_clients">
|
||||
<button
|
||||
|
||||
@@ -48,7 +48,15 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
|
||||
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 qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
GDPR export
|
||||
</Button>
|
||||
{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">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
GDPR export
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
) : null}
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<AssignedToChip
|
||||
interestId={interest.id}
|
||||
currentAssignedTo={interest.assignedTo ?? null}
|
||||
currentAssignedToName={interest.assignedToName ?? null}
|
||||
/>
|
||||
</PermissionGate>
|
||||
{assignmentEnabled ? (
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<AssignedToChip
|
||||
interestId={interest.id}
|
||||
currentAssignedTo={interest.assignedTo ?? null}
|
||||
currentAssignedToName={interest.assignedToName ?? null}
|
||||
/>
|
||||
</PermissionGate>
|
||||
) : null}
|
||||
<MultiEoiChip interestId={interest.id} />
|
||||
<DealPulseChip
|
||||
interest={{
|
||||
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientId ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
||||
aria-label="Open client page"
|
||||
>
|
||||
<User />
|
||||
Client page
|
||||
</Link>
|
||||
</Button>
|
||||
) : 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}
|
||||
{/* 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. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientId ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
onClick={() => setLogContactOpen(true)}
|
||||
aria-label="Log a contact for this interest"
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
Log contact
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
||||
aria-label="Open client page"
|
||||
>
|
||||
<User />
|
||||
Client page
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
onClick={() => setLogContactOpen(true)}
|
||||
aria-label="Log a contact for this interest"
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
Log contact
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 { 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')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Assigned to">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={assigneeOptions}
|
||||
value={interest.assignedTo}
|
||||
onSave={save('assignedTo')}
|
||||
placeholder="Unassigned"
|
||||
/>
|
||||
</Row>
|
||||
{assignmentEnabled ? (
|
||||
<Row label="Assigned to">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={assigneeOptions}
|
||||
value={interest.assignedTo}
|
||||
onSave={save('assignedTo')}
|
||||
placeholder="Unassigned"
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -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<HTMLInputElement>(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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user