import React, { useCallback, useEffect, useState } from "react"; import { Alert, Box, Chip, Divider, LinearProgress, Paper, Skeleton, Stack, ToggleButton, ToggleButtonGroup, Typography, } from "@mui/material"; import { getApiErrorMessage } from "../api"; import { CareerMatch, JobAnalysis, Timeline, applicationIntelligenceApi, } from "../applicationWorkspace"; import { useI18n } from "../i18n/I18nProvider"; // 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/application-workspace.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(load: () => Promise, deps: React.DependencyList) { const { t } = useI18n(); const [data, setData] = useState(null); const [error, setError] = useState(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 ( {title} {subtitle && {subtitle}} {loading ? ( {[0, 1, 2].map((i) => )} ) : error ? ( {error} ) : empty ? ( {emptyText} ) : ( children )} ); } function Chips({ label, values, color }: { label: string; values: string[]; color?: "success" | "warning" | "default" }) { if (values.length === 0) return null; return ( {label} {values.map((v) => ( ))} ); } function Bullets({ label, values }: { label: string; values: string[] }) { if (values.length === 0) return null; return ( {label} {values.map((v, i) => ( {v} ))} ); } // ---------- Timeline ---------- export function ApplicationTimeline({ jobId }: { jobId: number }) { const { language, t } = useI18n(); const [category, setCategory] = useState(""); const { data, error, loading } = useIntelligence( () => applicationIntelligenceApi.timeline(jobId, category || undefined), [jobId, category], ); const hasEvents = (data?.totalEvents ?? 0) > 0; return ( {hasEvents && (data?.milestones.length ?? 0) > 0 && ( {t("intelligenceMilestones")} {(data?.milestones ?? []).map((m) => ( {m.summary} {new Date(m.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} ))} )} {/* 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`. */} {(data?.categories.length ?? 0) > 1 && ( setCategory(next ?? "")} aria-label={t("intelligenceTimelineFilter")} sx={{ flexWrap: "wrap" }} > {t("intelligenceAll")} {(data?.categories ?? []).map((c) => ( {timelineCategoryLabel(t, c)} ))} )} {(data?.days.length ?? 0) === 0 ? ( {t("intelligenceNoCategoryEvents")} ) : ( (data?.days ?? []).map((day) => ( {day.label} {day.events.map((e) => ( {e.summary} {e.isMilestone && } {e.detail && ( {e.detail} )} ))} )) )} ); } // ---------- Analysis ---------- export function ApplicationAnalysis({ jobId }: { jobId: number }) { const { t } = useI18n(); const { data, error, loading } = useIntelligence( () => 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 ( {data && !data.hasJobDescription && ( {t("intelligenceNoAdvert")} )} {data?.summary} {facts.map(([k, v]) => ( {k} {v ?? "—"} ))} ); } // ---------- Match ---------- export function ApplicationMatch({ jobId }: { jobId: number }) { const { t } = useI18n(); const { data, error, loading } = useIntelligence( () => applicationIntelligenceApi.match(jobId), [jobId], ); return ( {data && !data.hasSelectedCv ? ( {t("workspaceSelectCvFirst")} ) : data && !data.hasCareerProfile ? ( {t("intelligenceCvProfileEmpty")} ) : ( <> {data?.score}% {data && !data.hasEnoughSignal && ( {t("intelligenceAdvertTooShort")} )} {data?.languageMismatch && ( {t(data.usedTranslatedJobDescription ? "intelligenceMatchUsedTranslation" : "intelligenceMatchLanguageMismatch")} )} {(data?.relevantExperience.length ?? 0) > 0 && ( {t("intelligenceRelevantExperience")} {(data?.relevantExperience ?? []).map((e, i) => ( {e.title} {e.subtitle && {e.subtitle}} {e.matched.map((k) => )} ))} )} {(data?.relevantProjects.length ?? 0) > 0 && ( {t("intelligenceRelevantProjects")} {(data?.relevantProjects ?? []).map((p, i) => ( {p.title} {p.subtitle && {p.subtitle}} {p.matched.map((k) => )} ))} )} )} ); } function timelineCategoryLabel(t: (key: any) => string, category: string): string { const keys: Record = { 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; }