feat: integrate Career Workspace foundation from feature/career-workspace
Recover the F1 Career Profile foundation + AI-workspace persistence from the unmerged feature/career-workspace branch, so Phase 2 builds on the documented, tested target state instead of re-deriving it. Foundation only — CV Builder commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV Builder yet". See docs/career-workspace-branch-assessment.md. Squashed from 3 branch commits (235e291,5916f09,00a035e), resolved against main + Phase 0: - CareerProfile + CareerProfileVersion (append-only history), dual-written from every profile save path via CareerProfileService. ApplicationUser. ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item IDs assigned to jobs/education/certifications/projects (the prerequisite for future variant lineage). CvDateNormalizer for free-text -> YYYY-MM. - InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit / focus plan keyed by an attachment-context signature, so they stop regenerating (and re-spending the provider) on every open. Conflict resolutions (union, favouring current code + Phase 0): - JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and reconciler blocks, added the career/interview/ai-note tables (both SQLite and MySQL dialects). - ProfileCvController: dropped the branch's in-file DTO records (main defines them in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred ATS-badge work), keeping main's 7-arg CvTemplateDescriptor. - JobApplicationsController: kept the branch's cache-check, restored main's AsNoTracking on the read-only user load. Tables ship empty (verified dev); nothing to migrate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -290,6 +290,18 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||
|
||||
// Persisted server-side like interview prep (career-workspace-implementation-roadmap.md Phase
|
||||
// F5); Regenerate is the explicit escape hatch when the job has changed since it was written.
|
||||
const regenerateCandidateFit = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
setLoadingCandidateFit(true);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, r.data);
|
||||
setCandidateFit(r.data);
|
||||
toast("Candidate fit regenerated.", "success");
|
||||
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate candidate fit."), "error")).finally(() => setLoadingCandidateFit(false));
|
||||
}, [jobId, selectedAttachmentCsv, candidateFitCache, toast]);
|
||||
|
||||
// Match score is deterministic and cheap: load it on the Candidate Fit tab
|
||||
// independently of the slow AI narrative so users see the number instantly.
|
||||
useEffect(() => {
|
||||
@@ -349,6 +361,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||
|
||||
const regenerateFocusPlan = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
setLoadingFocusPlan(true);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data);
|
||||
setFocusPlan(r.data);
|
||||
toast("Focus plan regenerated.", "success");
|
||||
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false));
|
||||
}, [jobId, selectedAttachmentCsv, focusPlanCache, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
|
||||
@@ -365,6 +387,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
|
||||
|
||||
// Interview prep is now persisted server-side (career-workspace-implementation-roadmap.md
|
||||
// Phase F5) so it survives tab switches without re-running the AI call. Regenerate is the
|
||||
// explicit escape hatch for when the underlying job/notes have changed since it was written.
|
||||
const regenerateInterviewPrep = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
interviewPrepCache.setCached(`${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`, r.data);
|
||||
setInterviewPrep(r.data);
|
||||
toast("Interview prep regenerated.", "success");
|
||||
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate interview prep."), "error")).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
@@ -1146,6 +1181,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
||||
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||
@@ -1174,6 +1214,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 6 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
||||
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</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} />
|
||||
@@ -1187,6 +1232,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 7 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
|
||||
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
||||
|
||||
Reference in New Issue
Block a user