184 lines
11 KiB
TypeScript
184 lines
11 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { Alert, Box, Button, Chip, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
|
|
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
|
import PersonOutlineIcon from "@mui/icons-material/PersonOutline";
|
|
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
|
|
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
|
|
|
import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder";
|
|
import type { UserOperation } from "../../types";
|
|
import { useI18n } from "../../i18n/I18nProvider";
|
|
import { PageSection } from "../../layout/PageLayout";
|
|
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
|
|
|
|
export type CareerWorkspaceCompleteness = {
|
|
percent: number;
|
|
missing: string[];
|
|
};
|
|
|
|
export type CareerWorkspaceImportRun = {
|
|
id: number;
|
|
status: string;
|
|
errorMessage?: string;
|
|
operation?: UserOperation | null;
|
|
};
|
|
|
|
const activeStatuses = new Set(["queued", "running", "waiting_for_retry", "waiting_for_external_fallback"]);
|
|
|
|
function isActive(run: CareerWorkspaceImportRun) {
|
|
return run.operation
|
|
? activeStatuses.has(run.operation.status)
|
|
: run.status === "queued" || run.status === "running";
|
|
}
|
|
|
|
function ActionCard({ title, body, href, label, icon, onNavigate }: {
|
|
title: string;
|
|
body: string;
|
|
href: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
onNavigate?: () => void;
|
|
}) {
|
|
return (
|
|
<Paper variant="outlined" component="article" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
|
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
|
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{title}</Typography>
|
|
</Box>
|
|
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{body}</Typography>
|
|
<Button
|
|
href={href}
|
|
onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate(); } : undefined}
|
|
variant="text"
|
|
sx={{ alignSelf: "flex-start", px: 0.5 }}
|
|
>
|
|
{label}
|
|
</Button>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate, runsLoading = false, runsLoadError = null, onRetryProfile, onRetryRuns, profileDirty = false }: {
|
|
completeness: CareerWorkspaceCompleteness | null;
|
|
runs: CareerWorkspaceImportRun[];
|
|
loading: boolean;
|
|
loadError: string | null;
|
|
onNavigate?: (section: "profile" | "import") => void;
|
|
runsLoading?: boolean;
|
|
runsLoadError?: string | null;
|
|
onRetryProfile?: () => void;
|
|
onRetryRuns?: () => void;
|
|
profileDirty?: boolean;
|
|
}) {
|
|
const { language, t } = useI18n();
|
|
const [recentCvs, setRecentCvs] = useState<CvVariantSummary[]>([]);
|
|
const [recentLoading, setRecentLoading] = useState(true);
|
|
const [recentError, setRecentError] = useState(false);
|
|
const [recentAttempt, setRecentAttempt] = useState(0);
|
|
const profileLinkRef = useRef<HTMLAnchorElement>(null);
|
|
const importLinkRef = useRef<HTMLAnchorElement>(null);
|
|
const recentLinkRef = useRef<HTMLAnchorElement>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setRecentLoading(true);
|
|
cvBuilderApi.list()
|
|
.then((items) => {
|
|
if (!cancelled) {
|
|
setRecentCvs(Array.isArray(items)
|
|
? [...items].sort((left, right) => Date.parse(right.updatedAtUtc) - Date.parse(left.updatedAtUtc)).slice(0, 3)
|
|
: []);
|
|
setRecentError(false);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setRecentError(true);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setRecentLoading(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [recentAttempt]);
|
|
|
|
const importState = useMemo(() => {
|
|
const review = runs.filter((run) => run.status === "pending_review");
|
|
const active = runs.filter(isActive);
|
|
const failed = runs.filter((run) => run.status === "failed" || run.operation?.status === "failed");
|
|
if (review.length > 0) return { severity: "warning" as const, label: t("careerOverviewReviewImport"), message: t(review.length === 1 ? "careerOverviewReviewOne" : "careerOverviewReviewMany", { count: review.length }) };
|
|
if (active.length > 0) return { severity: "info" as const, label: t("careerOverviewViewProcessing"), message: t(active.length === 1 ? "careerOverviewProcessingOne" : "careerOverviewProcessingMany", { count: active.length }) };
|
|
if (failed.length > 0) return { severity: "error" as const, label: t("careerOverviewResolveImport"), message: t(failed.length === 1 ? "careerOverviewFailedOne" : "careerOverviewFailedMany", { count: failed.length }) };
|
|
return { severity: "success" as const, label: t("careerOverviewImportCv"), message: t(runs.length > 0 ? "careerOverviewImportComplete" : "careerOverviewNoImport") };
|
|
}, [runs, t]);
|
|
|
|
const profileKnown = !loading && !loadError && completeness !== null;
|
|
const importUnknown = runsLoading && runs.length === 0;
|
|
const importLabel = importUnknown || runsLoadError ? t("careerWorkspaceImportTab") : importState.label;
|
|
const isFirstRun = profileKnown && !runsLoading && !runsLoadError && !recentLoading && !recentError && completeness.percent === 0 && runs.length === 0 && recentCvs.length === 0;
|
|
|
|
return (
|
|
<PageSection title={t("careerWorkspaceOverviewTab")} description={t("careerOverviewSubtitle")} actions={
|
|
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
|
|
}>
|
|
<Box sx={{ display: "grid", gap: "var(--app-section-gap)" }}>
|
|
{isFirstRun ? <Alert severity="info">{t("careerOverviewFirstRun")}</Alert> : null}
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}>
|
|
<PageSection surface title={t("careerOverviewProfileStatus")} level="h3">
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center" }}>
|
|
<Typography variant="body2" color="text.secondary">{t("careerOverviewCompleteness")}</Typography>
|
|
{loading ? <Skeleton width={42} /> : profileKnown ? <Typography sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography> : null}
|
|
</Box>
|
|
{loading || profileKnown ? <LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent} sx={{ mt: 1, height: 8, borderRadius: 999 }} /> : null}
|
|
{loadError ? <Alert severity="error" sx={{ mt: 1 }} action={onRetryProfile ? <Button color="inherit" disabled={loading || profileDirty} onClick={() => { profileLinkRef.current?.focus(); onRetryProfile(); }}>{t("retry")}</Button> : undefined}>
|
|
{t("careerOverviewUnavailable")}
|
|
{profileDirty ? <Typography variant="body2" sx={{ mt: 1 }}>{t("careerOverviewRetryDraftHint")}</Typography> : null}
|
|
</Alert> : null}
|
|
{profileKnown && completeness.missing.length ? (
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).map((label) => localizeCompletenessLabel(label, t)).join(", ") })}</Typography>
|
|
) : null}
|
|
<Button ref={profileLinkRef} href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
|
|
</PageSection>
|
|
<PageSection surface title={t("careerOverviewCvImport")} level="h3">
|
|
{importUnknown ? <Typography role="status" color="text.secondary">{t("loading")}</Typography>
|
|
: runsLoadError ? <Alert severity="error" action={onRetryRuns ? <Button color="inherit" disabled={runsLoading} onClick={() => { importLinkRef.current?.focus(); onRetryRuns(); }}>{t("retry")}</Button> : undefined}>{t("careerOverviewImportUnavailable")}</Alert>
|
|
: <Alert severity={importState.severity}>{importState.message}</Alert>}
|
|
<Button ref={importLinkRef} href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} size="small" sx={{ mt: 0.75, px: 0.5 }}>{importLabel}</Button>
|
|
</PageSection>
|
|
</Box>
|
|
|
|
<Box component="nav" aria-label={t("careerOverviewActions")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", xl: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
|
|
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="/career?section=profile" onNavigate={onNavigate ? () => onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
|
|
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importLabel} icon={<UploadFileOutlinedIcon />} />
|
|
<ActionCard title={t("careerOverviewGeneralCv")} body={t("careerOverviewGeneralCvBody")} href="/career/builder" label={t("careerOverviewCreateGeneralCv")} icon={<DescriptionOutlinedIcon />} />
|
|
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
|
|
</Box>
|
|
|
|
<PageSection surface title={t("careerOverviewRecentCvs")} level="h3" actions={<Button ref={recentLinkRef} href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>}>
|
|
{recentLoading ? (
|
|
<Stack spacing={1}><Typography role="status" color="text.secondary">{t("loading")}</Typography><Skeleton height={32} /><Skeleton height={32} /></Stack>
|
|
) : recentError ? (
|
|
<Alert severity="warning" action={<Button color="inherit" onClick={() => { recentLinkRef.current?.focus(); setRecentAttempt((attempt) => attempt + 1); }}>{t("retry")}</Button>}>{t("careerOverviewRecentFailed")}</Alert>
|
|
) : recentCvs.length === 0 ? (
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewNoCvs")}</Typography>
|
|
) : (
|
|
<Stack spacing={1} sx={{ mt: 1 }}>
|
|
{recentCvs.map((cv) => (
|
|
<Box key={cv.id} sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Box sx={{ minWidth: 0, overflowWrap: "anywhere" }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{cv.name}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{t("careerOverviewUpdated", { date: new Date(cv.updatedAtUtc).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB") })}</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 0.75, alignItems: "center" }}>
|
|
{cv.jobApplicationId ? <Chip label={t("careerOverviewJobSpecific")} size="small" variant="outlined" /> : null}
|
|
<Button href={`/career/builder/${cv.id}`} size="small">{t("cvDashboardOpen")}</Button>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</PageSection>
|
|
</Box>
|
|
</PageSection>
|
|
);
|
|
}
|