Add focus plans and stage-aware follow-up drafting

This commit is contained in:
cesnimda
2026-03-23 22:04:39 +01:00
parent 19b0424ef3
commit 8db620e45b
8 changed files with 345 additions and 25 deletions
@@ -19,7 +19,7 @@ import {
} from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { ApplicationPackageResponse, CandidateFit, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
@@ -42,6 +42,7 @@ interface Props {
jobId: number | null;
onClose: () => void;
initialTab?: number;
initialFollowUpMode?: string;
}
function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" {
@@ -70,7 +71,7 @@ function copyLines(items: string[]) {
return navigator.clipboard.writeText(items.map((item) => `${item}`).join("\n"));
}
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 }: Props) {
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
@@ -84,7 +85,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
const [sendingDraft, setSendingDraft] = useState(false);
const [refreshingAi, setRefreshingAi] = useState(false);
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
@@ -96,14 +99,17 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
const [tailoredCvText, setTailoredCvText] = useState("");
const [draftRecipient, setDraftRecipient] = useState("");
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
const [draftReloadToken, setDraftReloadToken] = useState(0);
const [draftSubject, setDraftSubject] = useState("");
const [draftBody, setDraftBody] = useState("");
useEffect(() => {
if (!open || !jobId) return;
setTab(Math.max(0, Math.min(8, initialTab)));
setTab(Math.max(0, Math.min(9, initialTab)));
setFollowUpDraft(null);
setCandidateFit(null);
setFocusPlan(null);
setInterviewPrep(null);
setReadiness(null);
setApplicationPackage(null);
@@ -111,20 +117,21 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
setJob(r.data);
setTailoredCvText(r.data.tailoredCvText ?? "");
setDraftRecipient(r.data.company?.recruiterEmail ?? "");
setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply"));
});
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
}, [open, jobId, initialTab]);
}, [open, jobId, initialTab, initialFollowUpMode]);
useEffect(() => {
if (!open || !jobId || tab !== 4 || followUpDraft) return;
if (!open || !jobId || tab !== 4) return;
setLoadingDraft(true);
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`).then((r) => {
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode } }).then((r) => {
setFollowUpDraft(r.data);
setDraftSubject(r.data.subject);
setDraftBody(r.data.body);
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
}, [open, jobId, tab, followUpDraft]);
}, [open, jobId, tab, followUpMode, draftReloadToken]);
useEffect(() => {
if (!open || !jobId || tab !== 5 || candidateFit) return;
@@ -133,13 +140,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
}, [open, jobId, tab, candidateFit]);
useEffect(() => {
if (!open || !jobId || tab !== 6 || interviewPrep) return;
if (!open || !jobId || tab !== 6 || focusPlan) return;
setLoadingFocusPlan(true);
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
}, [open, jobId, tab, focusPlan]);
useEffect(() => {
if (!open || !jobId || tab !== 7 || interviewPrep) return;
setLoadingInterviewPrep(true);
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
}, [open, jobId, tab, interviewPrep]);
useEffect(() => {
if (!open || !jobId || tab !== 7 || readiness) return;
if (!open || !jobId || tab !== 8 || readiness) return;
setLoadingReadiness(true);
api.get<ReadinessResponse>(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false));
}, [open, jobId, tab, readiness]);
@@ -193,6 +206,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
<Tab label={t("jobDetailsTabTailoredCv")} />
<Tab label={t("jobTableFollowUp")} />
<Tab label={t("jobDetailsTabCandidateFit")} />
<Tab label={t("jobDetailsTabFocusPlan")} />
<Tab label={t("jobDetailsTabInterviewPrep")} />
<Tab label={t("jobTableReadiness")} />
{isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
@@ -364,8 +378,23 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
<Box>
{loadingDraft ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : followUpDraft ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box><Typography variant="overline">{t("jobDetailsReason")}</Typography><Typography>{followUpDraft.reason}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsSuggestedSendDate")}</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<Box><Typography variant="overline">{t("jobDetailsReason")}</Typography><Typography>{followUpDraft.reason}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsSuggestedSendDate")}</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel>{t("jobDetailsFollowUpMode")}</InputLabel>
<Select value={followUpMode} label={t("jobDetailsFollowUpMode")} onChange={(e) => setFollowUpMode(e.target.value)}>
<MenuItem value="post-apply">{t("jobDetailsFollowUpModePostApply")}</MenuItem>
<MenuItem value="waiting-update">{t("jobDetailsFollowUpModeWaiting")}</MenuItem>
<MenuItem value="post-interview">{t("jobDetailsFollowUpModePostInterview")}</MenuItem>
<MenuItem value="offer-checkin">{t("jobDetailsFollowUpModeOffer")}</MenuItem>
<MenuItem value="feedback-request">{t("jobDetailsFollowUpModeFeedback")}</MenuItem>
</Select>
</FormControl>
<Button variant="outlined" onClick={() => setDraftReloadToken((value) => value + 1)}>{t("jobDetailsRegenerateDraft")}</Button>
</Box>
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={t("jobDetailsRecipientHelp")} />
<TextField label={t("jobDetailsSubject")} value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
<TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} />
@@ -419,6 +448,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
)}
{tab === 6 && (
<Box>
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
</Box>
)}
{tab === 7 && (
<Box>
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
@@ -430,7 +472,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
</Box>
)}
{tab === 7 && (
{tab === 8 && (
<Box>
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
@@ -448,7 +490,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
</Box>
)}
{tab === 8 && isAdmin && (
{tab === 9 && isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoHistory")}</Typography> : history.map((entry) => <PaperRow key={entry.id} type={entry.type} oldValue={entry.oldValue} newValue={entry.newValue} at={entry.at} note={entry.note} />)}
</Box>
+5 -2
View File
@@ -148,6 +148,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
const [detailsInitialTab, setDetailsInitialTab] = useState(0);
const [detailsFollowUpMode, setDetailsFollowUpMode] = useState<string | undefined>(undefined);
const [editJobId, setEditJobId] = useState<number | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
@@ -181,11 +182,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const paramsSearch = new URLSearchParams(location.search);
const openId = Number(paramsSearch.get("open") || 0);
const tabIndex = Number(paramsSearch.get("tab") || 0);
const followMode = paramsSearch.get("followMode") || undefined;
if (!openId || jobs.length === 0) return;
const job = jobs.find((j) => j.id === openId);
if (!job) return;
setDetailsJobId(openId);
setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(8, tabIndex)) : 0);
setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(9, tabIndex)) : 0);
setDetailsFollowUpMode(followMode);
paramsSearch.delete("open");
paramsSearch.delete("tab");
navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true });
@@ -413,7 +416,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
</Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); }} />
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} />
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
+38
View File
@@ -176,6 +176,10 @@ export const translations = {
profileMasterCvBody: "Upload a PDF, DOCX, plain text file, markdown file, or image scan. The AI service extracts text where possible and falls back to OCR for supported scanned files.",
profileUploadCv: "Upload CV",
profileCvImprove: "Improve CV text",
profileCvRebuild: "Rebuild CV structure",
profileCvRebuilding: "Rebuilding CV...",
profileCvRebuilt: "CV rebuilt into a cleaner structure.",
profileCvRebuildFailed: "Failed to rebuild CV text.",
profileCvImproving: "Improving CV...",
profileCvImproved: "CV text improved.",
profileCvImproveFailed: "Failed to improve CV text.",
@@ -617,6 +621,7 @@ export const translations = {
jobDetailsTabAttachments: "Attachments",
jobDetailsTabTailoredCv: "Tailored CV",
jobDetailsTabCandidateFit: "Candidate fit",
jobDetailsTabFocusPlan: "Focus plan",
jobDetailsTabInterviewPrep: "Interview prep",
jobDetailsTabHistory: "History",
jobDetailsTailoredCvMode: "Generation mode",
@@ -677,6 +682,13 @@ export const translations = {
jobDetailsRecruiterMessageSaveFailed: "Failed to save recruiter message.",
jobDetailsKeyPoints: "Key points to emphasize",
jobDetailsReason: "Reason",
jobDetailsFollowUpMode: "Follow-up mode",
jobDetailsFollowUpModePostApply: "Post-apply check-in",
jobDetailsFollowUpModeWaiting: "Waiting for update",
jobDetailsFollowUpModePostInterview: "Post-interview follow-up",
jobDetailsFollowUpModeOffer: "Offer / decision check-in",
jobDetailsFollowUpModeFeedback: "Feedback request",
jobDetailsRegenerateDraft: "Regenerate draft",
jobDetailsSuggestedSendDate: "Suggested send date",
jobDetailsRecipient: "Recipient",
jobDetailsRecipientHelp: "Defaults to the company recruiter email when available.",
@@ -701,6 +713,13 @@ export const translations = {
jobDetailsRecruiterMessageGuidance: "Recruiter message guidance",
jobDetailsNoDraftAvailableYet: "No draft available yet.",
jobDetailsCandidateFitEmpty: "Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.",
jobDetailsFocusSummary: "Strategy summary",
jobDetailsImmediatePriorities: "Immediate priorities",
jobDetailsCvBulletIdeas: "CV bullet ideas",
jobDetailsProofPoints: "Proof points to lead with",
jobDetailsCoverLetterAngles: "Cover-letter angles",
jobDetailsFollowUpApproach: "Follow-up approach",
jobDetailsNoFocusPlan: "No focus plan available yet.",
jobDetailsInterviewPrepBrief: "Interview prep brief",
jobDetailsTalkingPoints: "Talking points",
jobDetailsLikelyQuestions: "Likely questions",
@@ -901,6 +920,10 @@ export const translations = {
profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil, markdown-fil eller et bildeskann. AI-tjenesten henter ut tekst der det er mulig og faller tilbake til OCR for støttede skannede filer.",
profileUploadCv: "Last opp CV",
profileCvImprove: "Forbedre CV-tekst",
profileCvRebuild: "Bygg opp CV-struktur på nytt",
profileCvRebuilding: "Bygger opp CV på nytt...",
profileCvRebuilt: "CV bygget opp i en renere struktur.",
profileCvRebuildFailed: "Kunne ikke bygge opp CV-tekst på nytt.",
profileCvImproving: "Forbedrer CV...",
profileCvImproved: "CV-tekst forbedret.",
profileCvImproveFailed: "Kunne ikke forbedre CV-tekst.",
@@ -1342,6 +1365,7 @@ export const translations = {
jobDetailsTabAttachments: "Vedlegg",
jobDetailsTabTailoredCv: "Tilpasset CV",
jobDetailsTabCandidateFit: "Kandidatmatch",
jobDetailsTabFocusPlan: "Fokusplan",
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
jobDetailsTabHistory: "Historikk",
jobDetailsTailoredCvMode: "Genereringsmodus",
@@ -1402,6 +1426,13 @@ export const translations = {
jobDetailsRecruiterMessageSaveFailed: "Kunne ikke lagre melding til rekrutterer.",
jobDetailsKeyPoints: "Nøkkelpunkter å fremheve",
jobDetailsReason: "Årsak",
jobDetailsFollowUpMode: "Oppfølgingsmodus",
jobDetailsFollowUpModePostApply: "Oppfølging etter søknad",
jobDetailsFollowUpModeWaiting: "Venter på oppdatering",
jobDetailsFollowUpModePostInterview: "Oppfølging etter intervju",
jobDetailsFollowUpModeOffer: "Tilbud / beslutningsoppfølging",
jobDetailsFollowUpModeFeedback: "Be om tilbakemelding",
jobDetailsRegenerateDraft: "Generer utkast på nytt",
jobDetailsSuggestedSendDate: "Foreslått sendingsdato",
jobDetailsRecipient: "Mottaker",
jobDetailsRecipientHelp: "Bruker selskapets rekrutterer-e-post som standard når den finnes.",
@@ -1426,6 +1457,13 @@ export const translations = {
jobDetailsRecruiterMessageGuidance: "Veiledning for rekrutterermelding",
jobDetailsNoDraftAvailableYet: "Ingen utkast tilgjengelig ennå.",
jobDetailsCandidateFitEmpty: "Legg til CV-teksten din på profilsiden for å generere en kandidatmatchanalyse for denne rollen.",
jobDetailsFocusSummary: "Strategioppsummering",
jobDetailsImmediatePriorities: "Viktigste prioriteringer nå",
jobDetailsCvBulletIdeas: "Ideer til CV-punkter",
jobDetailsProofPoints: "Bevispunkter å lede med",
jobDetailsCoverLetterAngles: "Vinkler for søknadsbrev",
jobDetailsFollowUpApproach: "Oppfølgingsstrategi",
jobDetailsNoFocusPlan: "Ingen fokusplan tilgjengelig ennå.",
jobDetailsInterviewPrepBrief: "Kort intervjuforberedelse",
jobDetailsTalkingPoints: "Samtalepunkter",
jobDetailsLikelyQuestions: "Sannsynlige spørsmål",
+22 -2
View File
@@ -52,6 +52,7 @@ export default function ProfilePage() {
const [loading, setLoading] = useState(false);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [cropOpen, setCropOpen] = useState(false);
@@ -248,12 +249,31 @@ export default function ProfilePage() {
}
}}
/>
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv} onClick={() => cvInputRef.current?.click()}>
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv}
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRebuildingCv(true);
try {
const res = await api.post<{ text?: string }>("/profile-cv/rebuild");
if (res.data?.text) setProfileCvText(res.data.text);
await loadProfile();
toast(t("profileCvRebuilt"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
} finally {
setRebuildingCv(false);
}
}}
>
{rebuildingCv ? t("profileCvRebuilding") : t("profileCvRebuild")}
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setImprovingCv(true);
try {
+9
View File
@@ -75,6 +75,15 @@ export interface CandidateFit {
recruiterMessageDraft?: string | null;
}
export interface FocusPlanResponse {
immediatePriorities: string[];
cvBulletIdeas: string[];
proofPointsToLeadWith: string[];
coverLetterAngles: string[];
followUpApproach: string[];
strategicSummary: string;
}
export interface InterviewPrepResponse {
summary: string;
talkingPoints: string[];