Add focus plans and stage-aware follow-up drafting
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
Reference in New Issue
Block a user