crm finish

This commit is contained in:
2026-03-14 18:58:23 -05:00
parent c0cc546e33
commit df3f1412f6
16 changed files with 679 additions and 38 deletions

View File

@@ -22,8 +22,9 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- CRM customer and vendor create/edit/detail workflows - CRM customer and vendor create/edit/detail workflows
- CRM search, filters, and persisted status tagging - CRM search, filters, and persisted status tagging
- CRM contact-history timeline with authored notes, calls, emails, and meetings - CRM contact-history timeline with authored notes, calls, emails, and meetings
- CRM shared file attachments on customer and vendor records - CRM shared file attachments on customer and vendor records, including delete support
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support - CRM reseller hierarchy, parent-child customer structure, and reseller discount support
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
- Theme persistence fixes and denser responsive workspace layouts - Theme persistence fixes and denser responsive workspace layouts
- SVAR Gantt integration wrapper with demo planning data - SVAR Gantt integration wrapper with demo planning data
- Multi-stage Docker packaging and migration-aware entrypoint - Multi-stage Docker packaging and migration-aware entrypoint
@@ -34,15 +35,15 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution - Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
- The frontend bundle is functional but should be code-split later, especially around the gantt module - The frontend bundle is functional but should be code-split later, especially around the gantt module
- CRM deeper operational metadata, lifecycle reporting, and richer multi-contact/commercial terms are not built yet - CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
## Planned feature phases ## Planned feature phases
### Phase 1: CRM and master data hardening ### Phase 1: CRM and master data hardening
- Better seed/bootstrap strategy for non-development environments - Better seed/bootstrap strategy for non-development environments
- Deeper CRM operational fields and lifecycle reporting - Additional CRM account-role depth if later sales/purchasing workflows need it
- Multi-contact records, commercial terms, and account-role expansion - More derived CRM rollups once quotes, orders, and purchasing documents exist
### Phase 2: Inventory and manufacturing core ### Phase 2: Inventory and manufacturing core
@@ -96,8 +97,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
## Near-term priority order ## Near-term priority order
1. CRM deeper operational fields, commercial terms, and lifecycle reporting 1. Inventory item and BOM data model
2. Inventory item and BOM data model 2. Sales order and quote foundation
3. Sales order and quote foundation 3. Shipping module tied to sales orders
4. Shipping module tied to sales orders 4. Live manufacturing gantt scheduling
5. Live manufacturing gantt scheduling 5. Expanded role and audit administration

View File

@@ -16,6 +16,7 @@ import type {
CrmCustomerHierarchyOptionDto, CrmCustomerHierarchyOptionDto,
CrmRecordDetailDto, CrmRecordDetailDto,
CrmRecordInput, CrmRecordInput,
CrmLifecycleStage,
CrmRecordStatus, CrmRecordStatus,
CrmRecordSummaryDto, CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js"; } from "@mrp/shared/dist/crm/types.js";
@@ -121,12 +122,32 @@ export const api = {
token token
); );
}, },
getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { deleteAttachment(token: string, fileId: string) {
return request<FileAttachmentDto>(
`/api/v1/files/${fileId}`,
{
method: "DELETE",
},
token
);
},
getCustomers(
token: string,
filters?: {
q?: string;
status?: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
state?: string;
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
}
) {
return request<CrmRecordSummaryDto[]>( return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/customers${buildQueryString({ `/api/v1/crm/customers${buildQueryString({
q: filters?.q, q: filters?.q,
status: filters?.status, status: filters?.status,
lifecycleStage: filters?.lifecycleStage,
state: filters?.state, state: filters?.state,
flag: filters?.flag,
})}`, })}`,
undefined, undefined,
token token
@@ -184,12 +205,23 @@ export const api = {
token token
); );
}, },
getVendors(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { getVendors(
token: string,
filters?: {
q?: string;
status?: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
state?: string;
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
}
) {
return request<CrmRecordSummaryDto[]>( return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/vendors${buildQueryString({ `/api/v1/crm/vendors${buildQueryString({
q: filters?.q, q: filters?.q,
status: filters?.status, status: filters?.status,
lifecycleStage: filters?.lifecycleStage,
state: filters?.state, state: filters?.state,
flag: filters?.flag,
})}`, })}`,
undefined, undefined,
token token

View File

@@ -8,6 +8,7 @@ import { api, ApiError } from "../../lib/api";
interface CrmAttachmentsPanelProps { interface CrmAttachmentsPanelProps {
ownerType: string; ownerType: string;
ownerId: string; ownerId: string;
onAttachmentCountChange?: (count: number) => void;
} }
function formatFileSize(sizeBytes: number) { function formatFileSize(sizeBytes: number) {
@@ -22,11 +23,12 @@ function formatFileSize(sizeBytes: number) {
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
} }
export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelProps) { export function CrmAttachmentsPanel({ ownerType, ownerId, onAttachmentCountChange }: CrmAttachmentsPanelProps) {
const { token, user } = useAuth(); const { token, user } = useAuth();
const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]); const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]);
const [status, setStatus] = useState("Loading attachments..."); const [status, setStatus] = useState("Loading attachments...");
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false; const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
@@ -40,6 +42,7 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP
.getAttachments(token, ownerType, ownerId) .getAttachments(token, ownerType, ownerId)
.then((nextAttachments) => { .then((nextAttachments) => {
setAttachments(nextAttachments); setAttachments(nextAttachments);
onAttachmentCountChange?.(nextAttachments.length);
setStatus( setStatus(
nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.` nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.`
); );
@@ -48,7 +51,7 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP
const message = error instanceof ApiError ? error.message : "Unable to load attachments."; const message = error instanceof ApiError ? error.message : "Unable to load attachments.";
setStatus(message); setStatus(message);
}); });
}, [canReadFiles, ownerId, ownerType, token]); }, [canReadFiles, onAttachmentCountChange, ownerId, ownerType, token]);
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) { async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -61,7 +64,11 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP
try { try {
const attachment = await api.uploadFile(token, file, ownerType, ownerId); const attachment = await api.uploadFile(token, file, ownerType, ownerId);
setAttachments((current) => [attachment, ...current]); setAttachments((current) => {
const nextAttachments = [attachment, ...current];
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment uploaded."); setStatus("Attachment uploaded.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to upload attachment."; const message = error instanceof ApiError ? error.message : "Unable to upload attachment.";
@@ -88,6 +95,30 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP
} }
} }
async function handleDelete(attachment: FileAttachmentDto) {
if (!token || !canWriteFiles) {
return;
}
setDeletingAttachmentId(attachment.id);
setStatus(`Deleting ${attachment.originalName}...`);
try {
await api.deleteAttachment(token, attachment.id);
setAttachments((current) => {
const nextAttachments = current.filter((item) => item.id !== attachment.id);
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment deleted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
setStatus(message);
} finally {
setDeletingAttachmentId(null);
}
}
return ( return (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7"> <article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
@@ -135,6 +166,16 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP
> >
Open Open
</button> </button>
{canWriteFiles ? (
<button
type="button"
onClick={() => handleDelete(attachment)}
disabled={deletingAttachmentId === attachment.id}
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
>
{deletingAttachmentId === attachment.id ? "Deleting..." : "Delete"}
</button>
) : null}
</div> </div>
</div> </div>
))} ))}

