feat(ui): human-confirmed status suggestion banner
When a job workspace opens, loads /status-suggestion and shows a
dismissible banner when a recent inbound email implies a status move
("This email looks like a move to Interview"). Applying it PATCHes the
status; nothing changes without the user's click.
- StatusSuggestion type + load-on-open effect + apply handler
- warning-toned banner shown above tab content on any tab
- EN/NB translations; README endpoint docs
- 2 frontend tests; full suite green (21 suites / 48 tests)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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<CandidateFit | null>(null);
|
||||
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
||||
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
||||
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
|
||||
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
|
||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(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<StatusSuggestion>(`/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 ? (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
|
||||
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{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" }}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof api>;
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobDetailsDialog open jobId={42} onClose={() => {}} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user