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

@@ -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({

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 { 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>
);
}

View File

@@ -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.",
},
};