View File

@@ -8,6 +8,7 @@ import { api, ApiError } from "../../lib/api";
import { CrmAttachmentsPanel } from "./CrmAttachmentsPanel"; import { CrmAttachmentsPanel } from "./CrmAttachmentsPanel";
import { CrmContactsPanel } from "./CrmContactsPanel"; import { CrmContactsPanel } from "./CrmContactsPanel";
import { CrmContactEntryForm } from "./CrmContactEntryForm"; import { CrmContactEntryForm } from "./CrmContactEntryForm";
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
import { CrmContactTypeBadge } from "./CrmContactTypeBadge"; import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
import { CrmStatusBadge } from "./CrmStatusBadge"; import { CrmStatusBadge } from "./CrmStatusBadge";
import { type CrmEntity, crmConfigs, emptyCrmContactEntryInput } from "./config"; import { type CrmEntity, crmConfigs, emptyCrmContactEntryInput } from "./config";
@@ -78,6 +79,13 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
contactHistory: [nextEntry, ...current.contactHistory].sort( contactHistory: [nextEntry, ...current.contactHistory].sort(
(left, right) => new Date(right.contactAt).getTime() - new Date(left.contactAt).getTime() (left, right) => new Date(right.contactAt).getTime() - new Date(left.contactAt).getTime()
), ),
rollups: {
lastContactAt: nextEntry.contactAt,
contactHistoryCount: (current.rollups?.contactHistoryCount ?? current.contactHistory.length) + 1,
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
attachmentCount: current.rollups?.attachmentCount ?? 0,
childCustomerCount: current.rollups?.childCustomerCount,
},
} }
: current : current
); );
@@ -102,7 +110,10 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p>
<h3 className="mt-3 text-3xl font-bold text-text">{record.name}</h3> <h3 className="mt-3 text-3xl font-bold text-text">{record.name}</h3>
<div className="mt-4"> <div className="mt-4">
<CrmStatusBadge status={record.status} /> <div className="flex flex-wrap gap-3">
<CrmStatusBadge status={record.status} />
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
</div>
</div> </div>
<p className="mt-2 text-sm text-muted"> <p className="mt-2 text-sm text-muted">
{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}. {config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}.
@@ -165,6 +176,18 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-muted"> <div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-muted">
Created {new Date(record.createdAt).toLocaleDateString()} Created {new Date(record.createdAt).toLocaleDateString()}
</div> </div>
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operational Flags</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.12em]">
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Preferred</span> : null}
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Strategic</span> : null}
{record.requiresApproval ? <span className="rounded-full border border-amber-400/40 px-3 py-1 text-amber-700 dark:text-amber-300">Requires Approval</span> : null}
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-3 py-1 text-rose-700 dark:text-rose-300">Blocked</span> : null}
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
<span className="rounded-full border border-line/70 px-3 py-1 text-muted">Standard</span>
) : null}
</div>
</div>
{entity === "customer" ? ( {entity === "customer" ? (
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-4 py-4"> <div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reseller Profile</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reseller Profile</p>
@@ -187,6 +210,26 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
) : null} ) : null}
</article> </article>
</div> </div>
<section className="grid gap-4 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-5 py-5 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Last Contact</p>
<div className="mt-3 text-lg font-bold text-text">
{record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"}
</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-5 py-5 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Entries</p>
<div className="mt-3 text-lg font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.length}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-5 py-5 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account Contacts</p>
<div className="mt-3 text-lg font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-5 py-5 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Attachments</p>
<div className="mt-3 text-lg font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div>
</article>
</section>
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? ( {entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p>
@@ -212,7 +255,21 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
ownerId={record.id} ownerId={record.id}
contacts={record.contacts ?? []} contacts={record.contacts ?? []}
onContactsChange={(contacts: CrmContactDto[]) => onContactsChange={(contacts: CrmContactDto[]) =>
setRecord((current) => (current ? { ...current, contacts } : current)) setRecord((current) =>
current
? {
...current,
contacts,
rollups: {
lastContactAt: current.rollups?.lastContactAt ?? null,
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
contactCount: contacts.length,
attachmentCount: current.rollups?.attachmentCount ?? 0,
childCustomerCount: current.rollups?.childCustomerCount,
},
}
: current
)
} }
/> />
<section className="grid gap-4 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]"> <section className="grid gap-4 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
@@ -264,7 +321,26 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
)} )}
</article> </article>
</section> </section>
<CrmAttachmentsPanel ownerType={config.fileOwnerType} ownerId={record.id} /> <CrmAttachmentsPanel
ownerType={config.fileOwnerType}
ownerId={record.id}
onAttachmentCountChange={(attachmentCount) =>
setRecord((current) =>
current
? {
...current,
rollups: {
lastContactAt: current.rollups?.lastContactAt ?? null,
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
attachmentCount,
childCustomerCount: current.rollups?.childCustomerCount,
},
}
: current
)
}
/>
</section> </section>
); );
} }

