From 9c8298c5e3013590e2aed9fbbe5fd5821be86de1 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 14 Mar 2026 16:08:29 -0500 Subject: [PATCH] CRM --- ROADMAP.md | 20 +-- client/src/lib/api.ts | 88 +++++++++- client/src/main.tsx | 14 +- client/src/modules/crm/CrmDetailPage.tsx | 113 +++++++++++++ client/src/modules/crm/CrmFormPage.tsx | 124 ++++++++++++++ client/src/modules/crm/CrmListPage.tsx | 146 +++++++++++++++++ client/src/modules/crm/CrmRecordForm.tsx | 63 ++++++++ client/src/modules/crm/CrmStatusBadge.tsx | 13 ++ client/src/modules/crm/CustomersPage.tsx | 45 +----- client/src/modules/crm/VendorsPage.tsx | 33 +--- client/src/modules/crm/config.ts | 63 ++++++++ .../migration.sql | 7 + server/prisma/schema.prisma | 2 + server/src/modules/crm/router.ts | 151 +++++++++++++++++- server/src/modules/crm/service.ts | 149 ++++++++++++++++- shared/src/crm/types.ts | 45 ++++++ shared/src/index.ts | 1 + 17 files changed, 975 insertions(+), 102 deletions(-) create mode 100644 client/src/modules/crm/CrmDetailPage.tsx create mode 100644 client/src/modules/crm/CrmFormPage.tsx create mode 100644 client/src/modules/crm/CrmListPage.tsx create mode 100644 client/src/modules/crm/CrmRecordForm.tsx create mode 100644 client/src/modules/crm/CrmStatusBadge.tsx create mode 100644 client/src/modules/crm/config.ts create mode 100644 server/prisma/migrations/20260314200000_crm_status_and_filters/migration.sql create mode 100644 shared/src/crm/types.ts diff --git a/ROADMAP.md b/ROADMAP.md index 65cd0bd..cea218d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,26 +19,27 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Local file attachment storage under `/app/data/uploads` - Puppeteer PDF service foundation with branded company-profile preview - CRM reference entities for customers and vendors +- CRM customer and vendor create/edit/detail workflows +- CRM search, filters, and persisted status tagging - SVAR Gantt integration wrapper with demo planning data - Multi-stage Docker packaging and migration-aware entrypoint +- Docker image validated locally with successful app startup and login flow - Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md` ### Current known gaps in the foundation -- Docker runtime has been authored but not validated in this environment because the local Docker daemon was unavailable - 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 is currently read-focused and seeded; create/update/detail workflows still need to be built +- CRM contact history, shared attachments, and deeper operational metadata are not built yet ## Planned feature phases ### Phase 1: CRM and master data hardening -- Customer and vendor create/edit/detail pages -- Search, filters, and status tagging - Contact history and internal notes - Shared attachment support on CRM entities - Better seed/bootstrap strategy for non-development environments +- Deeper CRM operational fields and lifecycle reporting ### Phase 2: Inventory and manufacturing core @@ -91,9 +92,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni ## Near-term priority order -1. CRM detail and edit workflows -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. CRM contact history and internal notes +2. CRM shared attachments and operational metadata +3. Inventory item and BOM data model +4. Sales order and quote foundation +5. Shipping module tied to sales orders diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 769d295..6c80da7 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -8,6 +8,12 @@ import type { LoginRequest, LoginResponse, } from "@mrp/shared"; +import type { + CrmRecordDetailDto, + CrmRecordInput, + CrmRecordStatus, + CrmRecordSummaryDto, +} from "@mrp/shared/dist/crm/types.js"; export class ApiError extends Error { constructor(message: string, public readonly code: string) { @@ -33,6 +39,18 @@ async function request(input: string, init?: RequestInit, token?: string): Pr return json.data; } +function buildQueryString(params: Record) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) { + searchParams.set(key, value); + } + } + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ""; +} + export const api = { login(payload: LoginRequest) { return request("/api/v1/auth/login", { @@ -75,11 +93,73 @@ export const api = { } return json.data; }, - getCustomers(token: string) { - return request>>("/api/v1/crm/customers", undefined, token); + getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { + return request( + `/api/v1/crm/customers${buildQueryString({ + q: filters?.q, + status: filters?.status, + state: filters?.state, + })}`, + undefined, + token + ); }, - getVendors(token: string) { - return request>>("/api/v1/crm/vendors", undefined, token); + getCustomer(token: string, customerId: string) { + return request(`/api/v1/crm/customers/${customerId}`, undefined, token); + }, + createCustomer(token: string, payload: CrmRecordInput) { + return request( + "/api/v1/crm/customers", + { + method: "POST", + body: JSON.stringify(payload), + }, + token + ); + }, + updateCustomer(token: string, customerId: string, payload: CrmRecordInput) { + return request( + `/api/v1/crm/customers/${customerId}`, + { + method: "PUT", + body: JSON.stringify(payload), + }, + token + ); + }, + getVendors(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { + return request( + `/api/v1/crm/vendors${buildQueryString({ + q: filters?.q, + status: filters?.status, + state: filters?.state, + })}`, + undefined, + token + ); + }, + getVendor(token: string, vendorId: string) { + return request(`/api/v1/crm/vendors/${vendorId}`, undefined, token); + }, + createVendor(token: string, payload: CrmRecordInput) { + return request( + "/api/v1/crm/vendors", + { + method: "POST", + body: JSON.stringify(payload), + }, + token + ); + }, + updateVendor(token: string, vendorId: string, payload: CrmRecordInput) { + return request( + `/api/v1/crm/vendors/${vendorId}`, + { + method: "PUT", + body: JSON.stringify(payload), + }, + token + ); }, getGanttDemo(token: string) { return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token); diff --git a/client/src/main.tsx b/client/src/main.tsx index 676f761..c5dcad4 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -10,6 +10,8 @@ import { AuthProvider } from "./auth/AuthProvider"; import { DashboardPage } from "./modules/dashboard/DashboardPage"; import { LoginPage } from "./modules/login/LoginPage"; import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage"; +import { CrmDetailPage } from "./modules/crm/CrmDetailPage"; +import { CrmFormPage } from "./modules/crm/CrmFormPage"; import { CustomersPage } from "./modules/crm/CustomersPage"; import { VendorsPage } from "./modules/crm/VendorsPage"; import { GanttPage } from "./modules/gantt/GanttPage"; @@ -35,7 +37,18 @@ const router = createBrowserRouter([ element: , children: [ { path: "/crm/customers", element: }, + { path: "/crm/customers/:customerId", element: }, { path: "/crm/vendors", element: }, + { path: "/crm/vendors/:vendorId", element: }, + ], + }, + { + element: , + children: [ + { path: "/crm/customers/new", element: }, + { path: "/crm/customers/:customerId/edit", element: }, + { path: "/crm/vendors/new", element: }, + { path: "/crm/vendors/:vendorId/edit", element: }, ], }, { @@ -60,4 +73,3 @@ ReactDOM.createRoot(document.getElementById("root")!).render( ); - diff --git a/client/src/modules/crm/CrmDetailPage.tsx b/client/src/modules/crm/CrmDetailPage.tsx new file mode 100644 index 0000000..75155f1 --- /dev/null +++ b/client/src/modules/crm/CrmDetailPage.tsx @@ -0,0 +1,113 @@ +import { permissions } from "@mrp/shared"; +import type { CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js"; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; +import { CrmStatusBadge } from "./CrmStatusBadge"; +import { type CrmEntity, crmConfigs } from "./config"; + +interface CrmDetailPageProps { + entity: CrmEntity; +} + +export function CrmDetailPage({ entity }: CrmDetailPageProps) { + const { token, user } = useAuth(); + const { customerId, vendorId } = useParams(); + const recordId = entity === "customer" ? customerId : vendorId; + const config = crmConfigs[entity]; + const [record, setRecord] = useState(null); + const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`); + + const canManage = user?.permissions.includes(permissions.crmWrite) ?? false; + + useEffect(() => { + if (!token || !recordId) { + return; + } + + const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId); + + loadRecord + .then((nextRecord) => { + setRecord(nextRecord); + setStatus(`${config.singularLabel} record loaded.`); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`; + setStatus(message); + }); + }, [config.singularLabel, entity, recordId, token]); + + if (!record) { + return
{status}
; + } + + return ( +
+
+
+
+

CRM Detail

+

{record.name}

+
+ +
+

+ {config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}. +

+
+
+ + Back to {config.collectionLabel.toLowerCase()} + + {canManage ? ( + + Edit {config.singularLabel.toLowerCase()} + + ) : null} +
+
+
+
+
+

Contact

+
+
+
Email
+
{record.email}
+
+
+
Phone
+
{record.phone}
+
+
+
Address
+
+ {[record.addressLine1, record.addressLine2, `${record.city}, ${record.state} ${record.postalCode}`, record.country] + .filter(Boolean) + .join("\n")} +
+
+
+
+
+

Notes

+

+ {record.notes || "No internal notes recorded for this account yet."} +

+
+ Created {new Date(record.createdAt).toLocaleDateString()} +
+
+
+
+ ); +} diff --git a/client/src/modules/crm/CrmFormPage.tsx b/client/src/modules/crm/CrmFormPage.tsx new file mode 100644 index 0000000..569a325 --- /dev/null +++ b/client/src/modules/crm/CrmFormPage.tsx @@ -0,0 +1,124 @@ +import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js"; +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; +import { CrmRecordForm } from "./CrmRecordForm"; +import { type CrmEntity, crmConfigs, emptyCrmRecordInput } from "./config"; + +interface CrmFormPageProps { + entity: CrmEntity; + mode: "create" | "edit"; +} + +export function CrmFormPage({ entity, mode }: CrmFormPageProps) { + const navigate = useNavigate(); + const { token } = useAuth(); + const { customerId, vendorId } = useParams(); + const recordId = entity === "customer" ? customerId : vendorId; + const config = crmConfigs[entity]; + const [form, setForm] = useState(emptyCrmRecordInput); + const [status, setStatus] = useState( + mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...` + ); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (mode !== "edit" || !token || !recordId) { + return; + } + + const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId); + + loadRecord + .then((record) => { + setForm({ + name: record.name, + email: record.email, + phone: record.phone, + addressLine1: record.addressLine1, + addressLine2: record.addressLine2, + city: record.city, + state: record.state, + postalCode: record.postalCode, + country: record.country, + status: record.status, + notes: record.notes, + }); + setStatus(`${config.singularLabel} record loaded.`); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`; + setStatus(message); + }); + }, [config.singularLabel, entity, mode, recordId, token]); + + function updateField(key: Key, value: CrmRecordInput[Key]) { + setForm((current: CrmRecordInput) => ({ ...current, [key]: value })); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!token) { + return; + } + + setIsSaving(true); + setStatus(`Saving ${config.singularLabel.toLowerCase()}...`); + + try { + const savedRecord = + entity === "customer" + ? mode === "create" + ? await api.createCustomer(token, form) + : await api.updateCustomer(token, recordId ?? "", form) + : mode === "create" + ? await api.createVendor(token, form) + : await api.updateVendor(token, recordId ?? "", form); + + navigate(`${config.routeBase}/${savedRecord.id}`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`; + setStatus(message); + setIsSaving(false); + } + } + + return ( +
+
+
+
+

CRM Editor

+

+ {mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`} +

+

+ Capture the operational contact and address details needed for quoting, purchasing, and shipping workflows. +

+
+ + Cancel + +
+
+
+ +
+ {status} + +
+
+
+ ); +} diff --git a/client/src/modules/crm/CrmListPage.tsx b/client/src/modules/crm/CrmListPage.tsx new file mode 100644 index 0000000..719e9e8 --- /dev/null +++ b/client/src/modules/crm/CrmListPage.tsx @@ -0,0 +1,146 @@ +import { permissions } from "@mrp/shared"; +import type { 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 { CrmStatusBadge } from "./CrmStatusBadge"; +import { crmStatusFilters, type CrmEntity, crmConfigs } from "./config"; + +interface CrmListPageProps { + entity: CrmEntity; +} + +export function CrmListPage({ entity }: CrmListPageProps) { + const { token, user } = useAuth(); + const config = crmConfigs[entity]; + const [records, setRecords] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [stateFilter, setStateFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState<"ALL" | CrmRecordStatus>("ALL"); + const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`); + + const canManage = user?.permissions.includes(permissions.crmWrite) ?? false; + + useEffect(() => { + if (!token) { + return; + } + + const filters = { + q: searchTerm.trim() || undefined, + state: stateFilter.trim() || undefined, + status: statusFilter === "ALL" ? undefined : statusFilter, + }; + + const loadRecords = entity === "customer" ? api.getCustomers(token, filters) : api.getVendors(token, filters); + + loadRecords + .then((nextRecords) => { + setRecords(nextRecords); + setStatus(`${nextRecords.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`; + setStatus(message); + }); + }, [config.collectionLabel, entity, searchTerm, stateFilter, statusFilter, token]); + + return ( +
+
+
+

CRM

+

{config.collectionLabel}

+

+ Operational contact records, shipping addresses, and account context for active {config.collectionLabel.toLowerCase()}. +

+
+ {canManage ? ( + + New {config.singularLabel.toLowerCase()} + + ) : null} +
+
+ + + +
+
{status}
+ {records.length === 0 ? ( +
+ {config.emptyMessage} +
+ ) : ( +
+ + + + + + + + + + + + + {records.map((record) => ( + + + + + + + + + ))} + +
NameStatusEmailPhoneLocationUpdated
+ + {record.name} + + + + {record.email}{record.phone} + {record.city}, {record.state}, {record.country} + {new Date(record.updatedAt).toLocaleDateString()}
+
+ )} +
+ ); +} diff --git a/client/src/modules/crm/CrmRecordForm.tsx b/client/src/modules/crm/CrmRecordForm.tsx new file mode 100644 index 0000000..b017fef --- /dev/null +++ b/client/src/modules/crm/CrmRecordForm.tsx @@ -0,0 +1,63 @@ +import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js"; + +import { crmStatusOptions } from "./config"; + +const fields: Array<{ key: keyof CrmRecordInput; label: string; type?: string }> = [ + { key: "name", label: "Company name" }, + { key: "email", label: "Email", type: "email" }, + { key: "phone", label: "Phone" }, + { key: "addressLine1", label: "Address line 1" }, + { key: "addressLine2", label: "Address line 2" }, + { key: "city", label: "City" }, + { key: "state", label: "State / Province" }, + { key: "postalCode", label: "Postal code" }, + { key: "country", label: "Country" }, +]; + +interface CrmRecordFormProps { + form: CrmRecordInput; + onChange: (key: Key, value: CrmRecordInput[Key]) => void; +} + +export function CrmRecordForm({ form, onChange }: CrmRecordFormProps) { + return ( + <> + +
+ {fields.map((field) => ( + + ))} +
+