import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Select, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography, } from "@mui/material"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; import DownloadIcon from "@mui/icons-material/Download"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { useDialogActions } from "../dialogs"; interface AttachmentItem { id: number; fileName: string; uploadDate: string; fileType: string; fileSize: number; purpose?: string | null; useForAi: boolean; } function fmtSize(n: number) { if (!n) return ""; const kb = n / 1024; if (kb < 1024) return `${kb.toFixed(0)} KB`; return `${(kb / 1024).toFixed(1)} MB`; } function isImageType(t: string) { return (t || "").toLowerCase().startsWith("image/"); } function isPdfType(t: string) { return (t || "").toLowerCase() === "application/pdf"; } function guessKind(fileName: string): string { const n = (fileName || "").toLowerCase(); if (n.includes("cover")) return "cover-letter"; if (n.includes("resume") || n.includes("résumé") || n.includes(" cv") || n.endsWith("cv.pdf")) return "resume"; if (n.includes("portfolio")) return "portfolio"; if (n.includes("case") || n.includes("sample")) return "case-study"; if (n.includes("cert")) return "certificate"; return "other"; } function purposeLabel(purpose: string | null | undefined, t: (key: any) => string) { switch ((purpose || "").trim().toLowerCase()) { case "resume": return t("attachmentsPurposeResume"); case "cover-letter": return t("attachmentsPurposeCoverLetter"); case "portfolio": return t("attachmentsPurposePortfolio"); case "case-study": return t("attachmentsPurposeCaseStudy"); case "certificate": return t("attachmentsPurposeCertificate"); default: return t("attachmentsPurposeOther"); } } export default function Attachments({ jobId }: { jobId: number }) { const { toast } = useToast(); const { t } = useI18n(); const { confirmAction, promptForValue } = useDialogActions(); const [items, setItems] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); const [preview, setPreview] = useState<{ url: string; type: string; name: string } | null>(null); const [dragActive, setDragActive] = useState(false); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const load = useCallback(async () => { try { const res = await api.get(`/attachments/${jobId}`); setItems(res.data); } catch { // non-fatal } }, [jobId]); useEffect(() => { void load(); }, [load]); useEffect(() => { return () => { if (preview?.url) URL.revokeObjectURL(preview.url); }; }, [preview?.url]); const uploadFiles = useCallback( async (files: File[]) => { if (!files.length) return; const data = new FormData(); files.forEach((f) => data.append("files", f)); data.append("jobId", jobId.toString()); setUploading(true); try { await api.post("/attachments", data, { headers: { "Content-Type": "multipart/form-data" }, }); toast(files.length === 1 ? t("attachmentsUploadedSingle") : t("attachmentsUploadedMany", { count: files.length }), "success"); await load(); } catch (error) { toast(getApiErrorMessage(error, t("attachmentsUploadFailed")), "error"); } finally { setUploading(false); } }, [jobId, load, t, toast], ); const rename = async (a: AttachmentItem) => { const next = await promptForValue(t("attachmentsRenamePrompt"), a.fileName, { title: t("attachmentsRenameTitle"), confirmLabel: t("attachmentsRename") }); if (!next || next.trim() === a.fileName) return; try { await api.patch(`/attachments/${a.id}`, { fileName: next.trim() }); toast(t("attachmentsRenamed"), "success"); await load(); } catch (error) { toast(getApiErrorMessage(error, t("attachmentsRenameFailed")), "error"); } }; const updateMetadata = async (a: AttachmentItem, patch: Partial>) => { try { await api.patch(`/attachments/${a.id}`, { purpose: patch.purpose ?? a.purpose ?? guessKind(a.fileName), useForAi: patch.useForAi ?? a.useForAi }); setItems((current) => current.map((item) => item.id === a.id ? { ...item, ...patch } : item)); toast(t("attachmentsUpdated"), "success"); } catch (error) { toast(getApiErrorMessage(error, t("attachmentsUpdateFailed")), "error"); } }; const remove = async (a: AttachmentItem) => { if (!(await confirmAction(t("attachmentsDeleteConfirm", { name: a.fileName }), { title: t("attachmentsDeleteTitle"), confirmLabel: t("attachmentsDelete"), destructive: true }))) return; try { await api.delete(`/attachments/${a.id}`); toast(t("attachmentsDeleted"), "success"); await load(); } catch (error) { toast(getApiErrorMessage(error, t("attachmentsDeleteFailed")), "error"); } }; const upload = async (e: React.ChangeEvent) => { const files = e.target.files ? Array.from(e.target.files) : []; await uploadFiles(files); e.target.value = ""; }; const getBlobUrl = async (id: number) => { const res = await api.get(`/attachments/download/${id}`, { responseType: "blob" }); const blob: Blob = res.data; return URL.createObjectURL(blob); }; const download = async (a: AttachmentItem) => { try { const url = await getBlobUrl(a.id); const link = document.createElement("a"); link.href = url; link.download = a.fileName || `attachment_${a.id}`; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 5000); } catch (error) { toast(getApiErrorMessage(error, t("attachmentsDownloadFailed")), "error"); } }; const openPreview = async (a: AttachmentItem) => { try { if (preview?.url) URL.revokeObjectURL(preview.url); const url = await getBlobUrl(a.id); setPreview({ url, type: a.fileType || "", name: a.fileName }); setPreviewOpen(true); } catch (error) { toast(getApiErrorMessage(error, t("attachmentsPreviewFailed")), "error"); } }; const count = useMemo(() => items.length, [items.length]); const imageCount = useMemo(() => items.filter((x) => isImageType(x.fileType)).length, [items]); const pdfCount = useMemo(() => items.filter((x) => isPdfType(x.fileType)).length, [items]); return ( {t("attachmentsTitle", { count })} {t("attachmentsSubtitle")} { e.preventDefault(); setDragActive(true); }} onDragEnter={(e) => { e.preventDefault(); setDragActive(true); }} onDragLeave={(e) => { e.preventDefault(); setDragActive(false); }} onDrop={(e) => { e.preventDefault(); setDragActive(false); void uploadFiles(Array.from(e.dataTransfer.files || [])); }} sx={{ mb: 2, p: 2, borderRadius: 3, border: "1px dashed", borderColor: dragActive ? "primary.main" : "divider", backgroundColor: dragActive ? "action.hover" : "background.paper", transition: "all 0.2s ease", textAlign: "center", }} > {t("attachmentsDragDrop")} {t("attachmentsDragDropHelp")} {uploading ? : null} {t("attachmentsName")} {t("attachmentsKind")} {t("attachmentsType")} {t("attachmentsSize")} {t("attachmentsPurpose")} {t("attachmentsAiUse")} {t("attachmentsActions")} {items.map((a) => { const canPreview = isImageType(a.fileType) || isPdfType(a.fileType); const kind = purposeLabel(a.purpose || guessKind(a.fileName), t); return ( {kind} {a.fileType ? a.fileType.replace("application/", "") : ""} {a.fileSize ? fmtSize(a.fileSize) : ""} {a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""} {t("attachmentsPurpose")} void updateMetadata(a, { useForAi: checked })} /> {a.useForAi ? t("attachmentsAiEnabled") : t("attachmentsAiDisabled")} {canPreview ? ( void openPreview(a)} title={t("attachmentsPreview")}> ) : null} void download(a)} title={t("attachmentsDownload")}> void rename(a)} title={t("attachmentsRename")}> void remove(a)} title={t("attachmentsDelete")}> ); })} {items.length === 0 ? ( {t("attachmentsEmpty")} ) : null}
setPreviewOpen(false)} fullWidth maxWidth="md"> {preview?.name ? t("attachmentsPreviewTitle", { name: preview.name }) : t("attachmentsPreview")} {!preview ? null : isImageType(preview.type) ? ( {preview.name} ) : isPdfType(preview.type) ? (