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:
cesnimda
2026-07-03 03:52:57 +02:00
parent ae3505b877
commit a1a3736cc4
5 changed files with 164 additions and 1 deletions
@@ -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" }}>