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
@@ -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 }) {