feat(applications): preserve submitted packages

This commit is contained in:
cesnimda
2026-08-31 16:54:41 +02:00
parent 47e8e3e50e
commit 5337ba3a5e
22 changed files with 903 additions and 11 deletions
@@ -62,6 +62,7 @@ function routeGet(overrides: Record<string, any> = {}) {
mockedApi.get.mockImplementation((url: string) => {
if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any);
if (url.endsWith("/cover-letter")) return Promise.resolve({ data: overrides.coverLetter ?? coverLetter } as any);
if (url.endsWith("/submitted-packages")) return Promise.resolve({ data: overrides.submittedPackages ?? [] } as any);
return Promise.resolve({ data: overrides.cv ?? cv } as any);
});
}
@@ -126,6 +127,21 @@ test("tailoring asks for a career profile when there is none", async () => {
expect(await screen.findByText(/Build your career profile/i)).toBeInTheDocument();
});
test("submitted package capture appends an immutable history entry", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: {
id: 12, version: 1, cvVariantId: 3, cvVariantName: "Backend CV", cvVariantVersion: 4,
cvThemeId: "nordic", hasCv: true, hasCoverLetter: true, createdAtUtc: "2026-08-31T12:00:00Z", attachments: [],
} } as any);
render(<ApplicationCvSection jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: /Freeze current package/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/submitted-packages"));
expect(await screen.findByText("Submission package v1")).toBeInTheDocument();
expect(screen.getByText(/Backend CV · v4/)).toBeInTheDocument();
});
// ---------- Cover letter ----------
test("cover letter loads the current text and its history", async () => {
@@ -246,6 +246,35 @@ export type CoverLetter = {
aiSuggestionCount: number;
};
export type SubmittedPackageAttachment = {
id: number;
fileName: string;
fileType: string;
purpose: string | null;
fileSize: number;
sha256: string;
};
export type SubmittedPackage = {
id: number;
version: number;
cvVariantId: number | null;
cvVariantName: string | null;
cvVariantVersion: number | null;
cvThemeId: string | null;
hasCv: boolean;
hasCoverLetter: boolean;
createdAtUtc: string;
attachments: SubmittedPackageAttachment[];
};
export type SubmittedPackageDetail = {
summary: SubmittedPackage;
renderedCvHtml: string | null;
coverLetterText: string | null;
applicationMaterialJson: string;
};
export const applicationAssetsApi = {
cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data),
attachVariant: (jobId: number, variantId: number | null) =>
@@ -258,6 +287,14 @@ export const applicationAssetsApi = {
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
restoreCoverLetter: (jobId: number, version: number) =>
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
submittedPackages: (jobId: number) =>
api.get<SubmittedPackage[]>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
captureSubmittedPackage: (jobId: number) =>
api.post<SubmittedPackage>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
submittedPackage: (jobId: number, packageId: number) =>
api.get<SubmittedPackageDetail>(`/jobapplications/${jobId}/submitted-packages/${packageId}`).then((r) => r.data),
downloadSubmittedAttachment: (jobId: number, packageId: number, attachmentId: number) =>
api.get<Blob>(`/jobapplications/${jobId}/submitted-packages/${packageId}/attachments/${attachmentId}`, { responseType: "blob" }).then((r) => r.data),
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) =>
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined),
};
@@ -1,17 +1,19 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
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, TailoringPlan, applicationAssetsApi,
ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi,
} from "../applicationWorkspace";
import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
@@ -196,10 +198,132 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
</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 }) {
+32
View File
@@ -413,6 +413,22 @@ export const translations = {
intelligenceRelevantProjects: "Relevant projects",
intelligenceSuggestions: "Suggestions",
assetsCvSubtitle: "The CV variant used for this application. Variants tailor the master career profile without duplicating it.",
assetsSubmittedPackages: "Submitted packages",
assetsSubmittedPackagesSubtitle: "Freeze the exact CV, cover letter, application material, and attached files used for a submission.",
assetsPackageCapture: "Freeze current package",
assetsPackageCapturing: "Freezing package…",
assetsNoSubmittedPackages: "No submitted package has been preserved yet.",
assetsSubmittedPackageVersion: "Submission package v{version}",
assetsPackageNoCv: "No CV",
assetsPackageNoCoverLetter: "No cover letter",
assetsPackageCaptureFailed: "Could not preserve the submitted package. Check that every attached file is still available and try again.",
assetsPackageDownloadFailed: "Could not download the preserved file.",
assetsPackageLoadFailed: "Could not load the preserved package.",
assetsPackageView: "View preserved package",
assetsPackageCvPreview: "Preserved CV preview",
assetsPackageTailoredCvText: "Tailored CV text",
assetsPackageRecruiterMessageDraft: "Recruiter message",
assetsPackageNotes: "Application answers and notes",
assetsLoadFailed: "Could not load this section.",
assetsAttachCvFailed: "Could not change the attached CV.",
assetsDuplicateCvFailed: "Could not create a tailored CV copy.",
@@ -2729,6 +2745,22 @@ export const translations = {
intelligenceRelevantProjects: "Relevante prosjekter",
intelligenceSuggestions: "Forslag",
assetsCvSubtitle: "CV-varianten som brukes for denne søknaden. Varianter tilpasser karriereprofilen uten å duplisere den.",
assetsSubmittedPackages: "Innsendte pakker",
assetsSubmittedPackagesSubtitle: "Frys den nøyaktige CV-en, søknadsbrevet, søknadsmaterialet og vedleggene som ble sendt.",
assetsPackageCapture: "Frys gjeldende pakke",
assetsPackageCapturing: "Fryser pakken…",
assetsNoSubmittedPackages: "Ingen innsendt pakke er bevart ennå.",
assetsSubmittedPackageVersion: "Innsendt pakke v{version}",
assetsPackageNoCv: "Ingen CV",
assetsPackageNoCoverLetter: "Intet søknadsbrev",
assetsPackageCaptureFailed: "Kunne ikke bevare den innsendte pakken. Kontroller at alle vedlegg fortsatt er tilgjengelige, og prøv igjen.",
assetsPackageDownloadFailed: "Kunne ikke laste ned den bevarte filen.",
assetsPackageLoadFailed: "Kunne ikke laste den bevarte pakken.",
assetsPackageView: "Vis bevart pakke",
assetsPackageCvPreview: "Forhåndsvisning av bevart CV",
assetsPackageTailoredCvText: "Tilpasset CV-tekst",
assetsPackageRecruiterMessageDraft: "Melding til rekrutterer",
assetsPackageNotes: "Søknadssvar og notater",
assetsLoadFailed: "Kunne ikke laste denne delen.",
assetsAttachCvFailed: "Kunne ikke endre den tilknyttede CV-en.",
assetsDuplicateCvFailed: "Kunne ikke opprette en tilpasset CV-kopi.",