Optimize workspace and daily-loop surfaces

This commit is contained in:
2026-04-11 12:03:49 +02:00
parent 27fd70a2d7
commit 33ac4b963b
7 changed files with 474 additions and 224 deletions
@@ -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) {