557 lines
30 KiB
TypeScript
557 lines
30 KiB
TypeScript
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 <ApplicationWorkspace />;
|
|
}
|
|
|
|
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<HTMLDivElement>(null);
|
|
|
|
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
|
|
const [error, setError] = useState<ViewResourceError | null>(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<BlockerFunction>(
|
|
({ 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 (
|
|
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={close}>{t("workspaceBack")}</Button>
|
|
<ViewStateNotice title={t("workspaceLoadFailed")} error={error} onRetry={load} compact />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
|
<ViewStateNotice title={t("workspaceLoadFailed")} loading={loading && !overview} compact />
|
|
<Paper variant="outlined" sx={{ minWidth: 0, borderRadius: 1 }}>
|
|
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
|
|
<Tooltip title={t("workspaceBack")}>
|
|
<IconButton size="small" aria-label={t("workspaceBack")} onClick={close}>
|
|
<ArrowBackIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
|
|
{t("workspace")}
|
|
</Typography>
|
|
{fullPageHref ? (
|
|
<Tooltip title={t("workspaceOpenFull")}>
|
|
<IconButton
|
|
component="a"
|
|
href={fullPageHref}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
size="small"
|
|
aria-label={t("workspaceOpenFull")}
|
|
sx={{ ml: "auto" }}
|
|
>
|
|
<OpenInNewIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
) : null}
|
|
</Stack>
|
|
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
|
<Tabs
|
|
value={section}
|
|
onChange={(_, value: WorkspaceSectionKey) => 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) => <Tab key={s.key} id={`${sectionId}-${s.key}`} aria-controls={`${sectionId}-panel`} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
|
</Tabs>
|
|
</Paper>
|
|
|
|
<Box ref={panelRef} role="tabpanel" id={`${sectionId}-panel`} aria-labelledby={`${sectionId}-${section}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
|
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
|
|
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
|
|
{section === "match" && jobId > 0 && (
|
|
<Stack spacing={2}>
|
|
<ApplicationAnalysis jobId={jobId} />
|
|
<ApplicationMatch jobId={jobId} />
|
|
<ApplicationStrategySnapshot jobId={jobId} />
|
|
</Stack>
|
|
)}
|
|
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
|
|
{(section === "application" || section === "timeline") && <>
|
|
<Tabs value={subsection} onChange={(_, sub: string) => go(section, sub)} variant="scrollable" scrollButtons="auto" aria-label={t(section === "application" ? "workspaceApplicationSections" : "workspaceTimelineSections")}>
|
|
{subsections.map((sub) => <Tab key={sub.key} value={sub.key} id={`${secondaryId}-${sub.key}`} aria-controls={`${secondaryId}-panel`} label={sub.label} />)}
|
|
</Tabs>
|
|
<Box role="tabpanel" id={`${secondaryId}-panel`} aria-labelledby={`${secondaryId}-${subsection}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
|
{section === "application" && subsection === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} includePackages={false} />}
|
|
{section === "application" && subsection === "letter" && jobId > 0 && <ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />}
|
|
{section === "application" && subsection === "answers" && jobId > 0 && <ApplicationPackageDraftsSection
|
|
jobId={jobId}
|
|
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
|
|
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
|
|
onSaved={load}
|
|
onDirtyChange={setPackageDraftsDirty}
|
|
/>}
|
|
{section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection jobId={jobId} />}
|
|
{section === "timeline" && subsection === "activity" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
|
|
{section === "timeline" && subsection === "correspondence" && jobId > 0 && <PageSection surface title={t("workspaceCommunication")} level="h3"><Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} /></PageSection>}
|
|
</Box>
|
|
</>}
|
|
{section === "documents" && jobId > 0 && <>
|
|
<PageSection surface title={t("workspaceCurrentDocuments")} description={t("workspaceCurrentDocumentsHelp")} actions={<>
|
|
<Button onClick={() => go("application", "cv")}>{t("workspaceCv")}</Button>
|
|
<Button onClick={() => go("application", "letter")}>{t("workspaceCoverLetter")}</Button>
|
|
</>}><Attachments jobId={jobId} /></PageSection>
|
|
<SubmittedPackagesSection jobId={jobId} allowCapture={false} />
|
|
</>}
|
|
{section === "tasks" && jobId > 0 && <>
|
|
<PageSection surface title={t("workspaceNotes")} actions={<Button startIcon={<EditOutlinedIcon />} onClick={() => setEditOpen(true)}>{t("workspaceEdit")}</Button>}>
|
|
<Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview?.notes || t("workspaceNoNotes")}</Typography>
|
|
</PageSection>
|
|
<PageSection title={t("workspaceChecklist")}><ApplicationChecklist jobId={jobId} onChanged={load} /></PageSection>
|
|
<PageSection surface title={t("calendarTitle")}><ApplicationCalendarActions jobId={jobId} followUpAt={overview?.followUpAt ?? null} deadline={overview?.deadline ?? null} /></PageSection>
|
|
</>}
|
|
</Box>
|
|
<EditJobDialog
|
|
open={editOpen}
|
|
jobId={jobId > 0 ? jobId : null}
|
|
onClose={() => setEditOpen(false)}
|
|
onSaved={() => { setEditOpen(false); void load(); }}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
|
const { language, t } = useI18n();
|
|
if (!overview) return <Box sx={{ p: 2 }} aria-hidden><Skeleton width="65%" height={34} /><Skeleton width="40%" /></Box>;
|
|
return (
|
|
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2 }}>
|
|
<PageHeader level="h2" title={overview.jobTitle} description={overview.company ?? undefined} actions={<>
|
|
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEditApplication")}</Button>
|
|
{overview.jobUrl ? <Button component="a" href={overview.jobUrl} target="_blank" rel="noopener noreferrer" endIcon={<OpenInNewIcon />}>{t("workspaceOpenAdvert")}</Button> : null}
|
|
</>} />
|
|
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, mt: 1.5, minWidth: 0 }}>
|
|
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" sx={{ maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }} />
|
|
{overview.location ? <Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>{overview.location}</Typography> : null}
|
|
{overview.deadline ? <Typography variant="body2" color="text.secondary">{t("jobDetailsDeadline")}: {new Date(overview.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography> : null}
|
|
</Box>
|
|
<ApplicationProgress status={overview.status} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
|
{t("workspaceProgress")}
|
|
</Typography>
|
|
<Box role="region" tabIndex={0} aria-label={`${t("workspaceProgress")}: ${statusLabel(t, status)}`} sx={{ display: "flex", overflowX: "auto", p: 1, mt: 0.5, borderRadius: 1 }}>
|
|
{normalized === "Other" ? <Typography variant="body2" color="text.secondary">{t("workspaceCustomProgress")}</Typography> : null}
|
|
{stages.map((stage, index) => {
|
|
const order = PIPELINE_STATUSES.indexOf(stage) + 1;
|
|
const current = stage === normalized;
|
|
const complete = !current && order < currentOrder;
|
|
return (
|
|
<Box key={stage} sx={{ display: "grid", gridTemplateColumns: index ? "minmax(30px, 1fr) auto" : "auto", alignItems: "center", minWidth: index ? 110 : 72, flex: 1 }}>
|
|
{index > 0 && <Box aria-hidden sx={{ height: 2, bgcolor: complete || current ? "primary.main" : "divider" }} />}
|
|
<Stack aria-current={current ? "step" : undefined} alignItems="center" spacing={0.5} sx={{ minWidth: 72 }}>
|
|
<Box aria-hidden sx={{ width: 12, height: 12, borderRadius: "50%", border: 2, borderColor: current || complete ? "primary.main" : "divider", bgcolor: complete ? "primary.main" : "background.paper", boxShadow: current ? (theme) => `0 0 0 4px ${theme.palette.primary.main}26` : "none" }} />
|
|
<Typography variant="caption" sx={{ whiteSpace: "nowrap", fontWeight: current ? 800 : 600, color: current ? "text.primary" : "text.secondary" }}>
|
|
{statusLabel(t, stage)}
|
|
</Typography>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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: <DescriptionOutlinedIcon fontSize="small" />, 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: <MailOutlineIcon fontSize="small" />, label: t("jobDetailsCoverLetter"), value: overview.hasCoverLetter ? t("workspaceReady") : t("workspaceNotWritten"), ok: overview.hasCoverLetter, go: "cover-letter" as const },
|
|
{ icon: <FolderOutlinedIcon fontSize="small" />, label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "documents" as const },
|
|
{ icon: <AutoFixHighIcon fontSize="small" />, label: t("workspaceAiSuggestions"), value: overview.aiInteractionCount ? t("workspaceSavedCount", { count: overview.aiInteractionCount }) : t("workspaceNoneYet"), ok: overview.aiInteractionCount > 0, go: "analysis" as const },
|
|
{ icon: <ChecklistIcon fontSize="small" />, 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 <Stack spacing={2}>{[0, 1].map((i) => <Skeleton key={i} variant="rounded" height={120} />)}</Stack>;
|
|
}
|
|
|
|
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 (
|
|
<Stack spacing={2} sx={{ minWidth: 0 }}>
|
|
<PageSection surface title={t("workspaceApplicationInfo")}>
|
|
<Box component="dl" sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 2 }}>
|
|
{[
|
|
[t("workspaceDiscovered"), overview.savedAt],
|
|
[t("workspaceApplied"), overview.dateApplied],
|
|
[t("jobDetailsDeadline"), overview.deadline],
|
|
[t("intelligenceCategoryFollowUp"), overview.followUpAt],
|
|
].map(([label, date]) => <Box key={label}>
|
|
<Typography component="dt" variant="caption" color="text.secondary">{label}</Typography>
|
|
<Typography component="dd" variant="body2" sx={{ m: 0, mt: 0.5, fontWeight: 600 }}>{date ? new Date(date).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"}</Typography>
|
|
</Box>)}
|
|
</Box>
|
|
</PageSection>
|
|
{overview.nextStep ? (
|
|
<PageSection surface title={t("workspaceNextAction")}>
|
|
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography>
|
|
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{nextStepReason}</Typography>
|
|
<Button variant="contained" endIcon={<ArrowForwardIcon />}
|
|
onClick={() => onGo(overview.nextStep?.key === "complete-career-profile" ? "career-profile" : overview.nextStep?.section ?? "checklist")}>
|
|
{nextStepLabel}
|
|
</Button>
|
|
</PageSection>
|
|
) : (
|
|
<Alert severity="success" sx={{ borderRadius: 3 }}>
|
|
{t("workspaceNothingOutstanding")}
|
|
</Alert>
|
|
)}
|
|
|
|
<PageSection title={t("workspacePreparation")}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1 }}>
|
|
{stats.map((s) => (
|
|
<Button key={s.label} variant="outlined"
|
|
onClick={() => onGo(s.go)}
|
|
sx={{ p: 1.5, display: "block", textAlign: "left", color: "text.primary", borderColor: "divider", minWidth: 0, whiteSpace: "normal", overflowWrap: "anywhere" }}>
|
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.secondary" }}>
|
|
{s.icon}
|
|
<Typography variant="caption" sx={{ fontWeight: 700 }}>{s.label}</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>{s.value}</Typography>
|
|
</Button>
|
|
))}
|
|
</Box>
|
|
</PageSection>
|
|
|
|
<PageSection surface title={t("workspaceRecentActivity")} actions={<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>}>
|
|
{overview.recentActivity.length === 0 ? (
|
|
<Typography variant="body2" color="text.secondary">{t("workspaceNoActivity")}</Typography>
|
|
) : (
|
|
<Stack spacing={0.75}>
|
|
{overview.recentActivity.map((a, i) => (
|
|
<Stack key={i} direction={{ xs: "column", sm: "row" }} spacing={0.5} justifyContent="space-between">
|
|
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}><strong>{a.type}</strong>{a.detail ? ` — ${a.detail}` : ""}</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</PageSection>
|
|
|
|
<OverviewDetails overview={overview} onEdit={onEdit} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function OverviewDetails({ overview, onEdit }: { overview: WorkspaceOverview; onEdit: () => void }) {
|
|
const { t } = useI18n();
|
|
const [params] = useSearchParams();
|
|
const prefix = useId();
|
|
const triggers = useRef<Record<string, HTMLDivElement | null>>({});
|
|
const targets: Record<string, string> = { "job-details": "details" };
|
|
const key = params.get("section") ?? "";
|
|
const target = Object.prototype.hasOwnProperty.call(targets, key) ? targets[key] : undefined;
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
useEffect(() => {
|
|
if (!target) return;
|
|
setExpanded((current) => ({ ...current, [target]: true }));
|
|
triggers.current[target]?.focus();
|
|
}, [target]);
|
|
const panels = [
|
|
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
|
];
|
|
return (
|
|
<Box>
|
|
{panels.map((panel) => (
|
|
<Accordion key={panel.id} expanded={!!expanded[panel.id]} onChange={(_, open) => setExpanded((current) => ({ ...current, [panel.id]: open }))} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
|
|
<AccordionSummary ref={(node: HTMLDivElement | null) => { triggers.current[panel.id] = node; }} expandIcon={<ExpandMoreIcon />} aria-controls={`${prefix}-${panel.id}-content`} id={`${prefix}-${panel.id}-header`}>
|
|
<Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
|
|
</Accordion>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectionKey): string {
|
|
const keys: Record<WorkspaceSectionKey, any> = {
|
|
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 <Skeleton variant="rounded" height={200} />;
|
|
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 (
|
|
<Stack spacing={2}>
|
|
<Paper sx={{ p: 2.5, borderRadius: 3 }}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mb: 1.5 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("workspaceApplicationInfo")}</Typography>
|
|
<Button size="small" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEdit")}</Button>
|
|
</Stack>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
|
|
{rows.map(([k, v]) => (
|
|
<Box key={k} sx={{ minWidth: 0 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{k}</Typography>
|
|
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{v}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
{overview.tags.length > 0 ? (
|
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, mt: 2 }}>
|
|
{overview.tags.map((tag) => <Chip key={tag} size="small" label={tag} />)}
|
|
</Box>
|
|
) : null}
|
|
{overview.notes ? (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{t("workspaceNotes")}</Typography>
|
|
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview.notes}</Typography>
|
|
</Box>
|
|
) : null}
|
|
</Paper>
|
|
|
|
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>{t("workspaceJobDescription")}</Typography>
|
|
{!overview.hasJobDescription ? (
|
|
<Alert severity="warning" sx={{ borderRadius: 2 }} action={<Button color="inherit" size="small" onClick={onEdit}>{t("workspaceAddAdvert")}</Button>}>
|
|
{t("workspaceNoJobDescription")}
|
|
</Alert>
|
|
) : (
|
|
<Stack spacing={2.5}>
|
|
{overview.translatedDescription ? (
|
|
<Box>
|
|
<Typography variant="overline" color="text.secondary">{t("workspaceTranslatedAdvert")}</Typography>
|
|
<Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.translatedDescription}</Typography>
|
|
</Box>
|
|
) : null}
|
|
{overview.description ? (
|
|
<Box>
|
|
{overview.translatedDescription ? <Typography variant="overline" color="text.secondary">{t("workspaceOriginalAdvert")}{overview.descriptionLanguage ? ` · ${overview.descriptionLanguage.toUpperCase()}` : ""}</Typography> : null}
|
|
<Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.description}</Typography>
|
|
</Box>
|
|
) : null}
|
|
</Stack>
|
|
)}
|
|
</Paper>
|
|
</Stack>
|
|
);
|
|
}
|