feat(ui): redesign job workspace sections

This commit is contained in:
cesnimda
2026-10-07 01:58:31 +02:00
parent acd1d94684
commit ac6d78289d
123 changed files with 2842 additions and 205 deletions
@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useId, useState } from "react";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Skeleton, Stack, TextField, Tooltip, Typography,
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";
@@ -19,6 +19,9 @@ 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.
//
@@ -70,24 +73,16 @@ function Shell({ title, subtitle, loading, error, children }: {
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>
<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 }: { jobId: number }) {
export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
@@ -166,7 +161,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
{data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ minWidth: 0, maxWidth: "100%" }}>
<Button
size="small"
variant="outlined"
@@ -198,12 +193,14 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
</Shell>
<ApplicationTailoringSection jobId={jobId} />
<SubmittedPackagesSection jobId={jobId} />
{includePackages && <SubmittedPackagesSection jobId={jobId} />}
</Stack>
);
}
function SubmittedPackagesSection({ jobId }: { jobId: number }) {
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),
@@ -260,31 +257,31 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
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" }}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>
</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}>
<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" justifyContent="flex-end">
<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={() => void view(item)} sx={{ alignSelf: "flex-start" }}>
<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" }}>
<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>
))}
@@ -292,15 +289,17 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
</Paper>
))}
</Stack>
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby="submitted-package-title">
<DialogTitle id="submitted-package-title">
<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>}
@@ -310,12 +309,19 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft", "Notes"].map((key) => typeof material[key] === "string" && material[key] ? (
{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>