diff --git a/ROADMAP.md b/ROADMAP.md index cea218d..940827e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,9 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - CRM reference entities for customers and vendors - 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 +- Theme persistence fixes and denser responsive workspace layouts - 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 @@ -30,14 +33,12 @@ 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 contact history, shared attachments, and deeper operational metadata are not built yet +- CRM deeper operational metadata and lifecycle reporting are not built yet ## Planned feature phases ### Phase 1: CRM and master data hardening -- 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 @@ -87,13 +88,14 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Stronger validation and error reporting across all APIs - More automated tests for auth, settings, files, PDFs, and workflow modules - Better mobile behavior in module-level pages +- Ongoing responsive-density tuning for module-level layouts and data-entry screens - Consistent document-template system shared by sales, purchasing, and shipping - Clear upgrade path for future module additions without refactoring the app shell ## Near-term priority order -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 +1. CRM deeper operational fields 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 diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index a8c5c15..50fc0e3 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -108,6 +108,16 @@ export const api = { return response.blob(); }, + getAttachments(token: string, ownerType: string, ownerId: string) { + return request( + `/api/v1/files${buildQueryString({ + ownerType, + ownerId, + })}`, + undefined, + token + ); + }, getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { return request( `/api/v1/crm/customers${buildQueryString({ diff --git a/client/src/modules/crm/CrmAttachmentsPanel.tsx b/client/src/modules/crm/CrmAttachmentsPanel.tsx new file mode 100644 index 0000000..b3ae3a0 --- /dev/null +++ b/client/src/modules/crm/CrmAttachmentsPanel.tsx @@ -0,0 +1,145 @@ +import type { FileAttachmentDto } from "@mrp/shared"; +import { permissions } from "@mrp/shared"; +import { useEffect, useState } from "react"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; + +interface CrmAttachmentsPanelProps { + ownerType: string; + ownerId: string; +} + +function formatFileSize(sizeBytes: number) { + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB`; + } + + return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function CrmAttachmentsPanel({ ownerType, ownerId }: CrmAttachmentsPanelProps) { + const { token, user } = useAuth(); + const [attachments, setAttachments] = useState([]); + const [status, setStatus] = useState("Loading attachments..."); + const [isUploading, setIsUploading] = useState(false); + + const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; + const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false; + + useEffect(() => { + if (!token || !canReadFiles) { + return; + } + + api + .getAttachments(token, ownerType, ownerId) + .then((nextAttachments) => { + setAttachments(nextAttachments); + setStatus( + nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.` + ); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load attachments."; + setStatus(message); + }); + }, [canReadFiles, ownerId, ownerType, token]); + + async function handleUpload(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file || !token || !canWriteFiles) { + return; + } + + setIsUploading(true); + setStatus("Uploading attachment..."); + + try { + const attachment = await api.uploadFile(token, file, ownerType, ownerId); + setAttachments((current) => [attachment, ...current]); + setStatus("Attachment uploaded."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to upload attachment."; + setStatus(message); + } finally { + setIsUploading(false); + event.target.value = ""; + } + } + + async function handleOpen(attachment: FileAttachmentDto) { + if (!token) { + return; + } + + try { + const blob = await api.getFileContentBlob(token, attachment.id); + const objectUrl = window.URL.createObjectURL(blob); + window.open(objectUrl, "_blank", "noopener,noreferrer"); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to open attachment."; + setStatus(message); + } + } + + return ( +
+
+
+

Attachments

+

Shared files

+

+ Drawings, customer markups, vendor documents, and other reference files linked to this record. +

