feat(ui): redesign job workspace sections
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user