feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
@@ -36,6 +37,7 @@ import GradientButton from "./GradientButton";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
|
||||
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
|
||||
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
|
||||
@@ -134,6 +136,7 @@ function serializeTailoredDraft(draft: TailoredCvDraft) {
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode, onOpenWorkspace }: Props) {
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
@@ -261,7 +264,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}, [open, jobId, tab, tailoredDraftCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4) return;
|
||||
if (!canUseAi || !open || !jobId || tab !== 4) return;
|
||||
const cacheKey = `${jobId}:followup:${followUpMode}:${selectedAttachmentCsv || "none"}:${draftReloadToken}`;
|
||||
const cached = followUpCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
@@ -278,10 +281,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setDraftSubject(r.data.subject);
|
||||
setDraftBody(r.data.body);
|
||||
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
|
||||
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
|
||||
}, [canUseAi, open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || candidateFit) return;
|
||||
if (!canUseAi || !open || !jobId || tab !== 5 || candidateFit) return;
|
||||
const cacheKey = `${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = candidateFitCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
@@ -294,19 +297,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
candidateFitCache.setCached(cacheKey, r.data);
|
||||
setCandidateFit(r.data);
|
||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||
}, [canUseAi, 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;
|
||||
if (!canUseAi || !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]);
|
||||
}, [canUseAi, 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.
|
||||
@@ -370,7 +373,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||
if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return;
|
||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = focusPlanCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
@@ -383,20 +386,20 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
focusPlanCache.setCached(cacheKey, r.data);
|
||||
setFocusPlan(r.data);
|
||||
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||
}, [canUseAi, open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||
|
||||
const regenerateFocusPlan = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
if (!canUseAi || !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]);
|
||||
}, [canUseAi, jobId, selectedAttachmentCsv, focusPlanCache, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
if (!canUseAi || !open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = interviewPrepCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
@@ -405,24 +408,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}
|
||||
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep/brief`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
interviewPrepCache.setCached(cacheKey, r.data);
|
||||
setInterviewPrep(r.data);
|
||||
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
|
||||
}, [canUseAi, 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;
|
||||
if (!canUseAi || !jobId) return;
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep/brief`, { 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]);
|
||||
}, [canUseAi, jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
setFollowUpDraft(null);
|
||||
@@ -755,7 +758,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 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" }}>
|
||||
<Typography variant="overline" sx={{ fontWeight: 700 }}>{t("jobDetailsStrategySnapshot")}</Typography>
|
||||
<GradientButton size="small" disabled={loadingStrategySnapshot} onClick={async () => {
|
||||
<GradientButton size="small" disabled={loadingStrategySnapshot || !canUseAi} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setLoadingStrategySnapshot(true);
|
||||
try {
|
||||
@@ -772,7 +775,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
} finally {
|
||||
setLoadingStrategySnapshot(false);
|
||||
}
|
||||
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : t("jobDetailsGenerateStrategySnapshot")}</GradientButton>
|
||||
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsGenerateStrategySnapshot") : "Pro required"}</GradientButton>
|
||||
</Box>
|
||||
{candidateFit || focusPlan ? (
|
||||
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 4, backgroundColor: "background.paper", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
@@ -802,7 +805,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}>
|
||||
<Typography variant="overline">{t("jobDetailsSummaryAndSkills")}</Typography>
|
||||
<Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => {
|
||||
<Button size="small" variant="outlined" disabled={refreshingAi || !canUseAi} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
if (!(await confirmAction(t("jobDetailsRefreshAiConfirm"), { title: t("jobDetailsRefreshAiTitle"), confirmLabel: t("jobDetailsRefreshAi") }))) return;
|
||||
setRefreshingAi(true);
|
||||
@@ -815,7 +818,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
} finally {
|
||||
setRefreshingAi(false);
|
||||
}
|
||||
}}>{refreshingAi ? t("jobDetailsRefreshing") : t("jobDetailsRefreshAi")}</Button>
|
||||
}}>{refreshingAi ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsRefreshAi") : "Pro required"}</Button>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
|
||||
</Box>
|
||||
@@ -896,7 +899,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}} />
|
||||
</Button>
|
||||
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
|
||||
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!canUseAi || loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : canUseAi ? "Generate tailored draft" : "Pro required"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
|
||||
@@ -1074,7 +1077,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
|
||||
<Button size="small" variant="outlined" disabled={generatingPackage || !canUseAi} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setGeneratingPackage(true);
|
||||
try {
|
||||
@@ -1092,7 +1095,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
} finally {
|
||||
setGeneratingPackage(false);
|
||||
}
|
||||
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
|
||||
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : canUseAi ? t("jobDetailsGeneratePackage") : "Pro required"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
|
||||
<Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
|
||||
</Box>
|
||||
@@ -1209,6 +1212,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!canUseAi && [4, 5, 6, 7].includes(tab) && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI assistance on this tab requires Pro. Non-AI job data and manual editing remain available.</Alert>}
|
||||
<JobInsightTabs
|
||||
tab={tab}
|
||||
matchScore={matchScore}
|
||||
|
||||
Reference in New Issue
Block a user