Complete S02 application package drafting loop

This commit is contained in:
2026-03-24 10:36:05 +01:00
parent 3e5f796326
commit b5b430947b
14 changed files with 864 additions and 152 deletions
@@ -47,6 +47,13 @@ type FollowUpDraft = {
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
type PackageDraftKind = "tailoredCv" | "coverLetter" | "applicationAnswer" | "recruiterMessage";
type PackageWorkspaceState = {
coverLetter: string;
applicationAnswer: string;
recruiterMessage: string;
};
interface Props {
open: boolean;
@@ -82,6 +89,54 @@ function copyLines(items: string[]) {
return navigator.clipboard.writeText(items.map((item) => `${item}`).join("\n"));
}
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();
const block = trimmedDraft
? `${APPLICATION_ANSWER_START}\n${trimmedDraft}\n${APPLICATION_ANSWER_END}`
: "";
if (!trimmedNotes) return block;
const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g");
if (markerPattern.test(trimmedNotes)) {
return block ? trimmedNotes.replace(markerPattern, block).trim() : trimmedNotes.replace(markerPattern, "").trim();
}
const legacyPattern = /(?:\n\n)?Application answer draft:\s*\n[\s\S]*$/i;
if (legacyPattern.test(trimmedNotes)) {
return block ? trimmedNotes.replace(legacyPattern, `\n\n${block}`).trim() : trimmedNotes.replace(legacyPattern, "").trim();
}
return block ? `${trimmedNotes}\n\n${block}` : trimmedNotes;
}
function getWorkspaceStatus(currentValue: string, savedValue: string) {
const current = currentValue.trim();
const saved = savedValue.trim();
if (current && current !== saved) return { label: "Unsaved edits", color: "warning" as const };
if (saved) return { label: "Saved to job", color: "success" as const };
if (current) return { label: "Generated only", color: "default" as const };
return { label: "Empty", color: "default" as const };
}
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
const { toast } = useToast();
const { t } = useI18n();
@@ -113,6 +168,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
const [tailoredCvText, setTailoredCvText] = useState("");
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);
@@ -131,9 +189,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setApplicationPackage(null);
setJobAttachments([]);
setSelectedAttachmentIds([]);
setPackageGeneratedAt(null);
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
setJob(r.data);
setTailoredCvText(r.data.tailoredCvText ?? "");
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"));
});
@@ -243,6 +311,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box>
) : null;
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? "");
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
const hasUnsavedPackageChanges = [
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
].some(Boolean);
const savePackageWorkspace = async () => {
if (!jobId || !job) return;
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
const draftsChanged =
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
if (!tailoredCvChanged && !draftsChanged) {
toast("No unsaved package changes.", "info");
return;
}
try {
if (tailoredCvChanged) {
setSavingTailoredCv(true);
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
}
if (draftsChanged) {
setSavingApplicationDrafts(true);
await api.put(`/jobapplications/${jobId}/application-drafts`, {
coverLetterText: packageWorkspace.coverLetter,
notes: nextNotes,
recruiterMessageDraft: packageWorkspace.recruiterMessage,
});
}
setJob((prev) => prev ? {
...prev,
tailoredCvText,
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
coverLetterText: packageWorkspace.coverLetter,
recruiterMessageDraft: packageWorkspace.recruiterMessage,
notes: nextNotes,
} : prev);
setSavedPackageWorkspace({ ...packageWorkspace });
setReadiness(null);
setInterviewPrep(null);
toast("Application package saved to this job.", "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
} finally {
setSavingTailoredCv(false);
setSavingApplicationDrafts(false);
}
};
const resetPackageWorkspaceToSaved = () => {
setTailoredCvText(job?.tailoredCvText ?? "");
setPackageWorkspace(savedPackageWorkspace);
toast("Restored the last saved package.", "info");
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
<DialogTitle sx={{ pb: 1 }}>
@@ -362,9 +497,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 3 && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
<Box>
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>{t("jobDetailsTailoredCvMode")}</InputLabel>
@@ -401,6 +539,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
setApplicationPackage(res.data);
setTailoredCvText(res.data.tailoredCvText ?? "");
setPackageWorkspace({
coverLetter: res.data.coverLetterDraft ?? "",
applicationAnswer: res.data.applicationAnswerDraft ?? "",
recruiterMessage: res.data.recruiterMessageDraft ?? "",
});
setPackageGeneratedAt(new Date().toISOString());
toast(t("jobDetailsPackageGenerated"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("jobDetailsPackageGenerationFailed")), "error");
@@ -408,78 +552,59 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setGeneratingPackage(false);
}
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
<Button size="small" variant="outlined" onClick={() => setTailoredCvText("")}>{t("jobDetailsClear")}</Button>
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(tailoredCvText)}>{t("jobDetailsCopy")}</Button>
<Button size="small" variant="contained" disabled={savingTailoredCv} onClick={async () => {
if (!jobId) return;
setSavingTailoredCv(true);
try {
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
setJob((prev) => prev ? { ...prev, tailoredCvText, tailoredCvUpdatedAt: new Date().toISOString() } : prev);
setReadiness(null);
setInterviewPrep(null);
toast(t("jobDetailsTailoredCvSaved"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("jobDetailsTailoredCvSaveFailed")), "error");
} finally {
setSavingTailoredCv(false);
}
}}>{savingTailoredCv ? t("jobDetailsSaving") : t("jobDetailsSaveTailoredCv")}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
<Button size="small" variant="contained" disabled={savingTailoredCv || savingApplicationDrafts} onClick={savePackageWorkspace}>{savingTailoredCv || savingApplicationDrafts ? t("jobDetailsSaving") : "Save package to job"}</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
<Chip size="small" label={`Tailored CV · ${tailoredCvStatus.label}`} color={tailoredCvStatus.color} />
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box>
{applicationPackage ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={applicationPackage.coverLetterDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
if (!jobId) return;
setSavingApplicationDrafts(true);
try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: content });
setJob((prev) => prev ? { ...prev, coverLetterText: content } : prev);
setReadiness(null);
toast(t("jobDetailsCoverLetterSaved"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("jobDetailsCoverLetterSaveFailed")), "error");
} finally {
setSavingApplicationDrafts(false);
}
}} saving={savingApplicationDrafts} />
<DraftCard title={t("jobDetailsShortApplicationAnswer")} content={applicationPackage.applicationAnswerDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
if (!jobId) return;
setSavingApplicationDrafts(true);
try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { notes: `Application answer draft:\n${content}` });
setReadiness(null);
toast(t("jobDetailsApplicationAnswerSaved"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("jobDetailsApplicationAnswerSaveFailed")), "error");
} finally {
setSavingApplicationDrafts(false);
}
}} saving={savingApplicationDrafts} />
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={applicationPackage.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
if (!jobId) return;
setSavingApplicationDrafts(true);
try {
await api.put(`/jobapplications/${jobId}/application-drafts`, { recruiterMessageDraft: content });
setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev);
toast(t("jobDetailsRecruiterMessageSaved"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("jobDetailsRecruiterMessageSaveFailed")), "error");
} finally {
setSavingApplicationDrafts(false);
}
}} saving={savingApplicationDrafts} />
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage.keyPoints} />
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage.coverLetterVariants.length > 0 ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage.recruiterMessageVariants.length > 0 ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage.attachmentSignals.length > 0 ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage.attachmentFilesUsed.length > 0 ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<WorkspaceDraftCard
title={t("jobDetailsCoverLetterDraft")}
value={packageWorkspace.coverLetter}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
statusLabel={coverLetterStatus.label}
statusColor={coverLetterStatus.color}
/>
<WorkspaceDraftCard
title={t("jobDetailsShortApplicationAnswer")}
value={packageWorkspace.applicationAnswer}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
statusLabel={applicationAnswerStatus.label}
statusColor={applicationAnswerStatus.color}
/>
<WorkspaceDraftCard
title={t("jobDetailsRecruiterMessageDraft")}
value={packageWorkspace.recruiterMessage}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
statusLabel={recruiterMessageStatus.label}
statusColor={recruiterMessageStatus.color}
/>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Typography variant="overline">Saved working material</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what later slices can trust and reuse.</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
</Box>
</Box>
) : null}
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
</Box>
</Box>
)}
@@ -653,6 +778,23 @@ function ListCard({ title, items, subtitle }: { title: string; items: string[];
);
}
function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
const { t } = useI18n();
return (
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
<Chip size="small" color={statusColor} label={statusLabel} />
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
</Box>
</Box>
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
</Box>
);
}
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
const { t } = useI18n();
const [value, setValue] = React.useState(content);