diff --git a/README.md b/README.md index 9a9cd89..55cd4aa 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,8 @@ Authentication: - Returns totals, counts by status, applied-last-30-days, and average days since applied. - `GET /api/jobapplications/{id}/match-score` - Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.) +- `GET /api/jobapplications/{id}/status-suggestion` + - Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed. - `DELETE /api/jobapplications/{id}` - Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event. - `POST /api/jobapplications/{id}/restore` diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index b7b791f..c64e741 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -18,9 +18,11 @@ import { TextField, Typography, } from "@mui/material"; +import { alpha } from "@mui/material/styles"; import { api, getApiErrorMessage } from "../api"; -import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, TailoredCvDraft } from "../types"; +import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types"; +import { statusLabel } from "../pipeline"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft"; @@ -172,6 +174,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [candidateFit, setCandidateFit] = useState(null); const [matchScore, setMatchScore] = useState(null); const [loadingMatchScore, setLoadingMatchScore] = useState(false); + const [statusSuggestion, setStatusSuggestion] = useState(null); + const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false); const [focusPlan, setFocusPlan] = useState(null); const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); const [loadingFocusPlan, setLoadingFocusPlan] = useState(false); @@ -205,6 +209,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, setFollowUpDraft(null); setCandidateFit(null); setMatchScore(null); + setStatusSuggestion(null); setFocusPlan(null); setInterviewPrep(null); setReadiness(null); @@ -303,6 +308,31 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, }).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false)); }, [open, jobId, tab, matchScore, matchScoreCache]); + // Suggest a status move from the latest inbound email when the workspace opens. + useEffect(() => { + if (!open || !jobId) return; + let cancelled = false; + api.get(`/jobapplications/${jobId}/status-suggestion`) + .then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); }) + .catch(() => { if (!cancelled) setStatusSuggestion(null); }); + return () => { cancelled = true; }; + }, [open, jobId]); + + const applyStatusSuggestion = async () => { + if (!jobId || !statusSuggestion?.suggestedStatus) return; + setApplyingStatusSuggestion(true); + try { + await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus }); + setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev); + setStatusSuggestion(null); + toast(t("statusSuggestionApplied"), "success"); + } catch (error: any) { + toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error"); + } finally { + setApplyingStatusSuggestion(false); + } + }; + useEffect(() => { if (!open || !jobId || tab !== 6 || focusPlan) return; const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`; @@ -621,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {attachmentPicker} + {statusSuggestion?.hasSuggestion ? ( + alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}> + + + {t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })} + + + {t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })} + + + + + + + + ) : null} + {tab === 0 && ( diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index b2fbd04..6c72577 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -781,6 +781,12 @@ export const translations = { jobDetailsTabFocusPlan: "Focus plan", jobDetailsTabInterviewPrep: "Interview prep", jobDetailsTabHistory: "History", + statusSuggestionTitle: "This email looks like a move to {status}", + statusSuggestionReason: "Matched \"{signal}\" · currently {current}", + statusSuggestionApply: "Move to {status}", + statusSuggestionDismiss: "Dismiss", + statusSuggestionApplied: "Status updated.", + statusSuggestionFailed: "Could not update status.", jobDetailsTailoredCvMode: "Generation mode", jobDetailsGenerationDefault: "Balanced", jobDetailsGenerationConcise: "Concise", @@ -1714,6 +1720,12 @@ export const translations = { jobDetailsTabFocusPlan: "Fokusplan", jobDetailsTabInterviewPrep: "Intervjuforberedelse", jobDetailsTabHistory: "Historikk", + statusSuggestionTitle: "Denne e-posten ser ut som en overgang til {status}", + statusSuggestionReason: "Traff \"{signal}\" · nå {current}", + statusSuggestionApply: "Flytt til {status}", + statusSuggestionDismiss: "Avvis", + statusSuggestionApplied: "Status oppdatert.", + statusSuggestionFailed: "Kunne ikke oppdatere status.", jobDetailsTailoredCvMode: "Genereringsmodus", jobDetailsGenerationDefault: "Balansert", jobDetailsGenerationConcise: "Kortfattet", diff --git a/job-tracker-ui/src/status-suggestion.test.tsx b/job-tracker-ui/src/status-suggestion.test.tsx new file mode 100644 index 0000000..d36d2d8 --- /dev/null +++ b/job-tracker-ui/src/status-suggestion.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ConfirmProvider } from './confirm'; +import { PromptProvider } from './prompt'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import JobDetailsDialog from './components/JobDetailsDialog'; +import { api } from './api'; + +jest.setTimeout(15000); + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + delete: jest.fn(() => Promise.resolve({ data: {} })), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: jest.fn(() => 'error'), +})); + +const mockedApi = api as jest.Mocked; + +function renderDialog() { + return render( + + + + + {}} /> + + + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/status-suggestion') { + return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + return Promise.resolve({ data: {} } as any); + }); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('status suggestion banner appears and applies via PATCH', async () => { + renderDialog(); + + expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument(); + + fireEvent.click(await screen.findByRole('button', { name: /move to interview/i })); + + await waitFor(() => { + expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' }); + }); +}); + +test('no banner when there is no suggestion', async () => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/status-suggestion') { + return Promise.resolve({ data: { hasSuggestion: false } } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + return Promise.resolve({ data: {} } as any); + }); + + renderDialog(); + + expect(await screen.findByText(/backend developer/i)).toBeInTheDocument(); + expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 133d417..9efd0d6 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -138,6 +138,16 @@ export interface MatchScoreSectionCoverage { total: number; } +export interface StatusSuggestion { + hasSuggestion: boolean; + suggestedStatus?: string | null; + currentStatus?: string | null; + signal?: string | null; + confidence?: string | null; + messageDate?: string | null; + messageSubject?: string | null; +} + export interface MatchScore { score: number; band: string;