diff --git a/JobTrackerApi/Controllers/AttachmentsController.cs b/JobTrackerApi/Controllers/AttachmentsController.cs index b55252f..e7c3454 100644 --- a/JobTrackerApi/Controllers/AttachmentsController.cs +++ b/JobTrackerApi/Controllers/AttachmentsController.cs @@ -27,7 +27,7 @@ namespace JobTrackerApi.Controllers _db = db; } - public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize); + public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi); // Child entities are accessed by raw integer ids in a few endpoints below. // Always resolve them through the parent JobApplication query so the global job-level @@ -46,6 +46,17 @@ namespace JobTrackerApi.Controllers return $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{suffix}{ext}"; } + private static string GuessPurpose(string fileName) + { + var n = (fileName ?? string.Empty).ToLowerInvariant(); + if (n.Contains("cover")) return "cover-letter"; + if (n.Contains("resume") || n.Contains("résumé") || n.Contains(" cv") || n.EndsWith("cv.pdf")) return "resume"; + if (n.Contains("portfolio")) return "portfolio"; + if (n.Contains("case") || n.Contains("sample")) return "case-study"; + if (n.Contains("cert")) return "certificate"; + return "other"; + } + [HttpGet("{jobId:int}")] public async Task>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken) { @@ -56,7 +67,7 @@ namespace JobTrackerApi.Controllers .AsNoTracking() .Where(a => a.JobApplicationId == jobId) .OrderByDescending(a => a.UploadDate) - .Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize)) + .Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize, a.Purpose, a.UseForAi)) .ToListAsync(cancellationToken); return Ok(items); @@ -76,17 +87,32 @@ namespace JobTrackerApi.Controllers return PhysicalFile(att.FilePath, contentType, fileName); } - public sealed record RenameAttachmentRequest(string FileName); + public sealed record UpdateAttachmentRequest(string? FileName, string? Purpose, bool? UseForAi); [HttpPatch("{id:int}")] - public async Task Rename([FromRoute] int id, [FromBody] RenameAttachmentRequest request, CancellationToken cancellationToken) + public async Task Rename([FromRoute] int id, [FromBody] UpdateAttachmentRequest request, CancellationToken cancellationToken) { var att = await FindOwnedAttachmentAsync(id, cancellationToken); if (att is null) return NotFound(); - var name = Path.GetFileName((request.FileName ?? "").Trim()); - if (name.Length == 0) return BadRequest("FileName is required."); + if (request.UseForAi is not null) + { + att.UseForAi = request.UseForAi.Value; + } + if (!string.IsNullOrWhiteSpace(request.Purpose)) + { + att.Purpose = request.Purpose.Trim().ToLowerInvariant(); + } + + var rawName = (request.FileName ?? string.Empty).Trim(); + if (rawName.Length == 0) + { + await _db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + var name = Path.GetFileName(rawName); var ext = Path.GetExtension(name); if (!AllowedExtensions.Contains(ext)) return BadRequest("That file type is not allowed."); @@ -166,7 +192,9 @@ namespace JobTrackerApi.Controllers FilePath = path, UploadDate = DateTime.Now, FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, - FileSize = file.Length + FileSize = file.Length, + Purpose = GuessPurpose(displayName), + UseForAi = true, }); } diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index aafb98b..92d062f 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -99,6 +99,10 @@ namespace JobTrackerApi.Controllers { query = query.Where(a => allowedIds.Contains(a.Id)); } + else + { + query = query.Where(a => a.UseForAi); + } var attachments = await query .OrderByDescending(a => a.UploadDate) diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index a827731..28330e6 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -676,6 +676,8 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); + EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;"); + EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); // Ensure data folder exists before creating/opening SQLite files. Directory.CreateDirectory(paths.DataRoot); @@ -730,6 +732,8 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;"); + EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;"); if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { diff --git a/Models/Attachments.cs b/Models/Attachments.cs index af7ebb8..41af632 100644 --- a/Models/Attachments.cs +++ b/Models/Attachments.cs @@ -15,5 +15,7 @@ namespace JobTrackerApi.Models public DateTime UploadDate { get; set; } = DateTime.Now; public string FileType { get; set; } = ""; // e.g., PDF, DOCX public long FileSize { get; set; } + public string? Purpose { get; set; } + public bool UseForAi { get; set; } = true; } } diff --git a/docs/jobbjakt-cleanup-tracker.md b/docs/jobbjakt-cleanup-tracker.md index 0de9077..c8e3325 100644 --- a/docs/jobbjakt-cleanup-tracker.md +++ b/docs/jobbjakt-cleanup-tracker.md @@ -11,6 +11,7 @@ Last updated: 2026-03-23 - [x] Add frontend test coverage for AI service status rendering - [x] Add CV text improvement flow powered by the AI service - [x] Extend AI extraction to job attachment ingestion for package/follow-up context +- [x] Add attachment metadata and AI-inclusion controls for job documents - [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*` ## Build / UI Issues @@ -82,5 +83,6 @@ Last updated: 2026-03-23 - [x] Reduce duplicated UI/data across multiple pages - [x] Add direct follow-up deep links for specific jobs - [x] Add attachment selection controls for AI-assisted job drafting tabs +- [x] Add a compact strategy snapshot on the job overview - [ ] Perform final UX clarity pass across major screens - [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages diff --git a/job-tracker-ui/src/components/Attachments.tsx b/job-tracker-ui/src/components/Attachments.tsx index 52e5537..d0f15b0 100644 --- a/job-tracker-ui/src/components/Attachments.tsx +++ b/job-tracker-ui/src/components/Attachments.tsx @@ -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>) => { + 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 }) { {t("attachmentsKind")} {t("attachmentsType")} {t("attachmentsSize")} - {t("attachmentsUploaded")} + {t("attachmentsPurpose")} + {t("attachmentsAiUse")} {t("attachmentsActions")} {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 ( @@ -264,6 +295,25 @@ export default function Attachments({ jobId }: { jobId: number }) { {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 ? ( @@ -288,7 +338,7 @@ export default function Attachments({ jobId }: { jobId: number }) { {items.length === 0 ? ( - + {t("attachmentsEmpty")} diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index f70f75f..cb0fd60 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -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(null); const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); const [loadingFocusPlan, setLoadingFocusPlan] = useState(false); + const [loadingStrategySnapshot, setLoadingStrategySnapshot] = useState(false); const [interviewPrep, setInterviewPrep] = useState(null); const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false); const [readiness, setReadiness] = useState(null); @@ -137,7 +140,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, api.get(`/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 && ( + + {t("jobDetailsStrategySnapshot")} + + + {candidateFit || focusPlan ? ( + + + {candidateFit ? = 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} /> : null} + {candidateFit?.fitLevel ? : null} + + {focusPlan?.strategicSummary ? {focusPlan.strategicSummary} : null} + {candidateFit?.matchSummary ? {candidateFit.matchSummary} : null} + {focusPlan?.immediatePriorities?.length ? : null} + + ) : ( + + {t("jobDetailsStrategySnapshotEmpty")} + + )} {t("jobDetailsDateApplied")}{job ? new Date(job.dateApplied).toLocaleDateString() : ""} {t("jobDetailsDaysSince")}{job?.daysSince ?? ""} {t("jobTableLocation")}{job?.location ?? ""} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index ddeb6ee..052965d 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -426,15 +426,33 @@ export const translations = { attachmentsType: "Type", attachmentsSize: "Size", attachmentsUploaded: "Uploaded", + attachmentsPurpose: "Purpose", + attachmentsAiUse: "Use for AI", attachmentsActions: "Actions", attachmentsPreview: "Preview", attachmentsDownload: "Download", attachmentsRename: "Rename", attachmentsDelete: "Delete", + attachmentsPurposeResume: "Resume", + attachmentsPurposeCoverLetter: "Cover letter", + attachmentsPurposePortfolio: "Portfolio", + attachmentsPurposeCaseStudy: "Case study", + attachmentsPurposeCertificate: "Certificate", + attachmentsPurposeOther: "Other", + attachmentsRenamePrompt: "Rename attachment to:", + attachmentsRenameTitle: "Rename attachment", + attachmentsDeleteTitle: "Delete attachment", + attachmentsDeleteConfirm: "Delete attachment \"{name}\"?", + attachmentsUploadedSingle: "File uploaded.", + attachmentsUploadedMany: "{count} files uploaded.", + attachmentsAiEnabled: "Included", + attachmentsAiDisabled: "Ignored", attachmentsEmpty: "No attachments yet. Upload a resume, cover letter, or portfolio to keep everything tied to this job.", attachmentsPreviewTitle: "Preview: {name}", attachmentsNoInlinePreview: "No inline preview for this file type.", attachmentsUploadFailed: "Upload failed.", + attachmentsUpdated: "Attachment updated.", + attachmentsUpdateFailed: "Failed to update attachment.", attachmentsRenamed: "Renamed.", attachmentsRenameFailed: "Rename failed.", attachmentsDeleted: "Deleted attachment.", @@ -734,6 +752,10 @@ export const translations = { jobDetailsFollowUpSent: "Follow-up sent and logged.", jobDetailsFollowUpSendFailed: "Failed to send follow-up.", jobDetailsHowYouMatch: "How you match", + jobDetailsStrategySnapshot: "Strategy snapshot", + jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot", + jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.", + jobDetailsStrategySnapshotFailed: "Failed to generate strategy snapshot.", jobDetailsMatchPercent: "{count}% match", jobDetailsTailoredPitch: "Tailored pitch", jobDetailsStrongMatches: "Strong matches", @@ -1204,15 +1226,33 @@ export const translations = { attachmentsType: "Filtype", attachmentsSize: "Størrelse", attachmentsUploaded: "Lastet opp", + attachmentsPurpose: "Formål", + attachmentsAiUse: "Bruk for AI", attachmentsActions: "Handlinger", attachmentsPreview: "Forhåndsvis", attachmentsDownload: "Last ned", attachmentsRename: "Gi nytt navn", attachmentsDelete: "Slett", + attachmentsPurposeResume: "CV", + attachmentsPurposeCoverLetter: "Søknadsbrev", + attachmentsPurposePortfolio: "Portefølje", + attachmentsPurposeCaseStudy: "Case-studie", + attachmentsPurposeCertificate: "Sertifikat", + attachmentsPurposeOther: "Annet", + attachmentsRenamePrompt: "Gi vedlegget nytt navn:", + attachmentsRenameTitle: "Gi nytt navn til vedlegg", + attachmentsDeleteTitle: "Slett vedlegg", + attachmentsDeleteConfirm: "Slette vedlegget \"{name}\"?", + attachmentsUploadedSingle: "Fil lastet opp.", + attachmentsUploadedMany: "{count} filer lastet opp.", + attachmentsAiEnabled: "Inkludert", + attachmentsAiDisabled: "Ignorert", attachmentsEmpty: "Ingen vedlegg ennå. Last opp en CV, et søknadsbrev eller en portefølje for å knytte alt til denne jobben.", attachmentsPreviewTitle: "Forhåndsvisning: {name}", attachmentsNoInlinePreview: "Ingen innebygd forhåndsvisning for denne filtypen.", attachmentsUploadFailed: "Opplasting mislyktes.", + attachmentsUpdated: "Vedlegg oppdatert.", + attachmentsUpdateFailed: "Kunne ikke oppdatere vedlegget.", attachmentsRenamed: "Gi nytt navn fullført.", attachmentsRenameFailed: "Kunne ikke gi nytt navn.", attachmentsDeleted: "Vedlegg slettet.", @@ -1512,6 +1552,10 @@ export const translations = { jobDetailsFollowUpSent: "Oppfølging sendt og loggført.", jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.", jobDetailsHowYouMatch: "Slik matcher du", + jobDetailsStrategySnapshot: "Strategioversikt", + jobDetailsGenerateStrategySnapshot: "Generer strategioversikt", + jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.", + jobDetailsStrategySnapshotFailed: "Kunne ikke generere strategioversikt.", jobDetailsMatchPercent: "{count}% match", jobDetailsTailoredPitch: "Tilpasset pitch", jobDetailsStrongMatches: "Sterke matcher",