From 05bc42c3d56345be73bbe3b369d489e8a20e7af9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 23 Mar 2026 22:30:54 +0100 Subject: [PATCH] Add shared attachment context controls for AI job tools --- .../Controllers/JobApplicationsController.cs | 17 ++--- docs/jobbjakt-cleanup-tracker.md | 1 + .../src/components/JobDetailsDialog.tsx | 72 ++++++++++++------- job-tracker-ui/src/i18n/translations.ts | 4 ++ 4 files changed, 59 insertions(+), 35 deletions(-) diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 1d74de6..9bd3718 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1471,7 +1471,7 @@ namespace JobTrackerApi.Controllers } [HttpGet("{id:int}/candidate-fit")] - public async Task> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken) + public async Task> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications .Include(j => j.Company) @@ -1500,7 +1500,7 @@ namespace JobTrackerApi.Controllers var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList(); var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList(); - var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken); + var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); var jobContext = $@"Job title: {job.JobTitle} Company: {job.Company?.Name} Status: {job.Status} @@ -1594,7 +1594,7 @@ Candidate CV/profile: } [HttpGet("{id:int}/focus-plan")] - public async Task> GetFocusPlan([FromRoute] int id, CancellationToken cancellationToken) + public async Task> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications .Include(j => j.Company) @@ -1623,7 +1623,7 @@ Candidate CV/profile: var matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList(); var missingTags = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList(); - var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken); + var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); var context = $@"Job title: {job.JobTitle} Company: {job.Company?.Name} Status: {job.Status} @@ -1675,14 +1675,15 @@ Candidate master CV: } [HttpGet("{id:int}/interview-prep")] - public async Task> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken) + public async Task> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications .Include(j => j.Company) .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); - var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary } + var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); + var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, attachmentContext?.Context } .Where(x => !string.IsNullOrWhiteSpace(x))); var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList(); @@ -2047,7 +2048,7 @@ Candidate master CV: } [HttpGet("{id:int}/followup-draft")] - public async Task> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken) + public async Task> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications .AsNoTracking() @@ -2083,7 +2084,7 @@ Candidate master CV: : job.Status == "Rejected" ? "feedback-request" : "post-apply") : mode.Trim().ToLowerInvariant(); - var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken); + var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); var aiContext = $@"Candidate name: {signerName} Role: {job.JobTitle} Company: {companyName} diff --git a/docs/jobbjakt-cleanup-tracker.md b/docs/jobbjakt-cleanup-tracker.md index 0d2d58c..0de9077 100644 --- a/docs/jobbjakt-cleanup-tracker.md +++ b/docs/jobbjakt-cleanup-tracker.md @@ -81,5 +81,6 @@ Last updated: 2026-03-23 - [x] Simplify create-job workflow - [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 - [ ] 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/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index ea562cc..6c26149 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -115,6 +115,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [draftReloadToken, setDraftReloadToken] = useState(0); const [draftSubject, setDraftSubject] = useState(""); const [draftBody, setDraftBody] = useState(""); + const selectedAttachmentCsv = useMemo(() => selectedAttachmentIds.join(","), [selectedAttachmentIds]); useEffect(() => { if (!open || !jobId) return; @@ -148,30 +149,37 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, useEffect(() => { if (!open || !jobId || tab !== 4) return; setLoadingDraft(true); - api.get(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode } }).then((r) => { + api.get(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); }).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false)); - }, [open, jobId, tab, followUpMode, draftReloadToken]); + }, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 5 || candidateFit) return; setLoadingCandidateFit(true); - api.get(`/jobapplications/${jobId}/candidate-fit`).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); - }, [open, jobId, tab, candidateFit]); + api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); + }, [open, jobId, tab, candidateFit, selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 6 || focusPlan) return; setLoadingFocusPlan(true); - api.get(`/jobapplications/${jobId}/focus-plan`).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); - }, [open, jobId, tab, focusPlan]); + api.get(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); + }, [open, jobId, tab, focusPlan, selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 7 || interviewPrep) return; setLoadingInterviewPrep(true); - api.get(`/jobapplications/${jobId}/interview-prep`).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); - }, [open, jobId, tab, interviewPrep]); + api.get(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); + }, [open, jobId, tab, interviewPrep, selectedAttachmentCsv]); + + useEffect(() => { + setFollowUpDraft(null); + setCandidateFit(null); + setFocusPlan(null); + setInterviewPrep(null); + }, [selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 8 || readiness) return; @@ -203,6 +211,33 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const showTranslatedText = translatedDescriptionText.length > 0; const showOriginalText = originalDescriptionText.length > 0; const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]); + const showAiAttachmentPicker = tab >= 3 && tab <= 7 && jobAttachments.length > 0; + + const attachmentPicker = showAiAttachmentPicker ? ( + + + {t("jobDetailsAttachmentContextPicker")} + + + + + + + {jobAttachments.map((attachment) => { + const selected = selectedAttachmentIds.includes(attachment.id); + return ( + setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))} + /> + ); + })} + + + ) : null; return ( @@ -234,6 +269,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {isAdmin ? : null} + {attachmentPicker} + {tab === 0 && ( {t("jobDetailsDateApplied")}{job ? new Date(job.dateApplied).toLocaleDateString() : ""} @@ -353,25 +390,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {t("jobDetailsTailoredCvIntro")} - {jobAttachments.length > 0 ? ( - - {t("jobDetailsAttachmentContextPicker")} - - {jobAttachments.map((attachment) => { - const selected = selectedAttachmentIds.includes(attachment.id); - return ( - setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))} - /> - ); - })} - - - ) : null} setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} /> {t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 2c2e40f..ec65913 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -695,6 +695,8 @@ export const translations = { jobDetailsTailoredCvSaveFailed: "Failed to save tailored CV.", jobDetailsTailoredCvIntro: "Generate a full application package, then edit and save the tailored resume you actually want to use for this role.", jobDetailsAttachmentContextPicker: "Use these attachments as AI context", + jobDetailsAttachmentSelectTop: "Use recent files", + jobDetailsAttachmentClear: "Clear selection", jobDetailsTailoredCvPlaceholder: "Paste or rewrite the version of your CV you want to use for this role.", jobDetailsLastUpdated: "Last updated: {value}", jobDetailsNotSavedYet: "Not saved yet", @@ -1469,6 +1471,8 @@ export const translations = { jobDetailsTailoredCvSaveFailed: "Kunne ikke lagre tilpasset CV.", jobDetailsTailoredCvIntro: "Generer en full søknadspakke, og rediger og lagre deretter den tilpassede CV-en du faktisk vil bruke for denne rollen.", jobDetailsAttachmentContextPicker: "Bruk disse vedleggene som AI-kontekst", + jobDetailsAttachmentSelectTop: "Bruk nylige filer", + jobDetailsAttachmentClear: "Tøm utvalg", jobDetailsTailoredCvPlaceholder: "Lim inn eller skriv om versjonen av CV-en du vil bruke for denne rollen.", jobDetailsLastUpdated: "Sist oppdatert: {value}", jobDetailsNotSavedYet: "Ikke lagret ennå",