Files
jobtrackingapp/job-tracker-ui/src/components/ApplicationAssets.tsx
2026-08-31 16:54:41 +02:00

769 lines
30 KiB
TypeScript

import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Skeleton, Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import RichTextField from "./RichTextField";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RestoreIcon from "@mui/icons-material/Restore";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import ArchiveOutlinedIcon from "@mui/icons-material/ArchiveOutlined";
import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
import { getApiErrorMessage } from "../api";
import {
ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi,
} from "../applicationWorkspace";
import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
// Phase 5.4 — Application Assets sections for the workspace.
//
// These compose systems that already exist. CV editing, preview, PDF export, themes and version
// history all live in the CV builder at /cv-builder — this section only chooses WHICH variant the
// application uses and links out. The cover letter is the one thing genuinely owned here, because it
// is application-specific by nature. docs/architecture/application-workspace.md.
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
const { t } = useI18n();
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
const run = useCallback(load, deps);
const reload = useCallback(() => {
let cancelled = false;
setLoading(true);
run()
.then((r) => {
if (!cancelled) {
setData(r);
setError(null);
}
})
.catch((err) => {
if (!cancelled) setError(getApiErrorMessage(err, t("assetsLoadFailed")));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [run, t]);
useEffect(() => reload(), [reload]);
return { data, error, loading, setData, setError, reload };
}
function Shell({ title, subtitle, loading, error, children }: {
title: string;
subtitle?: string;
loading: boolean;
error: string | null;
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
children
)}
</Paper>
);
}
// ---------- CV ----------
export function ApplicationCvSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const attach = async (variantId: number | null) => {
setBusy(true);
try {
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsAttachCvFailed")));
} finally {
setBusy(false);
}
};
const attached = data?.attachedVariantId ?? "";
const duplicateForJob = async () => {
if (!data?.attachedVariantId) return;
setBusy(true);
try {
const copy = await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" }));
setData(await applicationAssetsApi.attachVariant(jobId, copy.id));
window.location.assign(`/career/builder/${copy.id}`);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsDuplicateCvFailed")));
} finally {
setBusy(false);
}
};
return (
<Stack spacing={2}>
<Shell
title={t("workspaceCv")}
subtitle={t("assetsCvSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
{(data?.availableVariants.length ?? 0) === 0 ? (
<Alert severity="info" sx={{ borderRadius: 2 }}>
{t("assetsNoCvVariants")}
</Alert>
) : (
<TextField
select
size="small"
fullWidth
label={t("assetsAttachedCv")}
value={attached}
disabled={busy}
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
helperText={t("assetsAttachedCvHelp")}
>
<MenuItem value="">{t("assetsNone")}</MenuItem>
{(data?.availableVariants ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.name} · {v.themeId} · v{v.version}
</MenuItem>
))}
</TextField>
)}
{data?.attachedVariantId ? (
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{data.attachedVariantName}</Typography>
<Typography variant="caption" color="text.secondary">
{t("assetsThemeVersion", { theme: data.attachedThemeId ?? "—", version: data.attachedVersion ?? "—" })}
{data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
endIcon={<OpenInNewIcon fontSize="small" />}
// The builder's real route. It loads the variant from :id and enforces ownership
// server-side, so there is no second CV loading path here.
href={`/career/builder/${data.attachedVariantId}`}
>
{t("assetsEditPreviewExport")}
</Button>
<Button size="small" variant="contained" disabled={busy} onClick={() => void duplicateForJob()}>
{t("assetsDuplicateForJob")}
</Button>
</Stack>
</Stack>
</Paper>
) : (
<Typography variant="body2" color="text.secondary">
{t("assetsNoCvAttached")}
</Typography>
)}
{data?.hasTailoredCvText && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
{t("assetsLegacyCvText")}
</Alert>
)}
</Stack>
</Shell>
<ApplicationTailoringSection jobId={jobId} />
<SubmittedPackagesSection jobId={jobId} />
</Stack>
);
}
function SubmittedPackagesSection({ jobId }: { jobId: number }) {
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<SubmittedPackage[]>(
() => applicationAssetsApi.submittedPackages(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const [detail, setDetail] = useState<SubmittedPackageDetail | null>(null);
const [detailBusy, setDetailBusy] = useState(false);
const capture = async () => {
setBusy(true);
try {
const created = await applicationAssetsApi.captureSubmittedPackage(jobId);
setData([created, ...(data ?? [])]);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageCaptureFailed")));
} finally {
setBusy(false);
}
};
const download = async (item: SubmittedPackage, attachment: SubmittedPackage["attachments"][number]) => {
try {
const blob = await applicationAssetsApi.downloadSubmittedAttachment(jobId, item.id, attachment.id);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = attachment.fileName;
anchor.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageDownloadFailed")));
}
};
const view = async (item: SubmittedPackage) => {
setDetailBusy(true);
try {
setDetail(await applicationAssetsApi.submittedPackage(jobId, item.id));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageLoadFailed")));
} finally {
setDetailBusy(false);
}
};
const material = (() => {
if (!detail) return null;
try { return JSON.parse(detail.applicationMaterialJson) as Record<string, unknown>; } catch { return null; }
})();
return (
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={loading} error={error}>
<Stack spacing={1.5}>
<Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>
{(data?.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">{t("assetsNoSubmittedPackages")}</Typography>
) : data?.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("assetsSubmittedPackageVersion", { version: item.version })}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(item.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
</Typography>
</Box>
<Stack direction="row" gap={0.5} flexWrap="wrap" justifyContent="flex-end">
<Chip size="small" variant="outlined" color={item.hasCv ? "success" : "default"} label={item.hasCv ? `${item.cvVariantName ?? t("workspaceCv")} · v${item.cvVariantVersion ?? "—"}` : t("assetsPackageNoCv")} />
<Chip size="small" variant="outlined" color={item.hasCoverLetter ? "success" : "default"} label={item.hasCoverLetter ? t("workspaceCoverLetter") : t("assetsPackageNoCoverLetter")} />
</Stack>
</Stack>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={() => void view(item)} sx={{ alignSelf: "flex-start" }}>
{t("assetsPackageView")}
</Button>
{item.attachments.map((attachment) => (
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start" }}>
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
</Button>
))}
</Stack>
</Paper>
))}
</Stack>
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby="submitted-package-title">
<DialogTitle id="submitted-package-title">
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2.5}>
{detail?.renderedCvHtml ? (
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Box component="iframe" title={t("assetsPackageCvPreview")} srcDoc={detail.renderedCvHtml} sandbox="" sx={{ width: "100%", height: { xs: 520, md: 760 }, border: 1, borderColor: "divider", bgcolor: "white" }} />
</Box>
) : <Alert severity="info">{t("assetsPackageNoCv")}</Alert>}
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft", "Notes"].map((key) => typeof material[key] === "string" && material[key] ? (
<Box key={key}>
<Typography variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{String(material[key])}</Typography>
</Box>
) : null)}
</Stack>
</DialogContent>
<DialogActions><Button onClick={() => setDetail(null)}>{t("close")}</Button></DialogActions>
</Dialog>
</Shell>
);
}
// ---------- Tailoring ----------
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useAsset<TailoringPlan>(
() => applicationAssetsApi.tailoring(jobId),
[jobId],
);
return (
<Shell
title={t("assetsTailoring")}
subtitle={t("assetsTailoringSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
{data && !data.hasCareerProfile && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
{t("assetsBuildProfile")}
</Alert>
)}
{data && !data.hasJobDescription && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
{t("assetsAddAdvert")}
</Alert>
)}
{(data?.suggestions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("assetsNoSuggestions")}
</Typography>
) : (
(data?.suggestions ?? []).map((s) => (
<Box key={s.kind}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{s.title}</Typography>
{s.detail && (
<Typography variant="caption" color="text.secondary">{s.detail}</Typography>
)}
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.75 }}>
{s.items.map((item) => (
<Chip key={item} size="small" label={item} variant="outlined" />
))}
</Stack>
</Box>
))
)}
</Stack>
</Shell>
);
}
// ---------- Cover letter ----------
const COVER_LETTER_TEMPLATES = {
en: `Dear Hiring Manager,
I am writing to apply for the [role] position at [company]. [One sentence on why this company, specifically.]
In my current role I [the most relevant thing you have done, with a concrete outcome]. [A second example that matches what the advert asks for.]
[Why you want this job, in your own words.]
I would welcome the chance to talk it through.
Kind regards,
[Your name]`,
nb: `Kjære rekrutteringsansvarlig,
Jeg søker med dette på stillingen som [stilling] hos [selskap]. [Én konkret setning om hvorfor akkurat dette selskapet.]
I min nåværende rolle har jeg [det mest relevante du har gjort, med et konkret resultat]. [Et annet eksempel som samsvarer med det stillingsannonsen etterspør.]
[Hvorfor du ønsker denne stillingen, med egne ord.]
Jeg ser frem til muligheten til å utdype dette i et intervju.
Med vennlig hilsen
[Navnet ditt]`,
} as const;
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
);
const [draft, setDraft] = useState<string | null>(null);
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// The textarea is only seeded from the server until the user starts typing, so a reload never
// clobbers unsaved edits.
const text = draft ?? data?.text ?? "";
const dirty = draft !== null && draft !== (data?.text ?? "");
useEffect(() => {
onDirtyChange?.(dirty);
return () => onDirtyChange?.(false);
}, [dirty, onDirtyChange]);
const save = async (value: string, source = "manual", aiAction?: string) => {
setBusy(true);
try {
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction));
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverSaveFailed")));
} finally {
setBusy(false);
}
};
const restore = async (version: number) => {
setBusy(true);
try {
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverRestoreFailed")));
} finally {
setBusy(false);
}
};
return (
<Stack spacing={2}>
<Shell
title={t("workspaceCoverLetter")}
subtitle={t("assetsCoverSubtitle")}
loading={loading}
error={error}
>
<Stack spacing={2}>
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy}
onChange={setDraft}
placeholder={t("assetsCoverPlaceholder")}
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
{t("save")}
</Button>
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
{t("assetsDiscardChanges")}
</Button>
<Button
disabled={busy || text.trim().length > 0}
onClick={() => setDraft(COVER_LETTER_TEMPLATES[language])}
>
{t("assetsStartTemplate")}
</Button>
{dirty && (
<Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} />
)}
</Stack>
</Stack>
</Shell>
<CoverLetterAiAssistant
jobId={jobId}
currentText={text}
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
/>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null}>
{(data?.versions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("assetsNoVersions")}
</Typography>
) : (
<Stack spacing={0.5}>
{(data?.versions ?? []).map((v) => (
<Stack
key={v.version}
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ py: 0.5 }}
>
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label={t("assetsCurrent")} />}
</Stack>
<Typography variant="caption" color="text.secondary">
{new Date(v.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")} · {t("assetsCharacterCount", { count: v.length })}
</Typography>
</Box>
{!v.isCurrent && (
<Tooltip title={t("assetsRestoreVersion")}>
<span>
<IconButton
size="small"
disabled={busy}
aria-label={t("assetsRestoreVersionNumber", { version: v.version })}
onClick={() => restore(v.version)}
>
<RestoreIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
</Stack>
))}
</Stack>
)}
</Shell>
</Stack>
);
}
const COVER_LETTER_ACTIONS = [
{ key: "generate", labelKey: "coverActionGenerate" },
{ key: "regenerate", labelKey: "coverActionRegenerate" },
{ key: "improve", labelKey: "coverActionImprove" },
{ key: "shorten", labelKey: "coverActionShorten" },
{ key: "expand", labelKey: "coverActionExpand" },
{ key: "professional", labelKey: "coverActionProfessional" },
{ key: "natural", labelKey: "coverActionNatural" },
{ key: "grammar", labelKey: "coverActionGrammar" },
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
const [action, setAction] = useState("generate");
const [mode, setMode] = useState("professional");
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
const [instructions, setInstructions] = useState("");
const [suggestion, setSuggestion] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generate = async () => {
setBusy(true);
setError(null);
try {
const result = await aiWorkspaceApi.generate(jobId, {
module: "cover-letter",
mode,
action,
targetLanguage: language,
currentText: currentText.trim() || undefined,
extraContext: instructions.trim() || undefined,
});
setSuggestion(result.result.text?.trim() ?? "");
} catch (err) {
setError(getApiErrorMessage(err, t("coverAiGenerateFailed")));
} finally {
setBusy(false);
}
};
const hasCv = !!cv?.attachedVariantId;
return (
<Shell
title={t("coverAiTitle")}
subtitle={t("coverAiSubtitle")}
loading={loadingCv}
error={null}
>
<Stack spacing={2}>
{!hasCv ? (
<Alert severity="info">{t("coverAiSelectCv")}</Alert>
) : (
<Alert severity="success" variant="outlined" sx={{ py: 0.5 }}>
{t("coverAiUsing")} <strong>{cv?.attachedVariantName}</strong>
</Alert>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>{t("coverAiAction")}</InputLabel>
<Select label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>{t("coverAiTone")}</InputLabel>
<Select label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
].map(([value, key]) => <MenuItem key={value} value={value}>{t(key as any)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel>{t("coverAiDocumentLanguage")}</InputLabel>
<Select label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
</FormControl>
</Stack>
<TextField
label={t("coverAiAdditionalInstructions")}
placeholder={t("coverAiInstructionsPlaceholder")}
value={instructions}
onChange={(event) => setInstructions(event.target.value)}
multiline
minRows={2}
fullWidth
/>
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>{t("retry")}</Button>}>{error}</Alert>}
<Button
variant="contained"
startIcon={<AutoFixHighIcon />}
disabled={!canUseAi || !hasCv || busy}
onClick={() => void generate()}
sx={{ alignSelf: "flex-start" }}
>
{busy ? t("interviewAiGenerating") : canUseAi ? t(COVER_LETTER_ACTIONS.find((item) => item.key === action)?.labelKey ?? "coverActionGenerate") : t("interviewAiProRequired")}
</Button>
{suggestion && (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 1.5 }}>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}>
<Typography variant="overline" color="text.secondary">{t("coverAiCurrent")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{currentText || t("coverAiNoDraft")}
</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>{t("coverAiApply")}</Button>
<Button size="small" onClick={() => setSuggestion("")}>{t("coverAiReject")}</Button>
</Stack>
</Paper>
</Box>
)}
</Stack>
</Shell>
);
}
// ---------- Application answer and recruiter message ----------
type PackageDrafts = { applicationAnswer: string; recruiterMessage: string };
export function ApplicationPackageDraftsSection({
jobId,
initialApplicationAnswer,
initialRecruiterMessage,
onSaved,
onDirtyChange,
}: {
jobId: number;
initialApplicationAnswer: string;
initialRecruiterMessage: string;
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useI18n();
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (draft === null) setSaved(initial);
// `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobId, initialApplicationAnswer, initialRecruiterMessage]);
const value = draft ?? saved;
const dirty = draft !== null && (
draft.applicationAnswer !== saved.applicationAnswer ||
draft.recruiterMessage !== saved.recruiterMessage
);
useEffect(() => {
onDirtyChange?.(dirty);
return () => onDirtyChange?.(false);
}, [dirty, onDirtyChange]);
const update = (patch: Partial<PackageDrafts>) => setDraft({ ...value, ...patch });
const save = async () => {
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
setSaved(value);
setDraft(null);
setError(null);
onSaved?.();
} catch (err) {
setError(getApiErrorMessage(err, t("assetsDraftsSaveFailed")));
} finally {
setBusy(false);
}
};
return (
<Shell
title={t("assetsApplicationDrafts")}
subtitle={t("assetsApplicationDraftsSubtitle")}
loading={false}
error={error}
>
<Stack spacing={2}>
<RichTextField
minRows={6}
label={t("assetsApplicationAnswer")}
value={value.applicationAnswer}
disabled={busy}
onChange={(applicationAnswer) => update({ applicationAnswer })}
placeholder={t("assetsApplicationAnswerPlaceholder")}
/>
<RichTextField
minRows={4}
label={t("assetsRecruiterMessage")}
value={value.recruiterMessage}
disabled={busy}
onChange={(recruiterMessage) => update({ recruiterMessage })}
placeholder={t("assetsRecruiterMessagePlaceholder")}
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
<Button variant="contained" disabled={busy || !dirty} onClick={save}>
{t("assetsSaveDrafts")}
</Button>
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
{t("assetsDiscardChanges")}
</Button>
{dirty && <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} />}
</Stack>
</Stack>
</Shell>
);
}