187 lines
7.1 KiB
TypeScript
187 lines
7.1 KiB
TypeScript
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;
|
|
onAttachmentCountChange?: (count: number) => void;
|
|
}
|
|
|
|
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, onAttachmentCountChange }: CrmAttachmentsPanelProps) {
|
|
const { token, user } = useAuth();
|
|
const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]);
|
|
const [status, setStatus] = useState("Loading attachments...");
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
|
|
|
|
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);
|
|
onAttachmentCountChange?.(nextAttachments.length);
|
|
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, onAttachmentCountChange, 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) => {
|
|
const nextAttachments = [attachment, ...current];
|
|
onAttachmentCountChange?.(nextAttachments.length);
|
|
return nextAttachments;
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(attachment: FileAttachmentDto) {
|
|
if (!token || !canWriteFiles) {
|
|
return;
|
|
}
|
|
|
|
setDeletingAttachmentId(attachment.id);
|
|
setStatus(`Deleting ${attachment.originalName}...`);
|
|
|
|
try {
|
|
await api.deleteAttachment(token, attachment.id);
|
|
setAttachments((current) => {
|
|
const nextAttachments = current.filter((item) => item.id !== attachment.id);
|
|
onAttachmentCountChange?.(nextAttachments.length);
|
|
return nextAttachments;
|
|
});
|
|
setStatus("Attachment deleted.");
|
|
} catch (error: unknown) {
|
|
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
|
|
setStatus(message);
|
|
} finally {
|
|
setDeletingAttachmentId(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
|
<div className="flex flex-col gap-3 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-2 text-lg 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-2 py-2 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-2 py-2 text-sm text-muted">{status}</div>
|
|
{!canReadFiles ? (
|
|
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 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-4 py-8 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-2 rounded-3xl border border-line/70 bg-page/60 px-2 py-2 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>
|
|
{canWriteFiles ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDelete(attachment)}
|
|
disabled={deletingAttachmentId === attachment.id}
|
|
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
|
>
|
|
{deletingAttachmentId === attachment.id ? "Deleting..." : "Delete"}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|