Optimize workspace and daily-loop surfaces
This commit is contained in:
@@ -28,26 +28,12 @@ import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
import JobFlowBar from "./JobFlowBar";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type AttachmentItem = {
|
||||
id: number;
|
||||
fileName: string;
|
||||
uploadDate: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
purpose?: string | null;
|
||||
useForAi: boolean;
|
||||
};
|
||||
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
|
||||
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
|
||||
|
||||
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
|
||||
|
||||
type PackageWorkspaceState = {
|
||||
coverLetter: string;
|
||||
applicationAnswer: string;
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
type TailoredCvPreviewResponse = {
|
||||
templateId: string;
|
||||
html: string;
|
||||
@@ -91,20 +77,6 @@ function copyLines(items: string[]) {
|
||||
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
function extractApplicationAnswerDraft(notes?: string | null) {
|
||||
const value = (notes ?? "").trim();
|
||||
if (!value) return "";
|
||||
|
||||
const startIndex = value.indexOf(APPLICATION_ANSWER_START);
|
||||
const endIndex = value.indexOf(APPLICATION_ANSWER_END);
|
||||
if (startIndex >= 0 && endIndex > startIndex) {
|
||||
return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim();
|
||||
}
|
||||
|
||||
const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i);
|
||||
return legacyMatch?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) {
|
||||
const trimmedNotes = (notes ?? "").trim();
|
||||
const trimmedDraft = draft.trim();
|
||||
@@ -156,11 +128,41 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
|
||||
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
|
||||
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
|
||||
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
|
||||
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
|
||||
const tailoredDraftCache = useWorkspaceTabCache<TailoredCvDraft>();
|
||||
|
||||
const {
|
||||
job,
|
||||
setJob,
|
||||
tab,
|
||||
setTab,
|
||||
history,
|
||||
isAdmin,
|
||||
jobAttachments,
|
||||
selectedAttachmentIds,
|
||||
setSelectedAttachmentIds,
|
||||
profileAvatarImageDataUrl,
|
||||
packageWorkspace,
|
||||
setPackageWorkspace,
|
||||
savedPackageWorkspace,
|
||||
setSavedPackageWorkspace,
|
||||
packageGeneratedAt,
|
||||
setPackageGeneratedAt,
|
||||
draftRecipient,
|
||||
setDraftRecipient,
|
||||
followUpMode,
|
||||
setFollowUpMode,
|
||||
} = useJobWorkspaceBaseData({
|
||||
open,
|
||||
jobId,
|
||||
initialTab,
|
||||
initialFollowUpMode,
|
||||
});
|
||||
|
||||
const [job, setJob] = useState<JobApplication | null>(null);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
|
||||
const [loadingDraft, setLoadingDraft] = useState(false);
|
||||
const [sendingDraft, setSendingDraft] = useState(false);
|
||||
@@ -174,8 +176,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
|
||||
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
||||
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||
@@ -189,14 +189,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [tailoredCvPreview, setTailoredCvPreview] = useState<TailoredCvPreviewResponse | null>(null);
|
||||
const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false);
|
||||
const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false);
|
||||
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
|
||||
const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(null);
|
||||
const [useProfilePhoto, setUseProfilePhoto] = useState(true);
|
||||
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||
const [draftRecipient, setDraftRecipient] = useState("");
|
||||
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
|
||||
const [draftReloadToken, setDraftReloadToken] = useState(0);
|
||||
const [draftSubject, setDraftSubject] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
@@ -204,60 +198,43 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
setTab(Math.max(0, Math.min(9, initialTab)));
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
setFocusPlan(null);
|
||||
setInterviewPrep(null);
|
||||
setReadiness(null);
|
||||
setApplicationPackage(null);
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
setPackageGeneratedAt(null);
|
||||
setTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setSavedTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setTailoredCvPreview(null);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
setCustomPhotoDataUrl(null);
|
||||
setUseProfilePhoto(true);
|
||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
const savedWorkspace = {
|
||||
coverLetter: r.data.coverLetterText ?? "",
|
||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||
recruiterMessage: r.data.recruiterMessageDraft ?? "",
|
||||
};
|
||||
setSavedPackageWorkspace(savedWorkspace);
|
||||
setPackageWorkspace(savedWorkspace);
|
||||
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<AttachmentItem[]>(`/attachments/${jobId}`).then((r) => {
|
||||
const items = Array.isArray(r.data) ? r.data : [];
|
||||
setJobAttachments(items);
|
||||
const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id);
|
||||
setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id));
|
||||
}).catch(() => {
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
});
|
||||
api.get(`/auth/me`).then((r) => {
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
|
||||
}).catch(() => {
|
||||
setIsAdmin(false);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
});
|
||||
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||
}, [open, jobId, initialTab, initialFollowUpMode]);
|
||||
setDraftReloadToken(0);
|
||||
setDraftSubject("");
|
||||
setDraftBody("");
|
||||
followUpCache.clearCached();
|
||||
candidateFitCache.clearCached();
|
||||
focusPlanCache.clearCached();
|
||||
interviewPrepCache.clearCached();
|
||||
readinessCache.clearCached();
|
||||
tailoredDraftCache.clearCached();
|
||||
}, [open, jobId, followUpCache, candidateFitCache, focusPlanCache, interviewPrepCache, readinessCache, tailoredDraftCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 3) return;
|
||||
const cacheKey = `${jobId}:tailored-cv-draft`;
|
||||
const cached = tailoredDraftCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
const normalized = normalizeTailoredCvDraft(cached);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingTailoredCvDraft(true);
|
||||
api.get<TailoredCvDraft>(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => {
|
||||
const normalized = normalizeTailoredCvDraft(r.data);
|
||||
tailoredDraftCache.setCached(cacheKey, normalized);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
}).catch(() => {
|
||||
@@ -265,35 +242,75 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setTailoredCvDraft(empty);
|
||||
setSavedTailoredCvDraft(empty);
|
||||
}).finally(() => setLoadingTailoredCvDraft(false));
|
||||
}, [open, jobId, tab]);
|
||||
}, [open, jobId, tab, tailoredDraftCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4) return;
|
||||
const cacheKey = `${jobId}:followup:${followUpMode}:${selectedAttachmentCsv || "none"}:${draftReloadToken}`;
|
||||
const cached = followUpCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setFollowUpDraft(cached);
|
||||
setDraftSubject(cached.subject);
|
||||
setDraftBody(cached.body);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingDraft(true);
|
||||
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
followUpCache.setCached(cacheKey, r.data);
|
||||
setFollowUpDraft(r.data);
|
||||
setDraftSubject(r.data.subject);
|
||||
setDraftBody(r.data.body);
|
||||
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
|
||||
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv]);
|
||||
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || candidateFit) return;
|
||||
const cacheKey = `${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = candidateFitCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setCandidateFit(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingCandidateFit(true);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv]);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
candidateFitCache.setCached(cacheKey, r.data);
|
||||
setCandidateFit(r.data);
|
||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = focusPlanCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setFocusPlan(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingFocusPlan(true);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv]);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
focusPlanCache.setCached(cacheKey, r.data);
|
||||
setFocusPlan(r.data);
|
||||
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = interviewPrepCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setInterviewPrep(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv]);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { 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]);
|
||||
|
||||
useEffect(() => {
|
||||
setFollowUpDraft(null);
|
||||
@@ -304,9 +321,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 8 || readiness) return;
|
||||
const cacheKey = `${jobId}:readiness`;
|
||||
const cached = readinessCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setReadiness(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingReadiness(true);
|
||||
api.get<ReadinessResponse>(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false));
|
||||
}, [open, jobId, tab, readiness]);
|
||||
api.get<ReadinessResponse>(`/jobapplications/${jobId}/readiness`).then((r) => {
|
||||
readinessCache.setCached(cacheKey, r.data);
|
||||
setReadiness(r.data);
|
||||
}).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false));
|
||||
}, [open, jobId, tab, readiness, readinessCache]);
|
||||
|
||||
const tags: string[] = (() => {
|
||||
const raw = job?.tags;
|
||||
@@ -392,6 +419,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
});
|
||||
tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
@@ -399,6 +427,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
tailoredCvText: normalized.renderedText,
|
||||
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||
} : prev);
|
||||
readinessCache.clearCached();
|
||||
setReadiness(null);
|
||||
toast("Tailored CV draft saved.", "success");
|
||||
} catch (error: any) {
|
||||
@@ -422,6 +451,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setGeneratingTailoredCvDraft(true);
|
||||
const res = await api.post<TailoredCvDraft>(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } });
|
||||
const normalized = normalizeTailoredCvDraft(res.data);
|
||||
tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
@@ -519,6 +549,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
notes: nextNotes,
|
||||
} : prev);
|
||||
setSavedPackageWorkspace({ ...packageWorkspace });
|
||||
readinessCache.clearCached();
|
||||
interviewPrepCache.clearCached();
|
||||
setReadiness(null);
|
||||
setInterviewPrep(null);
|
||||
toast("Application package saved to this job.", "success");
|
||||
@@ -578,6 +610,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||
]);
|
||||
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, fitRes.data);
|
||||
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, focusRes.data);
|
||||
setCandidateFit(fitRes.data);
|
||||
setFocusPlan(focusRes.data);
|
||||
} catch {
|
||||
@@ -1007,6 +1041,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
try {
|
||||
await api.post(`/jobapplications/${jobId}/send-followup`, { toEmail: draftRecipient || null, subject: draftSubject, body: draftBody, nextFollowUpAt: followUpDraft.suggestedSendOn || null });
|
||||
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
|
||||
readinessCache.clearCached();
|
||||
setReadiness(null);
|
||||
toast(t("jobDetailsFollowUpSent"), "success");
|
||||
} catch (error: any) {
|
||||
|
||||
Reference in New Issue
Block a user