feat(ui): redesign job workspace sections

This commit is contained in:
cesnimda
2026-10-07 01:58:31 +02:00
parent acd1d94684
commit ac6d78289d
123 changed files with 2842 additions and 205 deletions
@@ -27,7 +27,7 @@ import {
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
} from "../components/ApplicationIntelligence";
import {
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection,
} from "../components/ApplicationAssets";
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
import EditJobDialog from "../components/EditJobDialog";
@@ -40,7 +40,7 @@ import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
checklistSystemTranslationToken, workspaceSection,
checklistSystemTranslationToken, workspaceSection, workspaceSubsection,
} from "../applicationWorkspace";
// Phase 5 Milestone 1 — the dedicated Application Workspace.
@@ -54,7 +54,7 @@ export default function ApplicationWorkspacePage() {
type ApplicationWorkspaceProps = {
jobIdOverride?: number;
sectionOverride?: WorkspaceSectionKey;
sectionOverride?: WorkspaceSectionKey | "analysis" | "cv" | "cover-letter";
onSectionChange?: (section: WorkspaceSectionKey) => void;
onClose?: () => void;
fullPageHref?: string;
@@ -74,8 +74,13 @@ export function ApplicationWorkspace({
const { t } = useI18n();
const { confirm } = useConfirm();
const [params, setParams] = useSearchParams();
const section = sectionOverride ?? workspaceSection(params.get("section"));
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);
@@ -140,15 +145,25 @@ export function ApplicationWorkspace({
load();
}, [load]);
const go = (next: string) => {
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) onSectionChange(workspaceSection(next));
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 });
};
@@ -161,6 +176,15 @@ export function ApplicationWorkspace({
{ 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 (
@@ -213,10 +237,10 @@ export function ApplicationWorkspace({
</Tabs>
</Paper>
<Box role="tabpanel" id={`${sectionId}-panel`} aria-labelledby={`${sectionId}-${section}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
<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 === "analysis" && jobId > 0 && (
{section === "match" && jobId > 0 && (
<Stack spacing={2}>
<ApplicationAnalysis jobId={jobId} />
<ApplicationMatch jobId={jobId} />
@@ -224,19 +248,39 @@ export function ApplicationWorkspace({
</Stack>
)}
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
{section === "cover-letter" && jobId > 0 && (
<>
<ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />
<ApplicationPackageDraftsSection
{(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}
@@ -391,17 +435,17 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
)}
</PageSection>
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
<OverviewDetails overview={overview} onEdit={onEdit} />
</Stack>
);
}
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
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", checklist: "tasks", documents: "documents", portfolio: "documents", timeline: "timeline", communication: "communication" };
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>>({});
@@ -412,11 +456,6 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
}, [target]);
const panels = [
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
{ id: "calendar", title: t("calendarTitle"), content: <ApplicationCalendarActions jobId={jobId} followUpAt={overview.followUpAt} deadline={overview.deadline} /> },
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
];
return (
<Box>
@@ -435,9 +474,11 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectionKey): string {
const keys: Record<WorkspaceSectionKey, any> = {
overview: "workspaceOverview",
analysis: "workspaceAnalysis",
cv: "workspaceCv",
"cover-letter": "workspaceCoverLetter",
match: "workspaceMatch",
application: "workspaceApplication",
documents: "workspaceDocuments",
timeline: "workspaceTimeline",
tasks: "workspaceTasks",
interview: "workspaceInterviewPrep",
};
return t(keys[section]);