refactor(workspace): unify application workflow
This commit is contained in:
@@ -4,8 +4,8 @@ import {
|
||||
} from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper,
|
||||
Skeleton, Stack, Tooltip, Typography,
|
||||
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, Divider, 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";
|
||||
@@ -16,11 +16,11 @@ 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 AiWorkspacePanel from "../components/AiWorkspacePanel";
|
||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
|
||||
import EditJobDialog from "../components/EditJobDialog";
|
||||
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, workspaceSection,
|
||||
@@ -149,9 +151,9 @@ export function ApplicationWorkspace({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "220px 1fr" }, gap: 2, alignItems: "start" }}>
|
||||
<Paper sx={{ p: 1, borderRadius: 3, position: { md: "sticky" }, top: 12 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1, py: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
|
||||
<Tooltip title="Back to applications">
|
||||
<IconButton size="small" aria-label="Back to applications" onClick={close}>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
@@ -176,45 +178,30 @@ export function ApplicationWorkspace({
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Stack>
|
||||
<List dense component="nav" aria-label="Workspace sections">
|
||||
{WORKSPACE_SECTIONS.map((s) => (
|
||||
<ListItemButton key={s.key} selected={section === s.key} onClick={() => go(s.key)} sx={{ borderRadius: 2 }}>
|
||||
<ListItemText
|
||||
primary={s.label}
|
||||
slotProps={{ primary: { fontSize: 14, fontWeight: section === s.key ? 700 : 500 } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
||||
<Tabs
|
||||
value={section}
|
||||
onChange={(_, value: WorkspaceSectionKey) => go(value)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="Workspace sections"
|
||||
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
|
||||
>
|
||||
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={s.label} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
||||
<Box 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} />}
|
||||
{section === "job-details" && <JobDetailsSection overview={overview} onEdit={() => setEditOpen(true)} />}
|
||||
{/* Deterministic answer first, then the AI panel below it — the page never generates on load. */}
|
||||
{section === "analysis" && jobId > 0 && <ApplicationAnalysis jobId={jobId} />}
|
||||
{section === "analysis" && jobId > 0 && <ApplicationStrategySnapshot jobId={jobId} />}
|
||||
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />}
|
||||
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
|
||||
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
|
||||
{section === "analysis" && jobId > 0 && (
|
||||
<Stack spacing={2}>
|
||||
<ApplicationAnalysis jobId={jobId} />
|
||||
<ApplicationMatch jobId={jobId} />
|
||||
<ApplicationStrategySnapshot jobId={jobId} />
|
||||
</Stack>
|
||||
)}
|
||||
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
|
||||
{(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<AiWorkspacePanel jobId={jobId} />
|
||||
</Paper>
|
||||
)}
|
||||
{section === "documents" && jobId > 0 && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}><Attachments jobId={jobId} /></Paper>
|
||||
)}
|
||||
{section === "communication" && jobId > 0 && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} />
|
||||
</Paper>
|
||||
)}
|
||||
{section === "checklist" && jobId > 0 && (
|
||||
<ApplicationChecklist jobId={jobId} onChanged={load} />
|
||||
)}
|
||||
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
|
||||
{section === "cover-letter" && jobId > 0 && (
|
||||
<>
|
||||
@@ -226,8 +213,6 @@ export function ApplicationWorkspace({
|
||||
onSaved={load}
|
||||
onDirtyChange={setPackageDraftsDirty}
|
||||
/>
|
||||
{/* Generation stays an explicit user action, below the editor the user owns. */}
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -241,9 +226,10 @@ export function ApplicationWorkspace({
|
||||
);
|
||||
}
|
||||
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
||||
const { t } = useI18n();
|
||||
if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>;
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, borderRadius: 3 }}>
|
||||
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2.5 }}>
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
|
||||
@@ -257,8 +243,7 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Chip size="small" label={overview.status} color="primary" variant="outlined" />
|
||||
<Chip size="small" label={overview.stageGroup} />
|
||||
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
|
||||
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
|
||||
{overview.jobUrl && (
|
||||
<Tooltip title="Open original advert">
|
||||
@@ -269,21 +254,59 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<ApplicationProgress status={overview.status} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewSection({ overview, onGo, onReload }: {
|
||||
function ApplicationProgress({ status }: { status: string }) {
|
||||
const { t } = useI18n();
|
||||
const normalized = normalizeStatus(status);
|
||||
const currentOrder = normalized === "Other" ? Number.MAX_SAFE_INTEGER : PIPELINE_STATUSES.indexOf(normalized) + 1;
|
||||
const isTerminal = normalized === "Rejected" || normalized === "Ghosted" || normalized === "Withdrawn";
|
||||
const stages = 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.5 }} aria-label={`Application progress: ${statusLabel(t, status)}`}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
|
||||
Application progress
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
|
||||
{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 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: WorkspaceSectionKey) => void;
|
||||
onReload: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const stats = useMemo(() => overview ? [
|
||||
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
|
||||
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
|
||||
{ icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const },
|
||||
{ icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "overview" as const },
|
||||
{ icon: <AutoFixHighIcon fontSize="small" />, label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const },
|
||||
{ icon: <ChecklistIcon fontSize="small" />, label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const },
|
||||
{ icon: <ChecklistIcon fontSize="small" />, label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const },
|
||||
] : [], [overview]);
|
||||
|
||||
if (!overview) {
|
||||
@@ -342,10 +365,34 @@ function OverviewSection({ overview, onGo, onReload }: {
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
|
||||
const panels = [
|
||||
{ id: "details", title: "Job details", content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
||||
{ id: "tasks", title: "Next actions and checklist", content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
||||
{ id: "timeline", title: "Activity history", content: <ApplicationTimeline jobId={jobId} /> },
|
||||
{ id: "documents", title: "Documents", content: <Attachments jobId={jobId} /> },
|
||||
{ id: "communication", title: "Communication", content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
|
||||
];
|
||||
return (
|
||||
<Box>
|
||||
{panels.map((panel) => (
|
||||
<Accordion key={panel.id} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${panel.id}-content`} id={`${panel.id}-header`}>
|
||||
<Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails id={`${panel.id}-content`} sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
||||
if (!overview) return <Skeleton variant="rounded" height={200} />;
|
||||
const rows: [string, string][] = [
|
||||
|
||||
Reference in New Issue
Block a user