Complete S02 application package drafting loop
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -46,7 +46,19 @@ beforeEach(() => {
|
||||
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' }, tailoredCvText: '', shortSummary: 'summary' } } as any);
|
||||
return Promise.resolve({ data: {
|
||||
id: 42,
|
||||
jobTitle: 'Backend Developer',
|
||||
status: 'Applied',
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 3,
|
||||
company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' },
|
||||
tailoredCvText: 'Saved CV',
|
||||
coverLetterText: 'Saved cover letter',
|
||||
recruiterMessageDraft: 'Saved recruiter message',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved application answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
shortSummary: 'summary'
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
||||
@@ -73,25 +85,40 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('generated application package can be edited and saved', async () => {
|
||||
test('application package workspace reflects saved job material, generated drafts, and save state', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
expect(await screen.findByRole('button', { name: /generate application package/i })).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate application package/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/cover letter variants/i)).toBeInTheDocument();
|
||||
const coverLetter = await screen.findByDisplayValue('Draft letter');
|
||||
fireEvent.change(coverLetter, { target: { value: 'Edited cover letter' } });
|
||||
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
||||
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
|
||||
|
||||
const saveButtons = await screen.findAllByRole('button', { name: /^save$/i });
|
||||
fireEvent.click(saveButtons[0]);
|
||||
fireEvent.change(coverLetter, { target: { value: 'Edited cover letter' } });
|
||||
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
|
||||
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package to job/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', { coverLetterText: 'Edited cover letter' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
||||
coverLetterText: 'Edited cover letter',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
recruiterMessageDraft: 'Edited recruiter note',
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
test('strategy snapshot can be generated from overview', async () => {
|
||||
|
||||
Reference in New Issue
Block a user