diff --git a/README.md b/README.md index 772f3dd..03f00ae 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,22 @@ Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment. +Current foundation scope includes: + +- authentication and RBAC +- company branding and theme settings +- CRM customers and vendors with create/edit/detail workflows +- CRM search, filtering, status tagging, and reseller hierarchy +- CRM contact history, account contacts, and shared attachments +- file storage and PDF rendering + ## Workspace - `client`: React, Vite, Tailwind frontend - `server`: Express API, Prisma, auth/RBAC, file storage, PDF rendering - `shared`: shared TypeScript contracts and constants -## Local development +## Local Development 1. Use Node.js 22 for local development if you want Prisma migration commands to behave the same way as Docker. 2. Install dependencies with `npm.cmd install`. @@ -45,22 +54,52 @@ docker build --build-arg NODE_VERSION=22 -t mrp-codex . The container startup script runs `npx prisma migrate deploy` automatically before launching the server. -## Persistence and backup +This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM migrations on Windows. + +## Persistence And Backup - SQLite database path: `/app/data/prisma/app.db` - Uploaded files: `/app/data/uploads` - Backup the entire mounted `/app/data` volume to preserve both records and attachments. +## CRM + +The current CRM foundation supports: + +- customer and vendor list, detail, create, and edit flows +- search by text plus status and state/province filters +- customer reseller flag, reseller discount, and parent-child hierarchy +- contact-history timeline entries for notes, calls, emails, and meetings +- multiple account contacts with role and primary-contact tracking +- shared file attachments on customer and vendor records +- commercial terms fields including payment terms, currency, tax exempt, and credit hold + +Recent CRM features depend on the committed Prisma migrations being applied. If you update the code and do not run migrations, the UI may render fields that are not yet present in the database. + ## Branding Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand. +Logo uploads are stored through the authenticated file pipeline and are rendered back into the settings UI through an authenticated blob fetch, so image preview works after save and refresh. + ## Migrations - Create a local migration: `npm run prisma:migrate` - Apply committed migrations in production: `npm run prisma:deploy` - If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth. -## PDF generation +As of March 14, 2026, the latest committed CRM migrations include: + +- CRM status and list filters +- CRM contact-history timeline +- reseller hierarchy and reseller discount support +- CRM commercial terms and account contacts + +## UI Notes + +- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation. +- The shell layout is tuned for wider desktop use than the original foundation build, but the client build still emits a Vite chunk-size warning because the app has not been code-split yet. + +## PDF Generation Puppeteer is used by the backend to render HTML templates into professional PDFs. The Docker image includes Chromium runtime dependencies required for headless execution. diff --git a/ROADMAP.md b/ROADMAP.md index 940827e..8aeb8ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - 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 reseller hierarchy, parent-child customer structure, and reseller discount support - Theme persistence fixes and denser responsive workspace layouts - SVAR Gantt integration wrapper with demo planning data - Multi-stage Docker packaging and migration-aware entrypoint @@ -33,7 +34,7 @@ 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 and lifecycle reporting are not built yet +- CRM deeper operational metadata, lifecycle reporting, and richer multi-contact/commercial terms are not built yet ## Planned feature phases @@ -41,6 +42,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Better seed/bootstrap strategy for non-development environments - Deeper CRM operational fields and lifecycle reporting +- Multi-contact records, commercial terms, and account-role expansion ### Phase 2: Inventory and manufacturing core @@ -94,7 +96,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni ## Near-term priority order -1. CRM deeper operational fields and lifecycle reporting +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 diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 50fc0e3..7de4042 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -9,8 +9,11 @@ import type { LoginResponse, } from "@mrp/shared"; import type { + CrmContactDto, + CrmContactInput, CrmContactEntryDto, CrmContactEntryInput, + CrmCustomerHierarchyOptionDto, CrmRecordDetailDto, CrmRecordInput, CrmRecordStatus, @@ -132,6 +135,15 @@ export const api = { getCustomer(token: string, customerId: string) { return request(`/api/v1/crm/customers/${customerId}`, undefined, token); }, + getCustomerHierarchyOptions(token: string, excludeCustomerId?: string) { + return request( + `/api/v1/crm/customers/hierarchy-options${buildQueryString({ + excludeCustomerId, + })}`, + undefined, + token + ); + }, createCustomer(token: string, payload: CrmRecordInput) { return request( "/api/v1/crm/customers", @@ -162,6 +174,16 @@ export const api = { token ); }, + createCustomerContact(token: string, customerId: string, payload: CrmContactInput) { + return request( + `/api/v1/crm/customers/${customerId}/contacts`, + { + method: "POST", + body: JSON.stringify(payload), + }, + token + ); + }, getVendors(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { return request( `/api/v1/crm/vendors${buildQueryString({ @@ -206,6 +228,16 @@ export const api = { token ); }, + createVendorContact(token: string, vendorId: string, payload: CrmContactInput) { + return request( + `/api/v1/crm/vendors/${vendorId}/contacts`, + { + method: "POST", + 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/modules/crm/CrmContactsPanel.tsx b/client/src/modules/crm/CrmContactsPanel.tsx new file mode 100644 index 0000000..4131918 --- /dev/null +++ b/client/src/modules/crm/CrmContactsPanel.tsx @@ -0,0 +1,153 @@ +import type { CrmContactDto, CrmContactInput } from "@mrp/shared/dist/crm/types.js"; +import { permissions } from "@mrp/shared"; +import { useState } from "react"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; +import { crmContactRoleOptions, emptyCrmContactInput, type CrmEntity } from "./config"; + +interface CrmContactsPanelProps { + entity: CrmEntity; + ownerId: string; + contacts: CrmContactDto[]; + onContactsChange: (contacts: CrmContactDto[]) => void; +} + +export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }: CrmContactsPanelProps) { + const { token, user } = useAuth(); + const [form, setForm] = useState(emptyCrmContactInput); + const [status, setStatus] = useState("Add account contacts for purchasing, AP, shipping, and engineering."); + const [isSaving, setIsSaving] = useState(false); + + const canManage = user?.permissions.includes(permissions.crmWrite) ?? false; + + function updateField(key: Key, value: CrmContactInput[Key]) { + setForm((current) => ({ ...current, [key]: value })); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!token) { + return; + } + + setIsSaving(true); + setStatus("Saving contact..."); + + try { + const nextContact = + entity === "customer" + ? await api.createCustomerContact(token, ownerId, form) + : await api.createVendorContact(token, ownerId, form); + + onContactsChange( + [nextContact, ...contacts] + .sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary) || left.fullName.localeCompare(right.fullName)) + ); + setForm({ + ...emptyCrmContactInput, + isPrimary: false, + }); + setStatus("Contact added."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to save CRM contact."; + setStatus(message); + } finally { + setIsSaving(false); + } + } + + return ( +
+

