776 lines
32 KiB
TypeScript
776 lines
32 KiB
TypeScript
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
|
|
import {
|
|
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
|
|
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";
|
|
import { PageSection } from "../layout/PageLayout";
|
|
import ViewStateNotice from "./ViewStateNotice";
|
|
import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../applicationDrafts";
|
|
|
|
// 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/overview.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 (
|
|
<PageSection surface title={title} description={subtitle} level="h3">
|
|
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
|
|
{!loading && !error && children}
|
|
</PageSection>
|
|
);
|
|
}
|
|
|
|
// ---------- CV ----------
|
|
|
|
export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) {
|
|
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={{ xs: "column", sm: "row" }} spacing={1} sx={{ minWidth: 0, maxWidth: "100%" }}>
|
|
<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} />
|
|
{includePackages && <SubmittedPackagesSection jobId={jobId} />}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId: number; allowCapture?: boolean }) {
|
|
const titleId = useId();
|
|
const viewTrigger = useRef<HTMLButtonElement | null>(null);
|
|
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}>
|
|
{allowCapture && <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} sx={{ minWidth: 0 }}>
|
|
<Stack direction={{ xs: "column", sm: "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" sx={{ minWidth: 0, maxWidth: "100%", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
|
<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={(event) => { viewTrigger.current = event.currentTarget; 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", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
|
|
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
|
|
</Button>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby={titleId} slotProps={{ transition: { onExited: () => viewTrigger.current?.focus() } }}>
|
|
<DialogTitle id={titleId}>
|
|
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
|
|
</DialogTitle>
|
|
<DialogContent dividers>
|
|
<Alert severity="info" sx={{ mb: 2 }}>{t("workspaceFrozenHelp")}</Alert>
|
|
<Stack spacing={2.5}>
|
|
{detail?.renderedCvHtml ? (
|
|
<Box>
|
|
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, overflowWrap: "anywhere" }}>{detail.summary.cvVariantName} · {t("assetsThemeVersion", { theme: detail.summary.cvThemeId ?? "—", version: detail.summary.cvVariantVersion ?? "—" })}</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"].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)}
|
|
{material && typeof material.Notes === "string" && [
|
|
[t("workspaceNotes"), removeApplicationAnswerDraft(material.Notes)],
|
|
[t("assetsApplicationAnswer"), extractApplicationAnswerDraft(material.Notes)],
|
|
].map(([label, content]) => content ? <Box key={label}>
|
|
<Typography variant="subtitle2">{label}</Typography>
|
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{content}</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 labelId = useId();
|
|
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", { name: cv?.attachedVariantName ?? "" })}
|
|
</Alert>
|
|
)}
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
|
<InputLabel id={`${labelId}-coverAiAction`}>{t("coverAiAction")}</InputLabel>
|
|
<Select labelId={`${labelId}-coverAiAction`} 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 id={`${labelId}-coverAiTone`}>{t("coverAiTone")}</InputLabel>
|
|
<Select labelId={`${labelId}-coverAiTone`} 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 id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
|
|
<Select labelId={`${labelId}-coverAiDocumentLanguage`} 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>
|
|
);
|
|
}
|