Add shared attachment context controls for AI job tools
This commit is contained in:
@@ -115,6 +115,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [draftReloadToken, setDraftReloadToken] = useState(0);
|
||||
const [draftSubject, setDraftSubject] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
const selectedAttachmentCsv = useMemo(() => selectedAttachmentIds.join(","), [selectedAttachmentIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
@@ -148,30 +149,37 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4) return;
|
||||
setLoadingDraft(true);
|
||||
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode } }).then((r) => {
|
||||
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
setFollowUpDraft(r.data);
|
||||
setDraftSubject(r.data.subject);
|
||||
setDraftBody(r.data.body);
|
||||
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
|
||||
}, [open, jobId, tab, followUpMode, draftReloadToken]);
|
||||
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || candidateFit) return;
|
||||
setLoadingCandidateFit(true);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||
setLoadingFocusPlan(true);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
setFocusPlan(null);
|
||||
setInterviewPrep(null);
|
||||
}, [selectedAttachmentCsv]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 8 || readiness) return;
|
||||
@@ -203,6 +211,33 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const showTranslatedText = translatedDescriptionText.length > 0;
|
||||
const showOriginalText = originalDescriptionText.length > 0;
|
||||
const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]);
|
||||
const showAiAttachmentPicker = tab >= 3 && tab <= 7 && jobAttachments.length > 0;
|
||||
|
||||
const attachmentPicker = showAiAttachmentPicker ? (
|
||||
<Box sx={{ mb: 2, 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="caption" sx={{ color: "text.secondary" }}>{t("jobDetailsAttachmentContextPicker")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button size="small" variant="text" onClick={() => setSelectedAttachmentIds(jobAttachments.slice(0, 4).map((item) => item.id))}>{t("jobDetailsAttachmentSelectTop")}</Button>
|
||||
<Button size="small" variant="text" onClick={() => setSelectedAttachmentIds([])}>{t("jobDetailsAttachmentClear")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{jobAttachments.map((attachment) => {
|
||||
const selected = selectedAttachmentIds.includes(attachment.id);
|
||||
return (
|
||||
<Chip
|
||||
key={attachment.id}
|
||||
label={attachment.fileName}
|
||||
color={selected ? "primary" : "default"}
|
||||
variant={selected ? "filled" : "outlined"}
|
||||
onClick={() => setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
||||
@@ -234,6 +269,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
{isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
|
||||
</Tabs>
|
||||
|
||||
{attachmentPicker}
|
||||
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
||||
@@ -353,25 +390,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
|
||||
{jobAttachments.length > 0 ? (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 0.75 }}>{t("jobDetailsAttachmentContextPicker")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{jobAttachments.map((attachment) => {
|
||||
const selected = selectedAttachmentIds.includes(attachment.id);
|
||||
return (
|
||||
<Chip
|
||||
key={attachment.id}
|
||||
label={attachment.fileName}
|
||||
color={selected ? "primary" : "default"}
|
||||
variant={selected ? "filled" : "outlined"}
|
||||
onClick={() => setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
@@ -695,6 +695,8 @@ export const translations = {
|
||||
jobDetailsTailoredCvSaveFailed: "Failed to save tailored CV.",
|
||||
jobDetailsTailoredCvIntro: "Generate a full application package, then edit and save the tailored resume you actually want to use for this role.",
|
||||
jobDetailsAttachmentContextPicker: "Use these attachments as AI context",
|
||||
jobDetailsAttachmentSelectTop: "Use recent files",
|
||||
jobDetailsAttachmentClear: "Clear selection",
|
||||
jobDetailsTailoredCvPlaceholder: "Paste or rewrite the version of your CV you want to use for this role.",
|
||||
jobDetailsLastUpdated: "Last updated: {value}",
|
||||
jobDetailsNotSavedYet: "Not saved yet",
|
||||
@@ -1469,6 +1471,8 @@ export const translations = {
|
||||
jobDetailsTailoredCvSaveFailed: "Kunne ikke lagre tilpasset CV.",
|
||||
jobDetailsTailoredCvIntro: "Generer en full søknadspakke, og rediger og lagre deretter den tilpassede CV-en du faktisk vil bruke for denne rollen.",
|
||||
jobDetailsAttachmentContextPicker: "Bruk disse vedleggene som AI-kontekst",
|
||||
jobDetailsAttachmentSelectTop: "Bruk nylige filer",
|
||||
jobDetailsAttachmentClear: "Tøm utvalg",
|
||||
jobDetailsTailoredCvPlaceholder: "Lim inn eller skriv om versjonen av CV-en du vil bruke for denne rollen.",
|
||||
jobDetailsLastUpdated: "Sist oppdatert: {value}",
|
||||
jobDetailsNotSavedYet: "Ikke lagret ennå",
|
||||
|
||||
Reference in New Issue
Block a user