View File

@@ -63,6 +63,11 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
currencyCode: record.currencyCode ?? "USD", currencyCode: record.currencyCode ?? "USD",
taxExempt: record.taxExempt ?? false, taxExempt: record.taxExempt ?? false,
creditHold: record.creditHold ?? false, creditHold: record.creditHold ?? false,
lifecycleStage: record.lifecycleStage ?? "ACTIVE",
preferredAccount: record.preferredAccount ?? false,
strategicAccount: record.strategicAccount ?? false,
requiresApproval: record.requiresApproval ?? false,
blockedAccount: record.blockedAccount ?? false,
notes: record.notes, notes: record.notes,
}); });
setStatus(`${config.singularLabel} record loaded.`); setStatus(`${config.singularLabel} record loaded.`);

View File

@@ -0,0 +1,22 @@
import type { CrmLifecycleStage } from "@mrp/shared/dist/crm/types.js";
import { crmLifecyclePalette } from "./config";
interface CrmLifecycleBadgeProps {
stage: CrmLifecycleStage;
}
const labels: Record<CrmLifecycleStage, string> = {
PROSPECT: "Prospect",
ACTIVE: "Active",
DORMANT: "Dormant",
CHURNED: "Churned",
};
export function CrmLifecycleBadge({ stage }: CrmLifecycleBadgeProps) {
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${crmLifecyclePalette[stage]}`}>
{labels[stage]}
</span>
);
}

View File

@@ -1,12 +1,13 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { CrmRecordStatus, CrmRecordSummaryDto } from "@mrp/shared/dist/crm/types.js"; import type { CrmLifecycleStage, CrmRecordStatus, CrmRecordSummaryDto } from "@mrp/shared/dist/crm/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
import { CrmStatusBadge } from "./CrmStatusBadge"; import { CrmStatusBadge } from "./CrmStatusBadge";
import { crmStatusFilters, type CrmEntity, crmConfigs } from "./config"; import { crmLifecycleFilters, crmOperationalFilters, crmStatusFilters, type CrmEntity, crmConfigs } from "./config";
interface CrmListPageProps { interface CrmListPageProps {
entity: CrmEntity; entity: CrmEntity;
@@ -19,6 +20,10 @@ export function CrmListPage({ entity }: CrmListPageProps) {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [stateFilter, setStateFilter] = useState(""); const [stateFilter, setStateFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | CrmRecordStatus>("ALL"); const [statusFilter, setStatusFilter] = useState<"ALL" | CrmRecordStatus>("ALL");
const [lifecycleFilter, setLifecycleFilter] = useState<"ALL" | CrmLifecycleStage>("ALL");
const [operationalFilter, setOperationalFilter] = useState<"ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED">(
"ALL"
);
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`); const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false; const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
@@ -32,6 +37,8 @@ export function CrmListPage({ entity }: CrmListPageProps) {
q: searchTerm.trim() || undefined, q: searchTerm.trim() || undefined,
state: stateFilter.trim() || undefined, state: stateFilter.trim() || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter, status: statusFilter === "ALL" ? undefined : statusFilter,
lifecycleStage: lifecycleFilter === "ALL" ? undefined : lifecycleFilter,
flag: operationalFilter === "ALL" ? undefined : operationalFilter,
}; };
const loadRecords = entity === "customer" ? api.getCustomers(token, filters) : api.getVendors(token, filters); const loadRecords = entity === "customer" ? api.getCustomers(token, filters) : api.getVendors(token, filters);
@@ -45,7 +52,7 @@ export function CrmListPage({ entity }: CrmListPageProps) {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`; const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
setStatus(message); setStatus(message);
}); });
}, [config.collectionLabel, entity, searchTerm, stateFilter, statusFilter, token]); }, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
return ( return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
@@ -66,7 +73,7 @@ export function CrmListPage({ entity }: CrmListPageProps) {
</Link> </Link>
) : null} ) : null}
</div> </div>
<div className="mt-6 grid gap-4 rounded-3xl border border-line/70 bg-page/60 p-4 lg:grid-cols-[1.4fr_0.8fr_0.8fr]"> <div className="mt-6 grid gap-4 rounded-3xl border border-line/70 bg-page/60 p-4 xl:grid-cols-[1.35fr_0.8fr_0.8fr_0.9fr_0.9fr]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input <input
@@ -90,6 +97,20 @@ export function CrmListPage({ entity }: CrmListPageProps) {
))} ))}
</select> </select>
</label> </label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Lifecycle</span>
<select
value={lifecycleFilter}
onChange={(event) => setLifecycleFilter(event.target.value as "ALL" | CrmLifecycleStage)}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
{crmLifecycleFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">State / Province</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">State / Province</span>
<input <input
@@ -99,6 +120,22 @@ export function CrmListPage({ entity }: CrmListPageProps) {
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand" className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
/> />
</label> </label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Operational</span>
<select
value={operationalFilter}
onChange={(event) =>
setOperationalFilter(event.target.value as "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED")
}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
{crmOperationalFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div> </div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-4 py-3 text-sm text-muted">{status}</div> <div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-4 py-3 text-sm text-muted">{status}</div>
{records.length === 0 ? ( {records.length === 0 ? (
@@ -112,9 +149,10 @@ export function CrmListPage({ entity }: CrmListPageProps) {
<tr> <tr>
<th className="px-4 py-3">Name</th> <th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Status</th> <th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Email</th> <th className="px-4 py-3">Lifecycle</th>
<th className="px-4 py-3">Phone</th> <th className="px-4 py-3">Operational</th>
<th className="px-4 py-3">Location</th> <th className="px-4 py-3">Activity</th>
<th className="px-4 py-3">Contact</th>
<th className="px-4 py-3">Updated</th> <th className="px-4 py-3">Updated</th>
</tr> </tr>
</thead> </thead>
@@ -135,10 +173,31 @@ export function CrmListPage({ entity }: CrmListPageProps) {
<td className="px-4 py-3"> <td className="px-4 py-3">
<CrmStatusBadge status={record.status} /> <CrmStatusBadge status={record.status} />
</td> </td>
<td className="px-4 py-3 text-muted">{record.email}</td>
<td className="px-4 py-3 text-muted">{record.phone}</td>
<td className="px-4 py-3 text-muted"> <td className="px-4 py-3 text-muted">
{record.city}, {record.state}, {record.country} {record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
</td>
<td className="px-4 py-3 text-xs text-muted">
<div className="flex flex-wrap gap-2">
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Preferred</span> : null}
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Strategic</span> : null}
{record.requiresApproval ? <span className="rounded-full border border-line/70 px-2 py-1">Approval</span> : null}
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-2 py-1 text-rose-600 dark:text-rose-300">Blocked</span> : null}
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
<span>Standard</span>
) : null}
</div>
</td>
<td className="px-4 py-3 text-xs text-muted">
<div>{record.rollups?.contactHistoryCount ?? 0} timeline entries</div>
<div>{record.rollups?.attachmentCount ?? 0} attachments</div>
{entity === "customer" ? <div>{record.rollups?.childCustomerCount ?? 0} child accounts</div> : null}
</td>
<td className="px-4 py-3 text-muted">
<div>{record.email}</div>
<div className="mt-1 text-xs">
{record.city}, {record.state}, {record.country}
</div>
<div className="mt-1 text-xs">{record.phone}</div>
</td> </td>
<td className="px-4 py-3 text-muted">{new Date(record.updatedAt).toLocaleDateString()}</td> <td className="px-4 py-3 text-muted">{new Date(record.updatedAt).toLocaleDateString()}</td>
</tr> </tr>

View File

@@ -1,6 +1,6 @@
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js"; import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import { crmStatusOptions } from "./config"; import { crmLifecycleOptions, crmStatusOptions } from "./config";
import type { CrmEntity } from "./config"; import type { CrmEntity } from "./config";
const fields: Array<{ const fields: Array<{
@@ -43,6 +43,20 @@ export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }:
))} ))}
</select> </select>
</label> </label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Lifecycle stage</span>
<select
value={form.lifecycleStage ?? "ACTIVE"}
onChange={(event) => onChange("lifecycleStage", event.target.value as CrmRecordInput["lifecycleStage"])}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
{crmLifecycleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{entity === "customer" ? ( {entity === "customer" ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)]">
<label className="block"> <label className="block">
@@ -124,6 +138,40 @@ export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }:
<span className="text-sm font-semibold text-text">Credit hold</span> <span className="text-sm font-semibold text-text">Credit hold</span>
</label> </label>
</div> </div>
<div className="grid gap-4 xl:grid-cols-4">
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.preferredAccount ?? false}
onChange={(event) => onChange("preferredAccount", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Preferred account</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.strategicAccount ?? false}
onChange={(event) => onChange("strategicAccount", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Strategic account</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.requiresApproval ?? false}
onChange={(event) => onChange("requiresApproval", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Requires approval</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.blockedAccount ?? false}
onChange={(event) => onChange("blockedAccount", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Blocked account</span>
</label>
</div>
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3"> <div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{fields.map((field) => ( {fields.map((field) => (
<label key={String(field.key)} className="block"> <label key={String(field.key)} className="block">

View File

@@ -1,11 +1,13 @@
import { import {
crmContactRoles, crmContactRoles,
crmContactEntryTypes, crmContactEntryTypes,
crmLifecycleStages,
crmRecordStatuses, crmRecordStatuses,
type CrmContactInput, type CrmContactInput,
type CrmContactRole, type CrmContactRole,
type CrmContactEntryInput, type CrmContactEntryInput,
type CrmContactEntryType, type CrmContactEntryType,
type CrmLifecycleStage,
type CrmRecordInput, type CrmRecordInput,
type CrmRecordStatus, type CrmRecordStatus,
} from "@mrp/shared/dist/crm/types.js"; } from "@mrp/shared/dist/crm/types.js";
@@ -59,6 +61,11 @@ export const emptyCrmRecordInput: CrmRecordInput = {
currencyCode: "USD", currencyCode: "USD",
taxExempt: false, taxExempt: false,
creditHold: false, creditHold: false,
lifecycleStage: "ACTIVE",
preferredAccount: false,
strategicAccount: false,
requiresApproval: false,
blockedAccount: false,
}; };
export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [ export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [
@@ -73,6 +80,29 @@ export const crmStatusFilters: Array<{ value: "ALL" | CrmRecordStatus; label: st
...crmStatusOptions, ...crmStatusOptions,
]; ];
export const crmLifecycleOptions: Array<{ value: CrmLifecycleStage; label: string }> = [
{ value: "PROSPECT", label: "Prospect" },
{ value: "ACTIVE", label: "Active" },
{ value: "DORMANT", label: "Dormant" },
{ value: "CHURNED", label: "Churned" },
];
export const crmLifecycleFilters: Array<{ value: "ALL" | CrmLifecycleStage; label: string }> = [
{ value: "ALL", label: "All lifecycle stages" },
...crmLifecycleOptions,
];
export const crmOperationalFilters: Array<{
value: "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
label: string;
}> = [
{ value: "ALL", label: "All accounts" },
{ value: "PREFERRED", label: "Preferred only" },
{ value: "STRATEGIC", label: "Strategic only" },
{ value: "REQUIRES_APPROVAL", label: "Requires approval" },
{ value: "BLOCKED", label: "Blocked only" },
];
export const crmStatusPalette: Record<CrmRecordStatus, string> = { export const crmStatusPalette: Record<CrmRecordStatus, string> = {
LEAD: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300", LEAD: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300", ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
@@ -80,6 +110,13 @@ export const crmStatusPalette: Record<CrmRecordStatus, string> = {
INACTIVE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300", INACTIVE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
}; };
export const crmLifecyclePalette: Record<CrmLifecycleStage, string> = {
PROSPECT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
DORMANT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
CHURNED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const emptyCrmContactEntryInput: CrmContactEntryInput = { export const emptyCrmContactEntryInput: CrmContactEntryInput = {
type: "NOTE", type: "NOTE",
summary: "", summary: "",
@@ -119,4 +156,4 @@ export const crmContactRoleOptions: Array<{ value: CrmContactRole; label: string
{ value: "OTHER", label: "Other" }, { value: "OTHER", label: "Other" },
]; ];
export { crmContactEntryTypes, crmContactRoles, crmRecordStatuses }; export { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses };

View File

@@ -0,0 +1,11 @@
ALTER TABLE "Customer" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Customer" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Vendor" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -113,6 +113,7 @@ model Customer {
postalCode String postalCode String
country String country String
status String @default("ACTIVE") status String @default("ACTIVE")
lifecycleStage String @default("ACTIVE")
isReseller Boolean @default(false) isReseller Boolean @default(false)
resellerDiscountPercent Float @default(0) resellerDiscountPercent Float @default(0)
parentCustomerId String? parentCustomerId String?
@@ -120,6 +121,10 @@ model Customer {
currencyCode String? @default("USD") currencyCode String? @default("USD")
taxExempt Boolean @default(false) taxExempt Boolean @default(false)
creditHold Boolean @default(false) creditHold Boolean @default(false)
preferredAccount Boolean @default(false)
strategicAccount Boolean @default(false)
requiresApproval Boolean @default(false)
blockedAccount Boolean @default(false)
notes String notes String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -141,10 +146,15 @@ model Vendor {
postalCode String postalCode String
country String country String
status String @default("ACTIVE") status String @default("ACTIVE")
lifecycleStage String @default("ACTIVE")
paymentTerms String? paymentTerms String?
currencyCode String? @default("USD") currencyCode String? @default("USD")
taxExempt Boolean @default(false) taxExempt Boolean @default(false)
creditHold Boolean @default(false) creditHold Boolean @default(false)
preferredAccount Boolean @default(false)
strategicAccount Boolean @default(false)
requiresApproval Boolean @default(false)
blockedAccount Boolean @default(false)
notes String notes String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import { crmContactEntryTypes, crmContactRoles, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js"; import { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { Router } from "express"; import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
@@ -32,6 +32,7 @@ const crmRecordSchema = z.object({
postalCode: z.string().trim().min(1), postalCode: z.string().trim().min(1),
country: z.string().trim().min(1), country: z.string().trim().min(1),
status: z.enum(crmRecordStatuses), status: z.enum(crmRecordStatuses),
lifecycleStage: z.enum(crmLifecycleStages).optional(),
notes: z.string(), notes: z.string(),
isReseller: z.boolean().optional(), isReseller: z.boolean().optional(),
resellerDiscountPercent: z.number().min(0).max(100).nullable().optional(), resellerDiscountPercent: z.number().min(0).max(100).nullable().optional(),
@@ -40,12 +41,18 @@ const crmRecordSchema = z.object({
currencyCode: z.string().max(8).nullable().optional(), currencyCode: z.string().max(8).nullable().optional(),
taxExempt: z.boolean().optional(), taxExempt: z.boolean().optional(),
creditHold: z.boolean().optional(), creditHold: z.boolean().optional(),
preferredAccount: z.boolean().optional(),
strategicAccount: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
blockedAccount: z.boolean().optional(),
}); });
const crmListQuerySchema = z.object({ const crmListQuerySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
state: z.string().optional(), state: z.string().optional(),
status: z.enum(crmRecordStatuses).optional(), status: z.enum(crmRecordStatuses).optional(),
lifecycleStage: z.enum(crmLifecycleStages).optional(),
flag: z.enum(["PREFERRED", "STRATEGIC", "REQUIRES_APPROVAL", "BLOCKED"]).optional(),
}); });
const crmContactEntrySchema = z.object({ const crmContactEntrySchema = z.object({
@@ -81,6 +88,8 @@ crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_r
query: parsed.data.q, query: parsed.data.q,
status: parsed.data.status, status: parsed.data.status,
state: parsed.data.state, state: parsed.data.state,
lifecycleStage: parsed.data.lifecycleStage,
flag: parsed.data.flag,
}) })
); );
}); });
@@ -192,6 +201,8 @@ crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_req
query: parsed.data.q, query: parsed.data.q,
status: parsed.data.status, status: parsed.data.status,
state: parsed.data.state, state: parsed.data.state,
lifecycleStage: parsed.data.lifecycleStage,
flag: parsed.data.flag,
}) })
); );
}); });

View File

@@ -8,6 +8,8 @@ import type {
CrmCustomerChildDto, CrmCustomerChildDto,
CrmRecordDetailDto, CrmRecordDetailDto,
CrmRecordInput, CrmRecordInput,
CrmLifecycleStage,
CrmRecordRollupsDto,
CrmRecordStatus, CrmRecordStatus,
CrmRecordSummaryDto, CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js"; } from "@mrp/shared/dist/crm/types.js";
@@ -25,6 +27,11 @@ function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
state: record.state, state: record.state,
country: record.country, country: record.country,
status: record.status as CrmRecordStatus, status: record.status as CrmRecordStatus,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
updatedAt: record.updatedAt.toISOString(), updatedAt: record.updatedAt.toISOString(),
}; };
} }
@@ -43,6 +50,12 @@ function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
type CustomerSummaryRecord = Customer & { type CustomerSummaryRecord = Customer & {
parentCustomer: Pick<Customer, "id" | "name"> | null; parentCustomer: Pick<Customer, "id" | "name"> | null;
_count: {
contactEntries: number;
contacts: number;
childCustomers: number;
};
contactEntries: Array<Pick<ContactEntryWithAuthor, "contactAt">>;
}; };
type CustomerDetailedRecord = Customer & { type CustomerDetailedRecord = Customer & {
@@ -57,6 +70,14 @@ type VendorDetailedRecord = Vendor & {
contacts: ContactRecord[]; contacts: ContactRecord[];
}; };
type VendorSummaryRecord = Vendor & {
_count: {
contactEntries: number;
contacts: number;
};
contactEntries: Array<Pick<ContactEntryWithAuthor, "contactAt">>;
};
type ContactRecord = { type ContactRecord = {
id: string; id: string;
fullName: string; fullName: string;
@@ -75,16 +96,39 @@ function mapCustomerChild(record: Pick<Customer, "id" | "name" | "status">): Crm
}; };
} }
function mapCustomerSummary(record: CustomerSummaryRecord): CrmRecordSummaryDto { function mapRollups(input: {
lastContactAt?: Date | null;
contactHistoryCount: number;
contactCount: number;
attachmentCount: number;
childCustomerCount?: number;
}): CrmRecordRollupsDto {
return {
lastContactAt: input.lastContactAt ? input.lastContactAt.toISOString() : null,
contactHistoryCount: input.contactHistoryCount,
contactCount: input.contactCount,
attachmentCount: input.attachmentCount,
childCustomerCount: input.childCustomerCount,
};
}
function mapCustomerSummary(record: CustomerSummaryRecord, attachmentCount: number): CrmRecordSummaryDto {
return { return {
...mapSummary(record), ...mapSummary(record),
isReseller: record.isReseller, isReseller: record.isReseller,
parentCustomerId: record.parentCustomer?.id ?? null, parentCustomerId: record.parentCustomer?.id ?? null,
parentCustomerName: record.parentCustomer?.name ?? null, parentCustomerName: record.parentCustomer?.name ?? null,
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record._count.contactEntries,
contactCount: record._count.contacts,
attachmentCount,
childCustomerCount: record._count.childCustomers,
}),
}; };
} }
function mapCustomerDetail(record: CustomerDetailedRecord): CrmRecordDetailDto { function mapCustomerDetail(record: CustomerDetailedRecord, attachmentCount: number): CrmRecordDetailDto {
return { return {
...mapDetailedRecord(record), ...mapDetailedRecord(record),
isReseller: record.isReseller, isReseller: record.isReseller,
@@ -96,7 +140,19 @@ function mapCustomerDetail(record: CustomerDetailedRecord): CrmRecordDetailDto {
currencyCode: record.currencyCode, currencyCode: record.currencyCode,
taxExempt: record.taxExempt, taxExempt: record.taxExempt,
creditHold: record.creditHold, creditHold: record.creditHold,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
contacts: record.contacts.map(mapCrmContact), contacts: record.contacts.map(mapCrmContact),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record.contactEntries.length,
contactCount: record.contacts.length,
attachmentCount,
childCustomerCount: record.childCustomers.length,
}),
}; };
} }
@@ -112,14 +168,37 @@ function mapCrmContact(record: ContactRecord): CrmContactDto {
}; };
} }
function mapVendorDetail(record: VendorDetailedRecord): CrmRecordDetailDto { function mapVendorSummary(record: VendorSummaryRecord, attachmentCount: number): CrmRecordSummaryDto {
return {
...mapSummary(record),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record._count.contactEntries,
contactCount: record._count.contacts,
attachmentCount,
}),
};
}
function mapVendorDetail(record: VendorDetailedRecord, attachmentCount: number): CrmRecordDetailDto {
return { return {
...mapDetailedRecord(record), ...mapDetailedRecord(record),
paymentTerms: record.paymentTerms, paymentTerms: record.paymentTerms,
currencyCode: record.currencyCode, currencyCode: record.currencyCode,
taxExempt: record.taxExempt, taxExempt: record.taxExempt,
creditHold: record.creditHold, creditHold: record.creditHold,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
contacts: record.contacts.map(mapCrmContact), contacts: record.contacts.map(mapCrmContact),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record.contactEntries.length,
contactCount: record.contacts.length,
attachmentCount,
}),
}; };
} }
@@ -177,15 +256,50 @@ function mapDetailedRecord(record: DetailedRecord): CrmRecordDetailDto {
interface CrmListFilters { interface CrmListFilters {
query?: string; query?: string;
status?: CrmRecordStatus; status?: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
state?: string; state?: string;
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
}
async function getAttachmentCountMap(ownerType: string, ownerIds: string[]) {
if (ownerIds.length === 0) {
return new Map<string, number>();
}
const groupedAttachments = await prisma.fileAttachment.groupBy({
by: ["ownerId"],
where: {
ownerType,
ownerId: {
in: ownerIds,
},
},
_count: {
_all: true,
},
});
return new Map(groupedAttachments.map((entry) => [entry.ownerId, entry._count._all]));
} }
function buildWhereClause(filters: CrmListFilters) { function buildWhereClause(filters: CrmListFilters) {
const trimmedQuery = filters.query?.trim(); const trimmedQuery = filters.query?.trim();
const trimmedState = filters.state?.trim(); const trimmedState = filters.state?.trim();
const flagFilter =
filters.flag === "PREFERRED"
? { preferredAccount: true }
: filters.flag === "STRATEGIC"
? { strategicAccount: true }
: filters.flag === "REQUIRES_APPROVAL"
? { requiresApproval: true }
: filters.flag === "BLOCKED"
? { blockedAccount: true }
: {};
return { return {
...(filters.status ? { status: filters.status } : {}), ...(filters.status ? { status: filters.status } : {}),
...(filters.lifecycleStage ? { lifecycleStage: filters.lifecycleStage } : {}),
...flagFilter,
...(trimmedState ? { state: { contains: trimmedState } } : {}), ...(trimmedState ? { state: { contains: trimmedState } } : {}),
...(trimmedQuery ...(trimmedQuery
? { ? {
@@ -213,11 +327,28 @@ export async function listCustomers(filters: CrmListFilters = {}) {
name: true, name: true,
}, },
}, },
contactEntries: {
select: {
contactAt: true,
},
orderBy: {
contactAt: "desc",
},
take: 1,
},
_count: {
select: {
contactEntries: true,
contacts: true,
childCustomers: true,
},
},
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
return customers.map(mapCustomerSummary); const attachmentCountMap = await getAttachmentCountMap("crm-customer", customers.map((customer) => customer.id));
return customers.map((customer) => mapCustomerSummary(customer, attachmentCountMap.get(customer.id) ?? 0));
} }
export async function getCustomerById(customerId: string) { export async function getCustomerById(customerId: string) {
@@ -252,7 +383,18 @@ export async function getCustomerById(customerId: string) {
}, },
}); });
return customer ? mapCustomerDetail(customer) : null; if (!customer) {
return null;
}
const attachmentCount = await prisma.fileAttachment.count({
where: {
ownerType: "crm-customer",
ownerId: customerId,
},
});
return mapCustomerDetail(customer, attachmentCount);
} }
export async function createCustomer(payload: CrmRecordInput) { export async function createCustomer(payload: CrmRecordInput) {
@@ -279,6 +421,7 @@ export async function createCustomer(payload: CrmRecordInput) {
country: payload.country, country: payload.country,
status: payload.status, status: payload.status,
notes: payload.notes, notes: payload.notes,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
isReseller: payload.isReseller ?? false, isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0, resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null, parentCustomerId: payload.parentCustomerId ?? null,
@@ -286,6 +429,10 @@ export async function createCustomer(payload: CrmRecordInput) {
currencyCode: payload.currencyCode ?? "USD", currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false, taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false, creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
}, },
}); });
@@ -300,7 +447,19 @@ export async function createCustomer(payload: CrmRecordInput) {
currencyCode: customer.currencyCode, currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt, taxExempt: customer.taxExempt,
creditHold: customer.creditHold, creditHold: customer.creditHold,
lifecycleStage: customer.lifecycleStage as CrmLifecycleStage,
preferredAccount: customer.preferredAccount,
strategicAccount: customer.strategicAccount,
requiresApproval: customer.requiresApproval,
blockedAccount: customer.blockedAccount,
contacts: [], contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
childCustomerCount: 0,
}),
}; };
} }
@@ -341,6 +500,7 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
country: payload.country, country: payload.country,
status: payload.status, status: payload.status,
notes: payload.notes, notes: payload.notes,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
isReseller: payload.isReseller ?? false, isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0, resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null, parentCustomerId: payload.parentCustomerId ?? null,
@@ -348,6 +508,10 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
currencyCode: payload.currencyCode ?? "USD", currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false, taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false, creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
}, },
}); });
@@ -362,17 +526,47 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
currencyCode: customer.currencyCode, currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt, taxExempt: customer.taxExempt,
creditHold: customer.creditHold, creditHold: customer.creditHold,
lifecycleStage: customer.lifecycleStage as CrmLifecycleStage,
preferredAccount: customer.preferredAccount,
strategicAccount: customer.strategicAccount,
requiresApproval: customer.requiresApproval,
blockedAccount: customer.blockedAccount,
contacts: [], contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
childCustomerCount: 0,
}),
}; };
} }
export async function listVendors(filters: CrmListFilters = {}) { export async function listVendors(filters: CrmListFilters = {}) {
const vendors = await prisma.vendor.findMany({ const vendors = await prisma.vendor.findMany({
where: buildWhereClause(filters), where: buildWhereClause(filters),
include: {
contactEntries: {
select: {
contactAt: true,
},
orderBy: {
contactAt: "desc",
},
take: 1,
},
_count: {
select: {
contactEntries: true,
contacts: true,
},
},
},
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
return vendors.map(mapSummary); const attachmentCountMap = await getAttachmentCountMap("crm-vendor", vendors.map((vendor) => vendor.id));
return vendors.map((vendor) => mapVendorSummary(vendor, attachmentCountMap.get(vendor.id) ?? 0));
} }
export async function listCustomerHierarchyOptions(excludeCustomerId?: string) { export async function listCustomerHierarchyOptions(excludeCustomerId?: string) {
@@ -422,7 +616,18 @@ export async function getVendorById(vendorId: string) {
}, },
}); });
return vendor ? mapVendorDetail(vendor) : null; if (!vendor) {
return null;
}
const attachmentCount = await prisma.fileAttachment.count({
where: {
ownerType: "crm-vendor",
ownerId: vendorId,
},
});
return mapVendorDetail(vendor, attachmentCount);
} }
export async function createVendor(payload: CrmRecordInput) { export async function createVendor(payload: CrmRecordInput) {
@@ -438,11 +643,16 @@ export async function createVendor(payload: CrmRecordInput) {
postalCode: payload.postalCode, postalCode: payload.postalCode,
country: payload.country, country: payload.country,
status: payload.status, status: payload.status,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
notes: payload.notes, notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null, paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD", currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false, taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false, creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
}, },
}); });
@@ -452,7 +662,18 @@ export async function createVendor(payload: CrmRecordInput) {
currencyCode: vendor.currencyCode, currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt, taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold, creditHold: vendor.creditHold,
lifecycleStage: vendor.lifecycleStage as CrmLifecycleStage,
preferredAccount: vendor.preferredAccount,
strategicAccount: vendor.strategicAccount,
requiresApproval: vendor.requiresApproval,
blockedAccount: vendor.blockedAccount,
contacts: [], contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
}),
}; };
} }
@@ -478,11 +699,16 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
postalCode: payload.postalCode, postalCode: payload.postalCode,
country: payload.country, country: payload.country,
status: payload.status, status: payload.status,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
notes: payload.notes, notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null, paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD", currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false, taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false, creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
}, },
}); });
@@ -492,7 +718,18 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
currencyCode: vendor.currencyCode, currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt, taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold, creditHold: vendor.creditHold,
lifecycleStage: vendor.lifecycleStage as CrmLifecycleStage,
preferredAccount: vendor.preferredAccount,
strategicAccount: vendor.strategicAccount,
requiresApproval: vendor.requiresApproval,
blockedAccount: vendor.blockedAccount,
contacts: [], contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
}),
}; };
} }

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import { fail, ok } from "../../lib/http.js"; import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js"; import { requirePermissions } from "../../lib/rbac.js";
import { createAttachment, getAttachmentContent, getAttachmentMetadata, listAttachmentsByOwner } from "./service.js"; import { createAttachment, deleteAttachment, getAttachmentContent, getAttachmentMetadata, listAttachmentsByOwner } from "./service.js";
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
@@ -71,3 +71,7 @@ filesRouter.get("/:id/content", requirePermissions([permissions.filesRead]), asy
response.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`); response.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
return response.send(content); return response.send(content);
}); });
filesRouter.delete("/:id", requirePermissions([permissions.filesWrite]), async (request, response) => {
return ok(response, await deleteAttachment(String(request.params.id)));
});

