Add attachment metadata and overview strategy snapshot
This commit is contained in:
@@ -9,8 +9,13 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -37,6 +42,8 @@ interface AttachmentItem {
|
||||
uploadDate: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
purpose?: string | null;
|
||||
useForAi: boolean;
|
||||
}
|
||||
|
||||
function fmtSize(n: number) {
|
||||
@@ -56,10 +63,23 @@ function isPdfType(t: string) {
|
||||
|
||||
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";
|
||||
return "Attachment";
|
||||
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 }) {
|
||||
@@ -105,7 +125,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
await api.post("/attachments", data, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast(files.length === 1 ? "File uploaded." : `${files.length} files uploaded.`, "success");
|
||||
toast(files.length === 1 ? t("attachmentsUploadedSingle") : t("attachmentsUploadedMany", { count: files.length }), "success");
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("attachmentsUploadFailed")), "error");
|
||||
@@ -117,7 +137,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
);
|
||||
|
||||
const rename = async (a: AttachmentItem) => {
|
||||
const next = await promptForValue("Rename attachment to:", a.fileName, { title: "Rename attachment", confirmLabel: "Rename" });
|
||||
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() });
|
||||
@@ -128,8 +148,18 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
const updateMetadata = async (a: AttachmentItem, patch: Partial<Pick<AttachmentItem, "purpose" | "useForAi">>) => {
|
||||
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(`Delete attachment "${a.fileName}"?`, { title: "Delete attachment", confirmLabel: "Delete", destructive: true }))) return;
|
||||
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");
|
||||
@@ -245,14 +275,15 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
<TableCell sx={{ width: 140 }}>{t("attachmentsKind")}</TableCell>
|
||||
<TableCell sx={{ width: 120 }}>{t("attachmentsType")}</TableCell>
|
||||
<TableCell sx={{ width: 90 }}>{t("attachmentsSize")}</TableCell>
|
||||
<TableCell sx={{ width: 170 }}>{t("attachmentsUploaded")}</TableCell>
|
||||
<TableCell sx={{ width: 160 }}>{t("attachmentsPurpose")}</TableCell>
|
||||
<TableCell sx={{ width: 110 }}>{t("attachmentsAiUse")}</TableCell>
|
||||
<TableCell sx={{ width: 170 }}>{t("attachmentsActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((a) => {
|
||||
const canPreview = isImageType(a.fileType) || isPdfType(a.fileType);
|
||||
const kind = guessKind(a.fileName);
|
||||
const kind = purposeLabel(a.purpose || guessKind(a.fileName), t);
|
||||
return (
|
||||
<TableRow key={a.id} hover>
|
||||
<TableCell>
|
||||
@@ -264,6 +295,25 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.fileType ? a.fileType.replace("application/", "") : ""}</TableCell>
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.fileSize ? fmtSize(a.fileSize) : ""}</TableCell>
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>{t("attachmentsPurpose")}</InputLabel>
|
||||
<Select value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
|
||||
<MenuItem value="resume">{t("attachmentsPurposeResume")}</MenuItem>
|
||||
<MenuItem value="cover-letter">{t("attachmentsPurposeCoverLetter")}</MenuItem>
|
||||
<MenuItem value="portfolio">{t("attachmentsPurposePortfolio")}</MenuItem>
|
||||
<MenuItem value="case-study">{t("attachmentsPurposeCaseStudy")}</MenuItem>
|
||||
<MenuItem value="certificate">{t("attachmentsPurposeCertificate")}</MenuItem>
|
||||
<MenuItem value="other">{t("attachmentsPurposeOther")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Switch size="small" checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{a.useForAi ? t("attachmentsAiEnabled") : t("attachmentsAiDisabled")}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", gap: 0.5, flex: "0 0 auto" }}>
|
||||
{canPreview ? (
|
||||
@@ -288,7 +338,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={8}>
|
||||
<Alert severity="info" variant="outlined">
|
||||
{t("attachmentsEmpty")}
|
||||
</Alert>
|
||||
|
||||
Reference in New Issue
Block a user