+
+ {canWriteFiles ? ( + + ) : null} +
+
{status}
+ {!canReadFiles ? ( +
+ You do not have permission to view file attachments. +
+ ) : attachments.length === 0 ? ( +
+ No attachments have been added to this record yet. +
+ ) : ( +
+ {attachments.map((attachment) => ( +
+
+

{attachment.originalName}

+

+ {attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()} +

+
+
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/modules/crm/CrmDetailPage.tsx b/client/src/modules/crm/CrmDetailPage.tsx index 5cdae57..c119beb 100644 --- a/client/src/modules/crm/CrmDetailPage.tsx +++ b/client/src/modules/crm/CrmDetailPage.tsx @@ -5,6 +5,7 @@ import { Link, useParams } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; import { api, ApiError } from "../../lib/api"; +import { CrmAttachmentsPanel } from "./CrmAttachmentsPanel"; import { CrmContactEntryForm } from "./CrmContactEntryForm"; import { CrmContactTypeBadge } from "./CrmContactTypeBadge"; import { CrmStatusBadge } from "./CrmStatusBadge"; @@ -205,6 +206,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) { )} + ); } diff --git a/client/src/modules/crm/config.ts b/client/src/modules/crm/config.ts index 6751146..f7239d5 100644 --- a/client/src/modules/crm/config.ts +++ b/client/src/modules/crm/config.ts @@ -14,6 +14,7 @@ interface CrmModuleConfig { collectionLabel: string; singularLabel: string; routeBase: string; + fileOwnerType: string; emptyMessage: string; } @@ -23,6 +24,7 @@ export const crmConfigs: Record = { collectionLabel: "Customers", singularLabel: "Customer", routeBase: "/crm/customers", + fileOwnerType: "crm-customer", emptyMessage: "No customer accounts have been added yet.", }, vendor: { @@ -30,6 +32,7 @@ export const crmConfigs: Record = { collectionLabel: "Vendors", singularLabel: "Vendor", routeBase: "/crm/vendors", + fileOwnerType: "crm-vendor", emptyMessage: "No vendor records have been added yet.", }, }; diff --git a/server/src/modules/files/router.ts b/server/src/modules/files/router.ts index adbb77d..b240260 100644 --- a/server/src/modules/files/router.ts +++ b/server/src/modules/files/router.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { fail, ok } from "../../lib/http.js"; import { requirePermissions } from "../../lib/rbac.js"; -import { createAttachment, getAttachmentContent, getAttachmentMetadata } from "./service.js"; +import { createAttachment, getAttachmentContent, getAttachmentMetadata, listAttachmentsByOwner } from "./service.js"; const upload = multer({ storage: multer.memoryStorage(), @@ -19,8 +19,22 @@ const uploadSchema = z.object({ ownerId: z.string().min(1), }); +const listSchema = z.object({ + ownerType: z.string().min(1), + ownerId: z.string().min(1), +}); + export const filesRouter = Router(); +filesRouter.get("/", requirePermissions([permissions.filesRead]), async (request, response) => { + const parsed = listSchema.safeParse(request.query); + if (!parsed.success) { + return fail(response, 400, "INVALID_INPUT", "ownerType and ownerId are required."); + } + + return ok(response, await listAttachmentsByOwner(parsed.data.ownerType, parsed.data.ownerId)); +}); + filesRouter.post( "/upload", requirePermissions([permissions.filesWrite]), diff --git a/server/src/modules/files/service.ts b/server/src/modules/files/service.ts index 93d0857..4841fdb 100644 --- a/server/src/modules/files/service.ts +++ b/server/src/modules/files/service.ts @@ -56,6 +56,18 @@ export async function getAttachmentMetadata(id: string) { ); } +export async function listAttachmentsByOwner(ownerType: string, ownerId: string) { + const files = await prisma.fileAttachment.findMany({ + where: { + ownerType, + ownerId, + }, + orderBy: [{ createdAt: "desc" }, { originalName: "asc" }], + }); + + return files.map(mapFile); +} + export async function getAttachmentContent(id: string) { const file = await prisma.fileAttachment.findUniqueOrThrow({ where: { id }, @@ -66,4 +78,3 @@ export async function getAttachmentContent(id: string) { content: await fs.readFile(path.join(paths.dataDir, file.relativePath)), }; } -