376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
|
|
import {
|
|
Alert, Box, Chip, LinearProgress, Stack, ToggleButton,
|
|
ToggleButtonGroup, Typography,
|
|
} from "@mui/material";
|
|
|
|
import { getApiErrorMessage } from "../api";
|
|
import {
|
|
CareerMatch, JobAnalysis, Timeline, applicationIntelligenceApi,
|
|
} from "../applicationWorkspace";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { PageSection } from "../layout/PageLayout";
|
|
import ViewStateNotice from "./ViewStateNotice";
|
|
|
|
// Phase 5.3 — Application Intelligence sections for the workspace.
|
|
//
|
|
// Everything here renders a deterministic, read-only backend answer. No component triggers an AI
|
|
// generation stays in the dedicated document/preparation tools, so nothing on this page can
|
|
// silently spend a token or change the user's data.
|
|
// docs/architecture/overview.md.
|
|
|
|
// One loader for all three sections: same fetch/loading/empty/error shape, so the sections stay
|
|
// consistent and each one is just its own rendering.
|
|
function useIntelligence<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
|
const { t } = useI18n();
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const run = useCallback(load, deps);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
run()
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
setData(result);
|
|
setError(null);
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) setError(getApiErrorMessage(err, t("intelligenceLoadFailed")));
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [run, t]);
|
|
|
|
return { data, error, loading };
|
|
}
|
|
|
|
function SectionShell({ title, subtitle, loading, error, empty, emptyText, children }: {
|
|
title: string;
|
|
subtitle?: string;
|
|
loading: boolean;
|
|
error: string | null;
|
|
empty?: boolean;
|
|
emptyText?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<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 && (empty ? (
|
|
<Typography variant="body2" color="text.secondary">{emptyText}</Typography>
|
|
) : (
|
|
children
|
|
))}
|
|
</PageSection>
|
|
);
|
|
}
|
|
|
|
function Chips({ label, values, color }: { label: string; values: string[]; color?: "success" | "warning" | "default" }) {
|
|
if (values.length === 0) return null;
|
|
return (
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{label}</Typography>
|
|
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.5 }}>
|
|
{values.map((v) => (
|
|
<Chip key={v} size="small" label={v} variant="outlined"
|
|
color={color === "default" ? undefined : color} />
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function Bullets({ label, values }: { label: string; values: string[] }) {
|
|
if (values.length === 0) return null;
|
|
return (
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{label}</Typography>
|
|
<Stack component="ul" spacing={0.5} sx={{ mt: 0.5, pl: 2.5, mb: 0 }}>
|
|
{values.map((v, i) => (
|
|
<Typography key={i} component="li" variant="body2">{v}</Typography>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// ---------- Timeline ----------
|
|
|
|
export function ApplicationTimeline({ jobId }: { jobId: number }) {
|
|
const { language, t } = useI18n();
|
|
const [category, setCategory] = useState<string>("");
|
|
const { data, error, loading } = useIntelligence<Timeline>(
|
|
() => applicationIntelligenceApi.timeline(jobId, category || undefined),
|
|
[jobId, category],
|
|
);
|
|
|
|
const hasEvents = (data?.totalEvents ?? 0) > 0;
|
|
|
|
return (
|
|
<Stack spacing={2}>
|
|
{hasEvents && (data?.milestones.length ?? 0) > 0 && (
|
|
<PageSection surface title={t("intelligenceMilestones")} level="h3">
|
|
<Stack spacing={0.75}>
|
|
{(data?.milestones ?? []).map((m) => (
|
|
<Stack key={m.id} direction={{ xs: "column", sm: "row" }} spacing={0.5} justifyContent="space-between" alignItems="baseline">
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{m.summary}</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{new Date(m.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}
|
|
</Typography>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
</PageSection>
|
|
)}
|
|
|
|
<SectionShell
|
|
title={t("intelligenceTimeline")}
|
|
subtitle={t("intelligenceTimelineSubtitle")}
|
|
loading={loading}
|
|
error={error}
|
|
empty={!hasEvents}
|
|
emptyText={t("intelligenceTimelineEmpty")}
|
|
>
|
|
{/* SectionShell takes children as a prop, so this JSX is built before it decides whether to
|
|
render it — every access has to tolerate a null `data`. */}
|
|
<Stack spacing={2}>
|
|
{(data?.categories.length ?? 0) > 1 && (
|
|
<ToggleButtonGroup
|
|
size="small"
|
|
exclusive
|
|
value={category}
|
|
onChange={(_e, next) => setCategory(next ?? "")}
|
|
aria-label={t("intelligenceTimelineFilter")}
|
|
sx={{ flexWrap: "wrap" }}
|
|
>
|
|
<ToggleButton value="" aria-label={t("intelligenceAllEvents")}>{t("intelligenceAll")}</ToggleButton>
|
|
{(data?.categories ?? []).map((c) => (
|
|
<ToggleButton key={c} value={c} aria-label={timelineCategoryLabel(t, c)}>
|
|
{timelineCategoryLabel(t, c)}
|
|
</ToggleButton>
|
|
))}
|
|
</ToggleButtonGroup>
|
|
)}
|
|
|
|
{(data?.days.length ?? 0) === 0 ? (
|
|
<Typography variant="body2" color="text.secondary">{t("intelligenceNoCategoryEvents")}</Typography>
|
|
) : (
|
|
(data?.days ?? []).map((day) => (
|
|
<Box key={day.date}>
|
|
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
|
|
{new Date(`${day.date.slice(0, 10)}T12:00:00`).toLocaleDateString(language === "nb" ? "nb-NO" : "en", { dateStyle: "long" })}
|
|
</Typography>
|
|
<Stack component="ol" spacing={1.5} sx={{ mt: 0.75, mb: 0, pl: 1.5, listStyle: "none", borderLeft: "2px solid", borderColor: "divider" }}>
|
|
{day.events.map((e) => (
|
|
<Box component="li" key={e.id} sx={{ pl: 1, overflowWrap: "anywhere" }}>
|
|
<Typography component="time" dateTime={e.at} variant="caption" color="text.secondary">{new Date(e.at).toLocaleTimeString(language === "nb" ? "nb-NO" : "en", { hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short" })} · {timelineCategoryLabel(t, e.category)}</Typography>
|
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
|
<Typography variant="body2" sx={{ fontWeight: e.isMilestone ? 700 : 500 }}>
|
|
{e.summary}
|
|
</Typography>
|
|
{e.isMilestone && <Chip size="small" label={t("intelligenceMilestone")} color="primary" variant="outlined" />}
|
|
</Stack>
|
|
{e.detail && (
|
|
<Typography variant="caption" color="text.secondary">{e.detail}</Typography>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Stack>
|
|
</SectionShell>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// ---------- Analysis ----------
|
|
|
|
export function ApplicationAnalysis({ jobId }: { jobId: number }) {
|
|
const { t } = useI18n();
|
|
const { data, error, loading } = useIntelligence<JobAnalysis>(
|
|
() => applicationIntelligenceApi.analysis(jobId),
|
|
[jobId],
|
|
);
|
|
|
|
const facts: [string, string | null][] = data
|
|
? [
|
|
[t("intelligenceRole"), data.role],
|
|
[t("company"), data.company],
|
|
[t("location"), data.location],
|
|
[t("intelligenceEmploymentType"), data.employmentType],
|
|
[t("intelligenceSeniority"), data.seniority],
|
|
[t("jobDetailsSalary"), data.salary],
|
|
]
|
|
: [];
|
|
|
|
return (
|
|
<SectionShell
|
|
title={t("workspaceAnalysis")}
|
|
subtitle={t("intelligenceAnalysisSubtitle")}
|
|
loading={loading}
|
|
error={error}
|
|
>
|
|
<Stack spacing={2}>
|
|
{data && !data.hasJobDescription && (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
{t("intelligenceNoAdvert")}
|
|
</Alert>
|
|
)}
|
|
|
|
<Typography variant="body2">{data?.summary}</Typography>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "160px 1fr" }, rowGap: 0.75, columnGap: 2 }}>
|
|
{facts.map(([k, v]) => (
|
|
<React.Fragment key={k}>
|
|
<Typography variant="body2" color="text.secondary">{k}</Typography>
|
|
<Typography variant="body2">{v ?? "—"}</Typography>
|
|
</React.Fragment>
|
|
))}
|
|
</Box>
|
|
|
|
<Chips label={t("intelligenceTechnologies")} values={data?.technologies ?? []} />
|
|
<Chips label={t("intelligenceSkills")} values={data?.skills ?? []} />
|
|
<Bullets label={t("intelligenceRequirements")} values={data?.importantRequirements ?? []} />
|
|
<Bullets label={t("intelligenceResponsibilities")} values={data?.responsibilities ?? []} />
|
|
<Chips label={t("intelligenceInterviewTopics")} values={data?.interviewTopics ?? []} />
|
|
<Bullets label={t("intelligenceNotStated")} values={data?.missingInformation ?? []} />
|
|
</Stack>
|
|
</SectionShell>
|
|
);
|
|
}
|
|
|
|
// ---------- Match ----------
|
|
|
|
export function ApplicationMatch({ jobId }: { jobId: number }) {
|
|
const { t } = useI18n();
|
|
const { data, error, loading } = useIntelligence<CareerMatch>(
|
|
() => applicationIntelligenceApi.match(jobId),
|
|
[jobId],
|
|
);
|
|
|
|
return (
|
|
<SectionShell
|
|
title={t("workspaceCvMatch")}
|
|
subtitle={data?.selectedCvName
|
|
? t("workspaceComparingCv", { name: data.selectedCvName })
|
|
: t("workspaceSelectCvMatch")}
|
|
loading={loading}
|
|
error={error}
|
|
>
|
|
<Stack spacing={2}>
|
|
{data && !data.hasSelectedCv ? (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
{t("workspaceSelectCvFirst")}
|
|
</Alert>
|
|
) : data && !data.hasCareerProfile ? (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
{t("intelligenceCvProfileEmpty")}
|
|
</Alert>
|
|
) : (
|
|
<>
|
|
<Box>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="baseline">
|
|
<Typography variant="h4" sx={{ fontWeight: 900 }}>{data?.score}%</Typography>
|
|
<Chip size="small" label={matchBandLabel(t, data?.band)} variant="outlined" />
|
|
</Stack>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={data?.score ?? 0}
|
|
aria-label={t("intelligenceMatchScore")}
|
|
sx={{ mt: 1, height: 8, borderRadius: 4 }}
|
|
/>
|
|
</Box>
|
|
|
|
{data && !data.hasEnoughSignal && (
|
|
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
|
{t("intelligenceAdvertTooShort")}
|
|
</Alert>
|
|
)}
|
|
|
|
{data?.languageMismatch && (
|
|
<Alert severity={data.usedTranslatedJobDescription ? "info" : "warning"} sx={{ borderRadius: 2 }}>
|
|
{t(data.usedTranslatedJobDescription
|
|
? "intelligenceMatchUsedTranslation"
|
|
: "intelligenceMatchLanguageMismatch")}
|
|
</Alert>
|
|
)}
|
|
|
|
<Chips label={t("intelligenceMatched")} values={data?.matchedSkills ?? []} color="success" />
|
|
<Chips label={t("intelligenceMissing")} values={data?.missingSkills ?? []} color="warning" />
|
|
|
|
{(data?.relevantExperience.length ?? 0) > 0 && (
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
|
{t("intelligenceRelevantExperience")}
|
|
</Typography>
|
|
<Stack spacing={1} sx={{ mt: 0.5 }}>
|
|
{(data?.relevantExperience ?? []).map((e, i) => (
|
|
<Box key={i}>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{e.title}</Typography>
|
|
{e.subtitle && <Typography variant="caption" color="text.secondary">{e.subtitle}</Typography>}
|
|
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.5 }}>
|
|
{e.matched.map((k) => <Chip key={k} size="small" label={k} variant="outlined" />)}
|
|
</Stack>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
{(data?.relevantProjects.length ?? 0) > 0 && (
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
|
{t("intelligenceRelevantProjects")}
|
|
</Typography>
|
|
<Stack spacing={1} sx={{ mt: 0.5 }}>
|
|
{(data?.relevantProjects ?? []).map((p, i) => (
|
|
<Box key={i}>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
|
|
{p.subtitle && <Typography variant="caption" color="text.secondary">{p.subtitle}</Typography>}
|
|
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.5 }}>
|
|
{p.matched.map((k) => <Chip key={k} size="small" label={k} variant="outlined" />)}
|
|
</Stack>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<Bullets label={t("intelligenceSuggestions")} values={data?.suggestions ?? []} />
|
|
</Stack>
|
|
</SectionShell>
|
|
);
|
|
}
|
|
|
|
function timelineCategoryLabel(t: (key: any) => string, category: string): string {
|
|
const keys: Record<string, any> = {
|
|
lifecycle: "intelligenceCategoryLifecycle",
|
|
stage: "intelligenceCategoryStage",
|
|
"follow-up": "intelligenceCategoryFollowUp",
|
|
communication: "intelligenceCategoryCommunication",
|
|
ai: "intelligenceCategoryAi",
|
|
};
|
|
return keys[category] ? t(keys[category]) : category;
|
|
}
|
|
|
|
function matchBandLabel(t: (key: any) => string, band?: string): string {
|
|
if (!band) return t("matchScoreBand_Unknown");
|
|
return ["Strong", "Partial", "Low", "Unknown"].includes(band)
|
|
? t(`matchScoreBand_${band}`)
|
|
: band;
|
|
}
|