crm3
This commit is contained in:
18
ROADMAP.md
18
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
|
||||
|
||||
@@ -108,6 +108,16 @@ export const api = {
|
||||
|
||||
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 }) {
|
||||
return request<CrmRecordSummaryDto[]>(
|
||||
`/api/v1/crm/customers${buildQueryString({
|
||||
|
||||
145
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal file
145
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
<CrmAttachmentsPanel ownerType={config.fileOwnerType} ownerId={record.id} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface CrmModuleConfig {
|
||||
collectionLabel: string;
|
||||
singularLabel: string;
|
||||
routeBase: string;
|
||||
fileOwnerType: string;
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ export const crmConfigs: Record<CrmEntity, CrmModuleConfig> = {
|
||||
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<CrmEntity, CrmModuleConfig> = {
|
||||
collectionLabel: "Vendors",
|
||||
singularLabel: "Vendor",
|
||||
routeBase: "/crm/vendors",
|
||||
fileOwnerType: "crm-vendor",
|
||||
emptyMessage: "No vendor records have been added yet.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user