This commit is contained in:
2026-03-14 17:32:00 -05:00
parent b776ec3381
commit f1fd2ed979
7 changed files with 197 additions and 10 deletions

View File

@@ -21,6 +21,9 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- CRM reference entities for customers and vendors - CRM reference entities for customers and vendors
- 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 shared file attachments on customer and vendor records
- 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
- Docker image validated locally with successful app startup and login flow - 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 - 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 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 ## Planned feature phases
### Phase 1: CRM and master data hardening ### 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 - Better seed/bootstrap strategy for non-development environments
- Deeper CRM operational fields and lifecycle reporting - 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 - Stronger validation and error reporting across all APIs
- More automated tests for auth, settings, files, PDFs, and workflow modules - More automated tests for auth, settings, files, PDFs, and workflow modules
- Better mobile behavior in module-level pages - 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 - Consistent document-template system shared by sales, purchasing, and shipping
- Clear upgrade path for future module additions without refactoring the app shell - Clear upgrade path for future module additions without refactoring the app shell
## Near-term priority order ## Near-term priority order
1. CRM contact history and internal notes 1. CRM deeper operational fields and lifecycle reporting
2. CRM shared attachments and operational metadata 2. Inventory item and BOM data model
3. Inventory item and BOM data model 3. Sales order and quote foundation
4. Sales order and quote foundation 4. Shipping module tied to sales orders
5. Shipping module tied to sales orders 5. Live manufacturing gantt scheduling

View File

@@ -108,6 +108,16 @@ export const api = {
return response.blob(); return response.blob();
}, },
getAttachments(token: string, ownerType: string, ownerId: string) {
return request<FileAttachmentDto[]>(
`/api/v1/files${buildQueryString({
ownerType,
ownerId,
})}`,
undefined,
token
);
},
getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) { getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) {
return request<CrmRecordSummaryDto[]>( return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/customers${buildQueryString({ `/api/v1/crm/customers${buildQueryString({

View File

@@ -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<FileAttachmentDto[]>([]);
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<HTMLInputElement>) {
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 (
<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>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Attachments</p>
<h4 className="mt-3 text-xl font-bold text-text">Shared files</h4>
<p className="mt-2 text-sm text-muted">
Drawings, customer markups, vendor documents, and other reference files linked to this record.
</p>
</div>
{canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white">
{isUploading ? "Uploading..." : "Upload file"}
<input className="hidden" type="file" onChange={handleUpload} disabled={isUploading} />
</label>
) : null}
</div>
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-4 py-3 text-sm text-muted">{status}</div>
{!canReadFiles ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-6 py-12 text-center text-sm text-muted">
You do not have permission to view file attachments.
</div>
) : attachments.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-6 py-12 text-center text-sm text-muted">
No attachments have been added to this record yet.
</div>
) : (
<div className="mt-5 space-y-3">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex flex-col gap-3 rounded-3xl border border-line/70 bg-page/60 px-4 py-4 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
<p className="mt-1 text-xs text-muted">
{attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()}
</p>
</div>
<div className="flex shrink-0 gap-3">
<button
type="button"
onClick={() => handleOpen(attachment)}
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text"
>
Open
</button>
</div>
</div>
))}
</div>
)}
</article>
);
}

View File

@@ -5,6 +5,7 @@ import { Link, useParams } 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 { CrmAttachmentsPanel } from "./CrmAttachmentsPanel";
import { CrmContactEntryForm } from "./CrmContactEntryForm"; import { CrmContactEntryForm } from "./CrmContactEntryForm";
import { CrmContactTypeBadge } from "./CrmContactTypeBadge"; import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
import { CrmStatusBadge } from "./CrmStatusBadge"; import { CrmStatusBadge } from "./CrmStatusBadge";
@@ -205,6 +206,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
)} )}
</article> </article>
</section> </section>
<CrmAttachmentsPanel ownerType={config.fileOwnerType} ownerId={record.id} />
</section> </section>
); );
} }

View File

@@ -14,6 +14,7 @@ interface CrmModuleConfig {
collectionLabel: string; collectionLabel: string;
singularLabel: string; singularLabel: string;
routeBase: string; routeBase: string;
fileOwnerType: string;
emptyMessage: string; emptyMessage: string;
} }
@@ -23,6 +24,7 @@ export const crmConfigs: Record<CrmEntity, CrmModuleConfig> = {
collectionLabel: "Customers", collectionLabel: "Customers",
singularLabel: "Customer", singularLabel: "Customer",
routeBase: "/crm/customers", routeBase: "/crm/customers",
fileOwnerType: "crm-customer",
emptyMessage: "No customer accounts have been added yet.", emptyMessage: "No customer accounts have been added yet.",
}, },
vendor: { vendor: {
@@ -30,6 +32,7 @@ export const crmConfigs: Record<CrmEntity, CrmModuleConfig> = {
collectionLabel: "Vendors", collectionLabel: "Vendors",
singularLabel: "Vendor", singularLabel: "Vendor",
routeBase: "/crm/vendors", routeBase: "/crm/vendors",
fileOwnerType: "crm-vendor",
emptyMessage: "No vendor records have been added yet.", emptyMessage: "No vendor records have been added yet.",
}, },
}; };

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 } from "./service.js"; import { createAttachment, getAttachmentContent, getAttachmentMetadata, listAttachmentsByOwner } from "./service.js";
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
@@ -19,8 +19,22 @@ const uploadSchema = z.object({
ownerId: z.string().min(1), ownerId: z.string().min(1),
}); });
const listSchema = z.object({
ownerType: z.string().min(1),
ownerId: z.string().min(1),
});
export const filesRouter = Router(); 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( filesRouter.post(
"/upload", "/upload",
requirePermissions([permissions.filesWrite]), requirePermissions([permissions.filesWrite]),

View File

@@ -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) { export async function getAttachmentContent(id: string) {
const file = await prisma.fileAttachment.findUniqueOrThrow({ const file = await prisma.fileAttachment.findUniqueOrThrow({
where: { id }, where: { id },
@@ -66,4 +78,3 @@ 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)),
}; };
} }