Contacts

+

People on this account

+
+ {contacts.length === 0 ? ( +
+ No contacts have been added yet. +
+ ) : ( + contacts.map((contact) => ( +
+
+
+
+ {contact.fullName} {contact.isPrimary ? • Primary : null} +
+
{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}
+
+
+
{contact.email}
+
{contact.phone}
+
+
+
+ )) + )} +
+ {canManage ? ( +
+
+ + + + +
+ +
+ {status} + +
+
+ ) : null} +
+ ); +} diff --git a/client/src/modules/crm/CrmDetailPage.tsx b/client/src/modules/crm/CrmDetailPage.tsx index c119beb..714b638 100644 --- a/client/src/modules/crm/CrmDetailPage.tsx +++ b/client/src/modules/crm/CrmDetailPage.tsx @@ -1,11 +1,12 @@ import { permissions } from "@mrp/shared"; -import type { CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js"; +import type { CrmContactDto, CrmContactEntryInput, 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 { CrmAttachmentsPanel } from "./CrmAttachmentsPanel"; +import { CrmContactsPanel } from "./CrmContactsPanel"; import { CrmContactEntryForm } from "./CrmContactEntryForm"; import { CrmContactTypeBadge } from "./CrmContactTypeBadge"; import { CrmStatusBadge } from "./CrmStatusBadge"; @@ -145,6 +146,15 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) { .join("\n")} +
+
Commercial terms
+
+
Payment terms: {record.paymentTerms ?? "Not set"}
+
Currency: {record.currencyCode ?? "USD"}
+
Tax exempt: {record.taxExempt ? "Yes" : "No"}
+
Credit hold: {record.creditHold ? "Yes" : "No"}
+
+
@@ -155,8 +165,56 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
Created {new Date(record.createdAt).toLocaleDateString()}
+ {entity === "customer" ? ( +
+

Reseller Profile

+
+
+ Account type:{" "} + {record.isReseller ? "Reseller" : record.parentCustomerName ? "End customer" : "Direct customer"} +
+
+ Discount: {(record.resellerDiscountPercent ?? 0).toFixed(2)}% +
+
+ Parent reseller: {record.parentCustomerName ?? "None"} +
+
+ Child accounts: {record.childCustomers?.length ?? 0} +
+
+
+ ) : null}
+ {entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? ( +
+

Hierarchy

+

End customers under this reseller

+
+ {record.childCustomers?.map((child) => ( + +
{child.name}
+
+ +
+ + ))} +
+
+ ) : null} + + setRecord((current) => (current ? { ...current, contacts } : current)) + } + />
{canManage ? (
diff --git a/client/src/modules/crm/CrmFormPage.tsx b/client/src/modules/crm/CrmFormPage.tsx index f7c2715..068c4a2 100644 --- a/client/src/modules/crm/CrmFormPage.tsx +++ b/client/src/modules/crm/CrmFormPage.tsx @@ -1,4 +1,4 @@ -import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js"; +import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js"; import { useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; @@ -19,11 +19,23 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) { const recordId = entity === "customer" ? customerId : vendorId; const config = crmConfigs[entity]; const [form, setForm] = useState(emptyCrmRecordInput); + const [hierarchyOptions, setHierarchyOptions] = useState([]); const [status, setStatus] = useState( mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...` ); const [isSaving, setIsSaving] = useState(false); + useEffect(() => { + if (entity !== "customer" || !token) { + return; + } + + api + .getCustomerHierarchyOptions(token, mode === "edit" ? recordId : undefined) + .then(setHierarchyOptions) + .catch(() => setHierarchyOptions([])); + }, [entity, mode, recordId, token]); + useEffect(() => { if (mode !== "edit" || !token || !recordId) { return; @@ -44,6 +56,13 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) { postalCode: record.postalCode, country: record.country, status: record.status, + isReseller: record.isReseller ?? false, + resellerDiscountPercent: record.resellerDiscountPercent ?? 0, + parentCustomerId: record.parentCustomerId ?? null, + paymentTerms: record.paymentTerms ?? "Net 30", + currencyCode: record.currencyCode ?? "USD", + taxExempt: record.taxExempt ?? false, + creditHold: record.creditHold ?? false, notes: record.notes, }); setStatus(`${config.singularLabel} record loaded.`); @@ -107,7 +126,7 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
- +
{status}