Add attachment-aware AI drafting and CV section tools

This commit is contained in:
cesnimda
2026-03-23 22:17:03 +01:00
parent 8db620e45b
commit 0c8258e90f
7 changed files with 316 additions and 17 deletions
@@ -36,6 +36,7 @@ type FollowUpDraft = {
};
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
interface Props {
open: boolean;
@@ -97,6 +98,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [generatingPackage, setGeneratingPackage] = useState(false);
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
const [tailoredCvText, setTailoredCvText] = useState("");
const [draftRecipient, setDraftRecipient] = useState("");
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
@@ -279,6 +281,15 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
<MenuItem value="balanced">{t("jobDetailsCoverLetterStyleBalanced")}</MenuItem>
<MenuItem value="concise">{t("jobDetailsCoverLetterStyleConcise")}</MenuItem>
<MenuItem value="formal">{t("jobDetailsCoverLetterStyleFormal")}</MenuItem>
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
</Select>
</FormControl>
<Button size="small" variant="outlined" onClick={async () => {
try {
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
@@ -292,7 +303,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
if (!jobId) return;
setGeneratingPackage(true);
try {
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode } });
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle } });
setApplicationPackage(res.data);
setTailoredCvText(res.data.tailoredCvText ?? "");
toast(t("jobDetailsPackageGenerated"), "success");
@@ -369,6 +380,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}
}} saving={savingApplicationDrafts} />
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage.keyPoints} />
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage.attachmentSignals.length > 0 ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage.attachmentFilesUsed.length > 0 ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
</Box>
) : null}
</Box>
@@ -525,13 +537,16 @@ function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { le
);
}
function ListCard({ title, items }: { title: string; items: string[] }) {
function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
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>
<Typography variant="overline">{title}</Typography>
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
</Box>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
+56
View File
@@ -190,6 +190,27 @@ export const translations = {
profileCvTextLabel: "Profile CV / master resume text",
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
profileCvSectionTools: "Section rewrite tools",
profileCvSectionToolsHelp: "Generate sharper versions of one CV section at a time before pasting them back into your master CV.",
profileCvSectionLabel: "Section",
profileCvSectionSummary: "Professional Summary",
profileCvSectionSkills: "Core Skills",
profileCvSectionExperience: "Experience Highlights",
profileCvSectionAchievements: "Selected Achievements",
profileCvSectionProjects: "Projects",
profileCvSectionStyle: "Rewrite style",
profileCvSectionStyleBalanced: "Balanced",
profileCvSectionStyleConcise: "Concise",
profileCvSectionStyleImpact: "Impact-focused",
profileCvSectionStyleAts: "ATS-friendly",
profileCvSectionTargetRole: "Target role (optional)",
profileCvSectionRewrite: "Rewrite section",
profileCvSectionRewriting: "Rewriting section...",
profileCvSectionRewritten: "CV section draft generated.",
profileCvSectionRewriteFailed: "Failed to rewrite the CV section.",
profileCvSectionDraft: "Section draft",
profileCvSectionDraftPlaceholder: "Your rewritten section will appear here.",
profileCvSectionAppend: "Append to CV text",
profileSaveChanges: "Save changes",
profileUpdated: "Profile updated.",
profileUpdateFailed: "Failed to update profile.",
@@ -630,6 +651,11 @@ export const translations = {
jobDetailsGenerationAts: "ATS focused",
jobDetailsGenerationAchievement: "Achievement focused",
jobDetailsGenerationInterview: "Interview focused",
jobDetailsCoverLetterStyle: "Cover letter style",
jobDetailsCoverLetterStyleBalanced: "Balanced",
jobDetailsCoverLetterStyleConcise: "Concise",
jobDetailsCoverLetterStyleFormal: "Formal",
jobDetailsCoverLetterStyleBold: "Bold",
jobDetailsResume: "Resume",
jobDetailsCoverLetter: "Cover letter",
jobDetailsPortfolio: "Portfolio",
@@ -681,6 +707,8 @@ export const translations = {
jobDetailsRecruiterMessageSaved: "Recruiter message saved to this job.",
jobDetailsRecruiterMessageSaveFailed: "Failed to save recruiter message.",
jobDetailsKeyPoints: "Key points to emphasize",
jobDetailsAttachmentSignals: "Attachment-derived signals",
jobDetailsNoAttachmentSignals: "No reusable attachment signals were found yet.",
jobDetailsReason: "Reason",
jobDetailsFollowUpMode: "Follow-up mode",
jobDetailsFollowUpModePostApply: "Post-apply check-in",
@@ -934,6 +962,27 @@ export const translations = {
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
profileCvSectionTools: "Verktøy for CV-seksjoner",
profileCvSectionToolsHelp: "Generer skarpere versjoner av én CV-seksjon om gangen før du limer dem tilbake i hoved-CV-en.",
profileCvSectionLabel: "Seksjon",
profileCvSectionSummary: "Profesjonell oppsummering",
profileCvSectionSkills: "Kjernekompetanse",
profileCvSectionExperience: "Erfaringshøydepunkter",
profileCvSectionAchievements: "Utvalgte prestasjoner",
profileCvSectionProjects: "Prosjekter",
profileCvSectionStyle: "Omskrivingsstil",
profileCvSectionStyleBalanced: "Balansert",
profileCvSectionStyleConcise: "Kortfattet",
profileCvSectionStyleImpact: "Effektfokusert",
profileCvSectionStyleAts: "ATS-vennlig",
profileCvSectionTargetRole: "Målrolle (valgfritt)",
profileCvSectionRewrite: "Omskriv seksjon",
profileCvSectionRewriting: "Omskriver seksjon...",
profileCvSectionRewritten: "CV-seksjon generert.",
profileCvSectionRewriteFailed: "Kunne ikke omskrive CV-seksjonen.",
profileCvSectionDraft: "Seksjonsutkast",
profileCvSectionDraftPlaceholder: "Den omskrevne seksjonen vises her.",
profileCvSectionAppend: "Legg til i CV-teksten",
profileSaveChanges: "Lagre endringer",
profileUpdated: "Profil oppdatert.",
profileUpdateFailed: "Kunne ikke oppdatere profil.",
@@ -1374,6 +1423,11 @@ export const translations = {
jobDetailsGenerationAts: "ATS-fokusert",
jobDetailsGenerationAchievement: "Prestasjonfokusert",
jobDetailsGenerationInterview: "Intervjufokusert",
jobDetailsCoverLetterStyle: "Stil for søknadsbrev",
jobDetailsCoverLetterStyleBalanced: "Balansert",
jobDetailsCoverLetterStyleConcise: "Kortfattet",
jobDetailsCoverLetterStyleFormal: "Formell",
jobDetailsCoverLetterStyleBold: "Modig",
jobDetailsResume: "CV",
jobDetailsCoverLetter: "Søknadsbrev",
jobDetailsPortfolio: "Portefølje",
@@ -1425,6 +1479,8 @@ export const translations = {
jobDetailsRecruiterMessageSaved: "Melding til rekrutterer lagret på denne jobben.",
jobDetailsRecruiterMessageSaveFailed: "Kunne ikke lagre melding til rekrutterer.",
jobDetailsKeyPoints: "Nøkkelpunkter å fremheve",
jobDetailsAttachmentSignals: "Signal fra vedlegg",
jobDetailsNoAttachmentSignals: "Ingen gjenbrukbare signaler fra vedlegg ble funnet ennå.",
jobDetailsReason: "Årsak",
jobDetailsFollowUpMode: "Oppfølgingsmodus",
jobDetailsFollowUpModePostApply: "Oppfølging etter søknad",
+74 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
import { Alert, Avatar, Box, Button, Chip, Divider, FormControl, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
@@ -11,6 +11,9 @@ import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -64,7 +67,11 @@ export default function ProfilePage() {
const [displayName, setDisplayName] = useState("");
const [headline, setHeadline] = useState("");
const [profileCvText, setProfileCvText] = useState("");
const [rewritingSection, setRewritingSection] = useState(false);
const [cvSection, setCvSection] = useState<CvSectionOption>("Professional Summary");
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("balanced");
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
const [cvSectionDraft, setCvSectionDraft] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@@ -306,6 +313,71 @@ export default function ProfilePage() {
disabled={!isLocal}
fullWidth
/>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvSectionTools")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
</Box>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRewritingSection(true);
try {
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
sectionName: cvSection,
style: cvSectionStyle,
targetRole: cvSectionTargetRole.trim() || null,
});
setCvSectionDraft(res.data?.text ?? "");
toast(t("profileCvSectionRewritten"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
} finally {
setRewritingSection(false);
}
}}
>
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
</Button>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1.2fr" }, gap: 1.5, mb: 1.5 }}>
<FormControl fullWidth size="small">
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
<MenuItem value="Selected Achievements">{t("profileCvSectionAchievements")}</MenuItem>
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>{t("profileCvSectionStyle")}</InputLabel>
<Select value={cvSectionStyle} label={t("profileCvSectionStyle")} onChange={(e) => setCvSectionStyle(e.target.value as CvSectionStyle)}>
<MenuItem value="balanced">{t("profileCvSectionStyleBalanced")}</MenuItem>
<MenuItem value="concise">{t("profileCvSectionStyleConcise")}</MenuItem>
<MenuItem value="impact">{t("profileCvSectionStyleImpact")}</MenuItem>
<MenuItem value="ats">{t("profileCvSectionStyleAts")}</MenuItem>
</Select>
</FormControl>
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth />
</Box>
<TextField
label={t("profileCvSectionDraft")}
value={cvSectionDraft}
onChange={(e) => setCvSectionDraft(e.target.value)}
multiline
minRows={6}
fullWidth
placeholder={t("profileCvSectionDraftPlaceholder")}
/>
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim())}>{t("profileCvSectionAppend")}</Button>
</Box>
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{cvWordCount} words
+2
View File
@@ -105,6 +105,8 @@ export interface ApplicationPackageResponse {
applicationAnswerDraft?: string | null;
recruiterMessageDraft?: string | null;
keyPoints: string[];
attachmentSignals: string[];
attachmentFilesUsed: string[];
}
export interface SaveApplicationDraftsRequest {