View File

@@ -78,3 +78,23 @@ export async function getAttachmentContent(id: string) {
content: await fs.readFile(path.join(paths.dataDir, file.relativePath)), content: await fs.readFile(path.join(paths.dataDir, file.relativePath)),
}; };
} }
export async function deleteAttachment(id: string) {
const file = await prisma.fileAttachment.findUniqueOrThrow({
where: { id },
});
try {
await fs.unlink(path.join(paths.dataDir, file.relativePath));
} catch (error: unknown) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
await prisma.fileAttachment.delete({
where: { id },
});
return mapFile(file);
}

View File

@@ -1,8 +1,10 @@
export const crmRecordStatuses = ["LEAD", "ACTIVE", "ON_HOLD", "INACTIVE"] as const; export const crmRecordStatuses = ["LEAD", "ACTIVE", "ON_HOLD", "INACTIVE"] as const;
export const crmContactEntryTypes = ["NOTE", "CALL", "EMAIL", "MEETING"] as const; export const crmContactEntryTypes = ["NOTE", "CALL", "EMAIL", "MEETING"] as const;
export const crmLifecycleStages = ["PROSPECT", "ACTIVE", "DORMANT", "CHURNED"] as const;
export type CrmRecordStatus = (typeof crmRecordStatuses)[number]; export type CrmRecordStatus = (typeof crmRecordStatuses)[number];
export type CrmContactEntryType = (typeof crmContactEntryTypes)[number]; export type CrmContactEntryType = (typeof crmContactEntryTypes)[number];
export type CrmLifecycleStage = (typeof crmLifecycleStages)[number];
export const crmContactRoles = ["PRIMARY", "PURCHASING", "AP", "SHIPPING", "ENGINEERING", "SALES", "OTHER"] as const; export const crmContactRoles = ["PRIMARY", "PURCHASING", "AP", "SHIPPING", "ENGINEERING", "SALES", "OTHER"] as const;
export type CrmContactRole = (typeof crmContactRoles)[number]; export type CrmContactRole = (typeof crmContactRoles)[number];
@@ -58,6 +60,14 @@ export interface CrmCustomerHierarchyOptionDto {
isReseller: boolean; isReseller: boolean;
} }
export interface CrmRecordRollupsDto {
lastContactAt: string | null;
contactHistoryCount: number;
contactCount: number;
attachmentCount: number;
childCustomerCount?: number;
}
export interface CrmRecordSummaryDto { export interface CrmRecordSummaryDto {
id: string; id: string;
name: string; name: string;
@@ -67,9 +77,15 @@ export interface CrmRecordSummaryDto {
state: string; state: string;
country: string; country: string;
status: CrmRecordStatus; status: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
preferredAccount?: boolean;
strategicAccount?: boolean;
requiresApproval?: boolean;
blockedAccount?: boolean;
isReseller?: boolean; isReseller?: boolean;
parentCustomerId?: string | null; parentCustomerId?: string | null;
parentCustomerName?: string | null; parentCustomerName?: string | null;
rollups?: CrmRecordRollupsDto;
updatedAt: string; updatedAt: string;
} }
@@ -90,6 +106,12 @@ export interface CrmRecordDetailDto extends CrmRecordSummaryDto {
taxExempt?: boolean; taxExempt?: boolean;
creditHold?: boolean; creditHold?: boolean;
contacts?: CrmContactDto[]; contacts?: CrmContactDto[];
lifecycleStage?: CrmLifecycleStage;
preferredAccount?: boolean;
strategicAccount?: boolean;
requiresApproval?: boolean;
blockedAccount?: boolean;
rollups?: CrmRecordRollupsDto;
} }
export interface CrmRecordInput { export interface CrmRecordInput {
@@ -111,6 +133,11 @@ export interface CrmRecordInput {
currencyCode?: string | null; currencyCode?: string | null;
taxExempt?: boolean; taxExempt?: boolean;
creditHold?: boolean; creditHold?: boolean;
lifecycleStage?: CrmLifecycleStage;
preferredAccount?: boolean;
strategicAccount?: boolean;
requiresApproval?: boolean;
blockedAccount?: boolean;
} }
export type CustomerSummaryDto = CrmRecordSummaryDto; export type CustomerSummaryDto = CrmRecordSummaryDto;