Add attachment metadata and overview strategy snapshot

This commit is contained in:
cesnimda
2026-03-23 22:46:44 +01:00
parent 93f5c9beb7
commit 603f5e8b74
8 changed files with 190 additions and 18 deletions
+60 -10
View File
@@ -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>
@@ -34,6 +34,8 @@ type AttachmentItem = {
uploadDate: string;
fileType: string;
fileSize: number;
purpose?: string | null;
useForAi: boolean;
};
type FollowUpDraft = {
@@ -97,6 +99,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
const [loadingStrategySnapshot, setLoadingStrategySnapshot] = useState(false);
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
@@ -137,7 +140,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
api.get<AttachmentItem[]>(`/attachments/${jobId}`).then((r) => {
const items = Array.isArray(r.data) ? r.data : [];
setJobAttachments(items);
setSelectedAttachmentIds(items.slice(0, 3).map((item) => item.id));
const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id);
setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id));
}).catch(() => {
setJobAttachments([]);
setSelectedAttachmentIds([]);
@@ -273,6 +277,40 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 0 && (
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline">{t("jobDetailsStrategySnapshot")}</Typography>
<Button size="small" variant="outlined" disabled={loadingStrategySnapshot} onClick={async () => {
if (!jobId) return;
setLoadingStrategySnapshot(true);
try {
const [fitRes, focusRes] = await Promise.all([
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
]);
setCandidateFit(fitRes.data);
setFocusPlan(focusRes.data);
} catch {
toast(t("jobDetailsStrategySnapshotFailed"), "error");
} finally {
setLoadingStrategySnapshot(false);
}
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : t("jobDetailsGenerateStrategySnapshot")}</Button>
</Box>
{candidateFit || focusPlan ? (
<Box sx={{ gridColumn: "1 / -1", p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
{candidateFit ? <Chip size="small" color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} /> : null}
{candidateFit?.fitLevel ? <Chip size="small" variant="outlined" label={candidateFit.fitLevel} /> : null}
</Box>
{focusPlan?.strategicSummary ? <Typography sx={{ whiteSpace: "pre-wrap", mb: 1 }}>{focusPlan.strategicSummary}</Typography> : null}
{candidateFit?.matchSummary ? <Typography sx={{ color: "text.secondary", whiteSpace: "pre-wrap", mb: 1.5 }}>{candidateFit.matchSummary}</Typography> : null}
{focusPlan?.immediatePriorities?.length ? <ListCard title={t("jobDetailsImmediatePriorities")} items={focusPlan.immediatePriorities.slice(0, 3)} /> : null}
</Box>
) : (
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography sx={{ color: "text.secondary" }}>{t("jobDetailsStrategySnapshotEmpty")}</Typography>
</Box>
)}
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>