feat(applications): preserve submitted packages
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user