diff --git a/ROADMAP.md b/ROADMAP.md index 8aeb8ad..99003a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 search, filters, and persisted status tagging - 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 multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups - Theme persistence fixes and denser responsive workspace layouts - SVAR Gantt integration wrapper with demo planning data - 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 - 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 ### Phase 1: CRM and master data hardening - Better seed/bootstrap strategy for non-development environments -- Deeper CRM operational fields and lifecycle reporting -- Multi-contact records, commercial terms, and account-role expansion +- Additional CRM account-role depth if later sales/purchasing workflows need it +- More derived CRM rollups once quotes, orders, and purchasing documents exist ### 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 -1. CRM deeper operational fields, commercial terms, and lifecycle reporting -2. Inventory item and BOM data model -3. Sales order and quote foundation -4. Shipping module tied to sales orders -5. Live manufacturing gantt scheduling +1. Inventory item and BOM data model +2. Sales order and quote foundation +3. Shipping module tied to sales orders +4. Live manufacturing gantt scheduling +5. Expanded role and audit administration diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 7de4042..9a4a9aa 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -16,6 +16,7 @@ import type { CrmCustomerHierarchyOptionDto, CrmRecordDetailDto, CrmRecordInput, + CrmLifecycleStage, CrmRecordStatus, CrmRecordSummaryDto, } from "@mrp/shared/dist/crm/types.js"; @@ -121,12 +122,32 @@ export const api = { token ); }, - getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { + deleteAttachment(token: string, fileId: string) { + return request( + `/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( `/api/v1/crm/customers${buildQueryString({ q: filters?.q, status: filters?.status, + lifecycleStage: filters?.lifecycleStage, state: filters?.state, + flag: filters?.flag, })}`, undefined, token @@ -184,12 +205,23 @@ export const api = { 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( `/api/v1/crm/vendors${buildQueryString({ q: filters?.q, status: filters?.status, + lifecycleStage: filters?.lifecycleStage, state: filters?.state, + flag: filters?.flag, })}`, undefined, token diff --git a/client/src/modules/crm/CrmAttachmentsPanel.tsx b/client/src/modules/crm/CrmAttachmentsPanel.tsx index b3ae3a0..a0552d5 100644 --- a/client/src/modules/crm/CrmAttachmentsPanel.tsx +++ b/client/src/modules/crm/CrmAttachmentsPanel.tsx @@ -8,6 +8,7 @@ import { api, ApiError } from "../../lib/api"; interface CrmAttachmentsPanelProps { ownerType: string; ownerId: string; + onAttachmentCountChange?: (count: number) => void; } function formatFileSize(sizeBytes: number) { @@ -22,11 +23,12 @@ function formatFileSize(sizeBytes: number) { 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 [attachments, setAttachments] = useState([]); const [status, setStatus] = useState("Loading attachments..."); const [isUploading, setIsUploading] = useState(false); + const [deletingAttachmentId, setDeletingAttachmentId] = useState(null); const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false; @@ -40,6 +42,7 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP .getAttachments(token, ownerType, ownerId) .then((nextAttachments) => { setAttachments(nextAttachments); + onAttachmentCountChange?.(nextAttachments.length); setStatus( 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."; setStatus(message); }); - }, [canReadFiles, ownerId, ownerType, token]); + }, [canReadFiles, onAttachmentCountChange, ownerId, ownerType, token]); async function handleUpload(event: React.ChangeEvent) { const file = event.target.files?.[0]; @@ -61,7 +64,11 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP try { 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."); } catch (error: unknown) { 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 (
@@ -135,6 +166,16 @@ export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelP > Open + {canWriteFiles ? ( + + ) : null}
))} diff --git a/client/src/modules/crm/CrmDetailPage.tsx b/client/src/modules/crm/CrmDetailPage.tsx index 714b638..da37776 100644 --- a/client/src/modules/crm/CrmDetailPage.tsx +++ b/client/src/modules/crm/CrmDetailPage.tsx @@ -8,6 +8,7 @@ import { api, ApiError } from "../../lib/api"; import { CrmAttachmentsPanel } from "./CrmAttachmentsPanel"; import { CrmContactsPanel } from "./CrmContactsPanel"; import { CrmContactEntryForm } from "./CrmContactEntryForm"; +import { CrmLifecycleBadge } from "./CrmLifecycleBadge"; import { CrmContactTypeBadge } from "./CrmContactTypeBadge"; import { CrmStatusBadge } from "./CrmStatusBadge"; import { type CrmEntity, crmConfigs, emptyCrmContactEntryInput } from "./config"; @@ -78,6 +79,13 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) { contactHistory: [nextEntry, ...current.contactHistory].sort( (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 ); @@ -102,7 +110,10 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {

CRM Detail

{record.name}

- +
+ + {record.lifecycleStage ? : null} +

{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}. @@ -165,6 +176,18 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {

Created {new Date(record.createdAt).toLocaleDateString()}
+
+

Operational Flags

+
+ {record.preferredAccount ? Preferred : null} + {record.strategicAccount ? Strategic : null} + {record.requiresApproval ? Requires Approval : null} + {record.blockedAccount ? Blocked : null} + {!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? ( + Standard + ) : null} +
+
{entity === "customer" ? (

Reseller Profile

@@ -187,6 +210,26 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) { ) : null}
+
+
+

Last Contact

+
+ {record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"} +
+
+
+

Timeline Entries

+
{record.rollups?.contactHistoryCount ?? record.contactHistory.length}
+
+
+

Account Contacts

+
{record.rollups?.contactCount ?? record.contacts?.length ?? 0}
+
+
+

Attachments

+
{record.rollups?.attachmentCount ?? 0}
+
+
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (

Hierarchy

@@ -212,7 +255,21 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) { ownerId={record.id} contacts={record.contacts ?? []} 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 + ) } />
@@ -264,7 +321,26 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) { )}
- + + 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 + ) + } + />
); } diff --git a/client/src/modules/crm/CrmFormPage.tsx b/client/src/modules/crm/CrmFormPage.tsx index 068c4a2..115758f 100644 --- a/client/src/modules/crm/CrmFormPage.tsx +++ b/client/src/modules/crm/CrmFormPage.tsx @@ -63,6 +63,11 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) { currencyCode: record.currencyCode ?? "USD", taxExempt: record.taxExempt ?? 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, }); setStatus(`${config.singularLabel} record loaded.`); diff --git a/client/src/modules/crm/CrmLifecycleBadge.tsx b/client/src/modules/crm/CrmLifecycleBadge.tsx new file mode 100644 index 0000000..92b1c74 --- /dev/null +++ b/client/src/modules/crm/CrmLifecycleBadge.tsx @@ -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 = { + PROSPECT: "Prospect", + ACTIVE: "Active", + DORMANT: "Dormant", + CHURNED: "Churned", +}; + +export function CrmLifecycleBadge({ stage }: CrmLifecycleBadgeProps) { + return ( + + {labels[stage]} + + ); +} diff --git a/client/src/modules/crm/CrmListPage.tsx b/client/src/modules/crm/CrmListPage.tsx index acd14f2..ab8fd6d 100644 --- a/client/src/modules/crm/CrmListPage.tsx +++ b/client/src/modules/crm/CrmListPage.tsx @@ -1,12 +1,13 @@ 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 { Link } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; import { api, ApiError } from "../../lib/api"; +import { CrmLifecycleBadge } from "./CrmLifecycleBadge"; import { CrmStatusBadge } from "./CrmStatusBadge"; -import { crmStatusFilters, type CrmEntity, crmConfigs } from "./config"; +import { crmLifecycleFilters, crmOperationalFilters, crmStatusFilters, type CrmEntity, crmConfigs } from "./config"; interface CrmListPageProps { entity: CrmEntity; @@ -19,6 +20,10 @@ export function CrmListPage({ entity }: CrmListPageProps) { const [searchTerm, setSearchTerm] = useState(""); const [stateFilter, setStateFilter] = useState(""); 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 canManage = user?.permissions.includes(permissions.crmWrite) ?? false; @@ -32,6 +37,8 @@ export function CrmListPage({ entity }: CrmListPageProps) { q: searchTerm.trim() || undefined, state: stateFilter.trim() || undefined, 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); @@ -45,7 +52,7 @@ export function CrmListPage({ entity }: CrmListPageProps) { const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`; setStatus(message); }); - }, [config.collectionLabel, entity, searchTerm, stateFilter, statusFilter, token]); + }, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]); return (
@@ -66,7 +73,7 @@ export function CrmListPage({ entity }: CrmListPageProps) { ) : null} -
+
+ +
{status}
{records.length === 0 ? ( @@ -112,9 +149,10 @@ export function CrmListPage({ entity }: CrmListPageProps) { Name Status - Email - Phone - Location + Lifecycle + Operational + Activity + Contact Updated @@ -135,10 +173,31 @@ export function CrmListPage({ entity }: CrmListPageProps) { - {record.email} - {record.phone} - {record.city}, {record.state}, {record.country} + {record.lifecycleStage ? : null} + + +
+ {record.preferredAccount ? Preferred : null} + {record.strategicAccount ? Strategic : null} + {record.requiresApproval ? Approval : null} + {record.blockedAccount ? Blocked : null} + {!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? ( + Standard + ) : null} +
+ + +
{record.rollups?.contactHistoryCount ?? 0} timeline entries
+
{record.rollups?.attachmentCount ?? 0} attachments
+ {entity === "customer" ?
{record.rollups?.childCustomerCount ?? 0} child accounts
: null} + + +
{record.email}
+
+ {record.city}, {record.state}, {record.country} +
+
{record.phone}
{new Date(record.updatedAt).toLocaleDateString()} diff --git a/client/src/modules/crm/CrmRecordForm.tsx b/client/src/modules/crm/CrmRecordForm.tsx index 41071ed..a6a0b4e 100644 --- a/client/src/modules/crm/CrmRecordForm.tsx +++ b/client/src/modules/crm/CrmRecordForm.tsx @@ -1,6 +1,6 @@ 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"; const fields: Array<{ @@ -43,6 +43,20 @@ export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }: ))} + {entity === "customer" ? (
+
+ + + + +
{fields.map((field) => (