import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { BlockerFunction, useBeforeUnload, useBlocker, useLocation, useNavigate, useParams, useSearchParams, } from "react-router-dom"; import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, IconButton, Paper, Skeleton, Stack, Tab, Tabs, Tooltip, Typography, } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import ChecklistIcon from "@mui/icons-material/Checklist"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { getApiErrorMessage } from "../api"; import Attachments from "../components/Attachments"; import Correspondence from "../components/Correspondence"; import ApplicationChecklist from "../components/ApplicationChecklist"; import ApplicationCalendarActions from "../components/ApplicationCalendarActions"; import { ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, } from "../components/ApplicationIntelligence"; import { ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection, } from "../components/ApplicationAssets"; import { ApplicationInterviewPrep } from "../components/InterviewPrep"; import EditJobDialog from "../components/EditJobDialog"; import ViewStateNotice from "../components/ViewStateNotice"; import { PageHeader, PageSection } from "../layout/PageLayout"; import type { ViewResourceError } from "../hooks/useViewResource"; import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist"; import { PIPELINE_STATUSES, normalizeStatus, statusLabel, statusTone } from "../pipeline"; import { useI18n } from "../i18n/I18nProvider"; import { useConfirm } from "../confirm"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, checklistSystemTranslationToken, workspaceSection, workspaceSubsection, } from "../applicationWorkspace"; // Phase 5 Milestone 1 — the dedicated Application Workspace. // // This is a surface, not a new data store: the overview is one aggregate read, and each section // reuses the component that already owns that domain (for example Attachments and Correspondence). // docs/architecture/overview.md. export default function ApplicationWorkspacePage() { return ; } type ApplicationWorkspaceProps = { jobIdOverride?: number; sectionOverride?: WorkspaceSectionKey | "analysis" | "cv" | "cover-letter"; onSectionChange?: (section: WorkspaceSectionKey) => void; onClose?: () => void; fullPageHref?: string; }; export function ApplicationWorkspace({ jobIdOverride, sectionOverride, onSectionChange, onClose, fullPageHref, }: ApplicationWorkspaceProps) { const { id } = useParams(); const jobId = jobIdOverride ?? Number(id); const location = useLocation(); const navigate = useNavigate(); const { t } = useI18n(); const { confirm } = useConfirm(); const [params, setParams] = useSearchParams(); const section = workspaceSection(sectionOverride ?? params.get("section")); const [overlaySubsection, setOverlaySubsection] = useState<{ section: WorkspaceSectionKey; value: string } | null>(null); const subsection = onSectionChange && overlaySubsection?.section === section ? overlaySubsection.value : workspaceSubsection(sectionOverride ?? params.get("section"), params.get("sub")); const sectionId = useId(); const secondaryId = useId(); const panelRef = useRef(null); const [overview, setOverview] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [editOpen, setEditOpen] = useState(false); const [coverLetterDirty, setCoverLetterDirty] = useState(false); const [packageDraftsDirty, setPackageDraftsDirty] = useState(false); const hasUnsavedChanges = coverLetterDirty || packageDraftsDirty; const shouldBlock = useCallback( ({ currentLocation, nextLocation }) => hasUnsavedChanges && ( currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search ), [hasUnsavedChanges], ); const blocker = useBlocker(shouldBlock); useBeforeUnload(useCallback((event) => { if (!hasUnsavedChanges) return; event.preventDefault(); event.returnValue = ""; }, [hasUnsavedChanges])); useEffect(() => { if (blocker.state !== "blocked") return; const blockedNavigation = blocker; let active = true; void confirm({ title: t("workspaceUnsavedTitle"), message: t("workspaceUnsavedMessage"), confirmLabel: t("workspaceDiscardLeave"), cancelLabel: t("workspaceKeepEditing"), destructive: true, }).then((approved) => { if (!active) return; if (approved) blockedNavigation.proceed(); else blockedNavigation.reset(); }); return () => { active = false; }; }, [blocker, confirm, t]); const load = useCallback(async () => { if (!Number.isInteger(jobId) || jobId <= 0) { setOverview(null); setError({ kind: "error", message: t("workspaceInvalidLink"), retryable: false }); setLoading(false); return; } try { setError(null); setLoading(true); setOverview(await applicationWorkspaceApi.overview(jobId)); } catch (err) { const status = (err as { response?: { status?: number } })?.response?.status; setError({ kind: "error", message: getApiErrorMessage(err, t("workspaceLoadFailed")), retryable: !status || status >= 500, status }); } finally { setLoading(false); } }, [jobId, t]); useEffect(() => { load(); }, [load]); useEffect(() => { // Direct entry gets a named target; keyboard tab activation keeps tab focus. if (!loading && section !== "overview" && document.activeElement === document.body) panelRef.current?.focus({ preventScroll: true }); }, [loading, section, subsection]); const go = (next: string, sub?: string) => { if (next === "career-profile") { navigate("/career?section=profile"); return; } if (onSectionChange) { setOverlaySubsection({ section: workspaceSection(next), value: workspaceSubsection(next, sub ?? null) }); onSectionChange(workspaceSection(next)); } else setParams((current) => { const updated = new URLSearchParams(current); updated.set("section", next); if (sub) updated.set("sub", sub); else updated.delete("sub"); return updated; }, { state: location.state }); }; const close = onClose ?? (() => { const state = location.state as { from?: unknown; focusJobId?: unknown } | null; const from = state?.from; const focusJobId = typeof state?.focusJobId === "number" ? state.focusJobId : undefined; navigate( typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs", { replace: true, state: focusJobId ? { focusJobId } : undefined }, ); }); const subsections = section === "application" ? [ { key: "cv", label: t("workspaceCv") }, { key: "letter", label: t("workspaceCoverLetter") }, { key: "answers", label: t("workspaceAnswers") }, { key: "submission", label: t("workspaceSubmission") }, ] : [ { key: "activity", label: t("workspaceActivityHistory") }, { key: "correspondence", label: t("workspaceCommunication") }, ]; if (error) { return ( ); } return ( {t("workspace")} {fullPageHref ? ( ) : null} setEditOpen(true)} /> go(value)} variant="scrollable" scrollButtons="auto" aria-label={t("workspaceSections")} sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }} > {WORKSPACE_SECTIONS.map((s) => )} {section === "overview" && jobId > 0 && } {section === "overview" && setEditOpen(true)} />} {section === "match" && jobId > 0 && ( )} {section === "interview" && jobId > 0 && } {(section === "application" || section === "timeline") && <> go(section, sub)} variant="scrollable" scrollButtons="auto" aria-label={t(section === "application" ? "workspaceApplicationSections" : "workspaceTimelineSections")}> {subsections.map((sub) => )} {section === "application" && subsection === "cv" && jobId > 0 && } {section === "application" && subsection === "letter" && jobId > 0 && } {section === "application" && subsection === "answers" && jobId > 0 && } {section === "application" && subsection === "submission" && jobId > 0 && } {section === "timeline" && subsection === "activity" && jobId > 0 && } {section === "timeline" && subsection === "correspondence" && jobId > 0 && } } {section === "documents" && jobId > 0 && <> }> } {section === "tasks" && jobId > 0 && <> } onClick={() => setEditOpen(true)}>{t("workspaceEdit")}}> {overview?.notes || t("workspaceNoNotes")} } 0 ? jobId : null} onClose={() => setEditOpen(false)} onSaved={() => { setEditOpen(false); void load(); }} /> ); } function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { const { language, t } = useI18n(); if (!overview) return ; return ( {overview.jobUrl ? : null} } /> {overview.location ? {overview.location} : null} {overview.deadline ? {t("jobDetailsDeadline")}: {new Date(overview.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} : null} ); } function ApplicationProgress({ status }: { status: string }) { const { t } = useI18n(); const normalized = normalizeStatus(status); const currentOrder = normalized === "Other" ? 0 : PIPELINE_STATUSES.indexOf(normalized) + 1; const isTerminal = normalized === "Rejected" || normalized === "Ghosted" || normalized === "Withdrawn"; const stages = normalized === "Other" ? [] : isTerminal ? [...PIPELINE_STATUSES.filter((stage) => !["Offer", "Rejected", "Ghosted", "Withdrawn"].includes(stage)), normalized] : PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage)); return ( {t("workspaceProgress")} {normalized === "Other" ? {t("workspaceCustomProgress")} : null} {stages.map((stage, index) => { const order = PIPELINE_STATUSES.indexOf(stage) + 1; const current = stage === normalized; const complete = !current && order < currentOrder; return ( {index > 0 && } `0 0 0 4px ${theme.palette.primary.main}26` : "none" }} /> {statusLabel(t, stage)} ); })} ); } function OverviewSection({ overview, onGo, onReload, onEdit }: { overview: WorkspaceOverview | null; onGo: (s: string) => void; onReload: () => void; onEdit: () => void; }) { const { language, t } = useI18n(); const stats = useMemo(() => overview ? [ { icon: , label: t("workspaceCv"), value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? t("workspaceTailoredText") : t("workspaceNotPrepared")), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const }, { icon: , label: t("jobDetailsCoverLetter"), value: overview.hasCoverLetter ? t("workspaceReady") : t("workspaceNotWritten"), ok: overview.hasCoverLetter, go: "cover-letter" as const }, { icon: , label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "documents" as const }, { icon: , label: t("workspaceAiSuggestions"), value: overview.aiInteractionCount ? t("workspaceSavedCount", { count: overview.aiInteractionCount }) : t("workspaceNoneYet"), ok: overview.aiInteractionCount > 0, go: "analysis" as const }, { icon: , label: t("workspaceChecklist"), value: overview.checklistProgress ? t("workspaceDoneCount", { completed: overview.checklistProgress.completed, total: overview.checklistProgress.total }) : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const }, ] : [], [overview, t]); if (!overview) { return {[0, 1].map((i) => )}; } const nextStepToken = checklistSystemTranslationToken(overview.nextStep?.key); const nextStepLabel = overview.nextStep ? nextStepToken ? t(`checklistItem_${nextStepToken}` as any) : overview.nextStep.label : null; const nextStepReason = overview.nextStep ? nextStepToken ? t(`checklistItem_${nextStepToken}Description` as any) : overview.nextStep.reason : null; return ( {[ [t("workspaceDiscovered"), overview.savedAt], [t("workspaceApplied"), overview.dateApplied], [t("jobDetailsDeadline"), overview.deadline], [t("intelligenceCategoryFollowUp"), overview.followUpAt], ].map(([label, date]) => {label} {date ? new Date(date).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"} )} {overview.nextStep ? ( {nextStepLabel} {nextStepReason} ) : ( {t("workspaceNothingOutstanding")} )} {stats.map((s) => ( ))} {t("workspaceRefresh")}}> {overview.recentActivity.length === 0 ? ( {t("workspaceNoActivity")} ) : ( {overview.recentActivity.map((a, i) => ( {a.type}{a.detail ? ` — ${a.detail}` : ""} {new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} ))} )} ); } function OverviewDetails({ overview, onEdit }: { overview: WorkspaceOverview; onEdit: () => void }) { const { t } = useI18n(); const [params] = useSearchParams(); const prefix = useId(); const triggers = useRef>({}); const targets: Record = { "job-details": "details" }; const key = params.get("section") ?? ""; const target = Object.prototype.hasOwnProperty.call(targets, key) ? targets[key] : undefined; const [expanded, setExpanded] = useState>({}); useEffect(() => { if (!target) return; setExpanded((current) => ({ ...current, [target]: true })); triggers.current[target]?.focus(); }, [target]); const panels = [ { id: "details", title: t("workspaceJobDetails"), content: }, ]; return ( {panels.map((panel) => ( setExpanded((current) => ({ ...current, [panel.id]: open }))} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}> { triggers.current[panel.id] = node; }} expandIcon={} aria-controls={`${prefix}-${panel.id}-content`} id={`${prefix}-${panel.id}-header`}> {panel.title} {panel.content} ))} ); } function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectionKey): string { const keys: Record = { overview: "workspaceOverview", match: "workspaceMatch", application: "workspaceApplication", documents: "workspaceDocuments", timeline: "workspaceTimeline", tasks: "workspaceTasks", interview: "workspaceInterviewPrep", }; return t(keys[section]); } function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { const { language, t } = useI18n(); if (!overview) return ; const rows: [string, string][] = [ [t("company"), overview.company ?? "—"], [t("location"), overview.location ?? "—"], [t("workspaceCountry"), overview.countryCode ?? "—"], [t("workspaceSource"), overview.source ?? "—"], [t("jobDetailsSalary"), overview.salary ?? "—"], [t("addJobModalStatus"), statusLabel(t, overview.status)], [t("workspaceDiscovered"), overview.savedAt ? new Date(overview.savedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"], [t("workspaceApplied"), overview.dateApplied ? new Date(overview.dateApplied).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"], [t("jobDetailsDeadline"), overview.deadline ? new Date(overview.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"], [t("intelligenceCategoryFollowUp"), overview.followUpAt ? new Date(overview.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : t("workspaceNotScheduled")], [t("workspaceNextAction"), overview.nextAction ?? "—"], ]; return ( {t("workspaceApplicationInfo")} {rows.map(([k, v]) => ( {k} {v} ))} {overview.tags.length > 0 ? ( {overview.tags.map((tag) => )} ) : null} {overview.notes ? ( {t("workspaceNotes")} {overview.notes} ) : null} {t("workspaceJobDescription")} {!overview.hasJobDescription ? ( {t("workspaceAddAdvert")}}> {t("workspaceNoJobDescription")} ) : ( {overview.translatedDescription ? ( {t("workspaceTranslatedAdvert")} {overview.translatedDescription} ) : null} {overview.description ? ( {overview.translatedDescription ? {t("workspaceOriginalAdvert")}{overview.descriptionLanguage ? ` · ${overview.descriptionLanguage.toUpperCase()}` : ""} : null} {overview.description} ) : null} )} ); }