diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 92d6c4e..9d0559c 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -722,6 +722,88 @@ namespace JobTrackerApi.Controllers return NoContent(); } + + [HttpPost("{id:int}/refresh-ai")] + public async Task> RefreshAi([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + + if (job is null) return NotFound(); + + var sourceText = string.Join(" + +", new[] { job.Description, job.TranslatedDescription, job.Notes } + .Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(sourceText)) + { + return BadRequest("This job does not have enough description or notes to generate a summary and skills."); + } + + var tags = SkillTagger.Detect(sourceText) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + job.Tags = tags.Count == 0 ? null : JsonSerializer.Serialize(tags); + + var shortSummary = await _summarizer.SummarizeAsync(sourceText, 160, 60); + job.ShortSummary = string.IsNullOrWhiteSpace(shortSummary) ? job.ShortSummary : shortSummary; + + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "AiRefreshed", + Note = "Summary and tags were manually refreshed.", + At = DateTime.Now + }); + + await _db.SaveChangesAsync(cancellationToken); + + var settings = await RulesEngine.GetSettings(_db, cancellationToken); + var lastMsg = await _db.Correspondences + .AsNoTracking() + .Where(c => c.JobApplicationId == id) + .OrderByDescending(c => c.Date) + .Select(c => (DateTime?)c.Date) + .FirstOrDefaultAsync(cancellationToken); + var followUp = RulesEngine.Evaluate(settings, job, DateTime.Now, lastMsg); + + return Ok(new JobApplicationDto( + Id: job.Id, + CompanyId: job.CompanyId, + Company: job.Company, + JobTitle: job.JobTitle, + Status: job.Status, + DateApplied: job.DateApplied, + ResponseReceived: job.ResponseReceived, + ResponseDate: job.ResponseDate, + Notes: job.Notes, + CoverLetterText: job.CoverLetterText, + JobUrl: job.JobUrl, + Description: job.Description, + TranslatedDescription: job.TranslatedDescription, + DescriptionLanguage: job.DescriptionLanguage, + Tags: job.Tags, + Deadline: job.Deadline, + Location: job.Location, + Salary: job.Salary, + NextAction: job.NextAction, + FollowUpAt: job.FollowUpAt, + FeedbackRequestedAt: job.FeedbackRequestedAt, + HasResume: job.HasResume, + HasCoverLetter: job.HasCoverLetter, + HasPortfolio: job.HasPortfolio, + HasOtherAttachment: job.HasOtherAttachment, + IsDeleted: job.IsDeleted, + DeletedAt: job.DeletedAt, + DaysSince: job.DaysSince, + NeedsFollowUp: followUp.NeedsFollowUp, + FollowUpReason: followUp.Reason, + ShortSummary: job.ShortSummary, + FullSummary: null + )); + } + [HttpDelete("{id:int}")] public async Task SoftDelete([FromRoute] int id, CancellationToken cancellationToken) { diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index ba24a2c..bb4bbdd 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -235,6 +235,7 @@ export default function Correspondence({ jobId }: { jobId: number }) { const deleteMessage = async (messageId: number) => { + if (!window.confirm("Remove this correspondence message?")) return; try { await api.delete(`/correspondence/${messageId}`); await load(); diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 6949cab..e635dfb 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -16,6 +16,7 @@ import { import { api } from "../api"; import { JobApplication } from "../types"; +import { useToast } from "../toast"; import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; @@ -50,6 +51,7 @@ function statusChipColor(status: string): "default" | "primary" | "warning" | "e } export default function JobDetailsDialog({ open, jobId, onClose }: Props) { + const { toast } = useToast(); const [job, setJob] = useState(null); const [tab, setTab] = useState(0); const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]); @@ -57,6 +59,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const [followUpDraft, setFollowUpDraft] = useState(null); const [loadingDraft, setLoadingDraft] = useState(false); const [sendingDraft, setSendingDraft] = useState(false); + const [refreshingAi, setRefreshingAi] = useState(false); const [draftRecipient, setDraftRecipient] = useState(""); const [draftSubject, setDraftSubject] = useState(""); const [draftBody, setDraftBody] = useState(""); @@ -182,12 +185,33 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { {job.translatedDescription} ) : null} - {job?.fullSummary || job?.shortSummary ? ( - - Summary - {job?.fullSummary ?? job?.shortSummary} + + + Summary and skills + - ) : null} + {job?.fullSummary ?? job?.shortSummary ?? "No summary yet."} + Notes {job?.notes ?? ""} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 66ab958..bdfe023 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -204,10 +204,20 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((x) => x !== id)); }; - const softDelete = async (id: number) => { + const confirmDelete = (jobsToDelete: JobApplication[]) => { + if (jobsToDelete.length === 0) return false; + if (jobsToDelete.length === 1) { + const job = jobsToDelete[0]; + return window.confirm(`Move "${job.jobTitle}" at ${job.company?.name ?? "this company"} to trash?`); + } + return window.confirm(`Move ${jobsToDelete.length} selected jobs to trash?`); + }; + + const softDelete = async (job: JobApplication) => { + if (!confirmDelete([job])) return; try { - await api.delete(`/jobapplications/${id}`); - toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(id); } }); + await api.delete(`/jobapplications/${job.id}`); + toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(job.id); } }); setReloadToken((t) => t + 1); } catch { toast("Failed to delete job.", "error"); @@ -236,6 +246,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => { if (selectedIds.length === 0) return; + const selectedJobs = jobs.filter((job) => selectedIds.includes(job.id)); + if (action === "delete" && !confirmDelete(selectedJobs)) return; try { await Promise.all(selectedIds.map((id) => { if (action === "delete") return api.delete(`/jobapplications/${id}`); @@ -354,7 +366,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col setEditJobId(job.id)}> { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> setDetailsJobId(job.id)}> - {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job.id)}>} + {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>}