feat(workspace): refine career workflows

Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
This commit is contained in:
cesnimda
2026-08-29 16:44:44 +02:00
parent 4d4af47651
commit 9edcbfc5de
29 changed files with 827 additions and 236 deletions
@@ -9,6 +9,7 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import {
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi,
} from "../applicationWorkspace";
@@ -20,6 +21,7 @@ import {
// scheduled...). Everything here is the user's to tick, add to, reorder or dismiss.
// docs/architecture/application-workspace.md.
export default function ApplicationChecklist({ jobId, onChanged }: { jobId: number; onChanged?: () => void }) {
const { t } = useI18n();
const [checklist, setChecklist] = useState<Checklist | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -30,9 +32,9 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
setChecklist(await applicationChecklistApi.get(jobId));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not load the checklist."));
setError(getApiErrorMessage(err, t("checklistLoadFailed")));
}
}, [jobId]);
}, [jobId, t]);
useEffect(() => {
load();
@@ -47,11 +49,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
await load();
onChanged?.();
} catch (err) {
setError(getApiErrorMessage(err, "Could not update the checklist."));
setError(getApiErrorMessage(err, t("checklistUpdateFailed")));
} finally {
setBusy(false);
}
}, [load, onChanged]);
}, [load, onChanged, t]);
const toggle = (item: ChecklistItem) =>
mutate(() => applicationChecklistApi.update(jobId, item.id, {
@@ -98,15 +100,15 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
{progress && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Application checklist</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("checklistTitle")}</Typography>
<Typography variant="body2" color="text.secondary">
{progress.completed} of {progress.total} done
{t("checklistProgress", { completed: progress.completed, total: progress.total })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress.percent}
aria-label="Checklist completion"
aria-label={t("checklistCompletion")}
sx={{ height: 8, borderRadius: 4 }}
/>
</Paper>
@@ -114,9 +116,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
{grouped.map((group) => (
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" color="text.secondary">{group.label}</Typography>
<Typography variant="overline" color="text.secondary">{checklistCategoryLabel(t, group.key)}</Typography>
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => (
{group.items.map((item) => {
const display = checklistItemDisplay(t, item);
return (
<Stack
key={item.id}
direction="row"
@@ -129,7 +133,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
checked={item.status === "done"}
disabled={busy}
onChange={() => toggle(item)}
inputProps={{ "aria-label": item.title }}
inputProps={{ "aria-label": display.title }}
sx={{ mt: -0.25 }}
/>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
@@ -142,40 +146,41 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
color: item.status === "done" ? "text.disabled" : "text.primary",
}}
>
{item.title}
{display.title}
</Typography>
{!item.isSystemGenerated && <Chip size="small" label="Yours" variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label="Detected" color="success" variant="outlined" />}
{!item.isSystemGenerated && <Chip size="small" label={t("checklistYours")} variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label={t("checklistDetected")} color="success" variant="outlined" />}
</Stack>
{item.description && (
<Typography variant="caption" color="text.secondary">{item.description}</Typography>
{display.description && (
<Typography variant="caption" color="text.secondary">{display.description}</Typography>
)}
</Box>
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
<Tooltip title="Move up">
<Tooltip title={t("checklistMoveUp")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Move up: ${item.title}`} onClick={() => move(item, -1)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveUpItem", { title: display.title })} onClick={() => move(item, -1)}>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<Tooltip title={t("checklistMoveDown")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Move down: ${item.title}`} onClick={() => move(item, 1)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveDownItem", { title: display.title })} onClick={() => move(item, 1)}>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.isSystemGenerated ? "Not relevant for this role" : "Delete"}>
<Tooltip title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Remove: ${item.title}`} onClick={() => remove(item)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistRemoveItem", { title: display.title })} onClick={() => remove(item)}>
<DeleteOutlineIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
))}
);
})}
</Stack>
</Paper>
))}
@@ -185,14 +190,34 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
<TextField
fullWidth
size="small"
label="Add your own task"
label={t("checklistAddTask")}
value={draft}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>Add</Button>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>{t("checklistAdd")}</Button>
</Stack>
</Paper>
</Stack>
);
}
function checklistCategoryLabel(t: (key: any, vars?: Record<string, string | number>) => string, category: string) {
const keys: Record<string, string> = {
preparation: "checklistCategoryPreparation",
submission: "checklistCategorySubmission",
"follow-up": "checklistCategoryFollowUp",
interview: "checklistCategoryInterview",
custom: "checklistCategoryCustom",
};
return keys[category] ? t(keys[category]) : category;
}
function checklistItemDisplay(t: (key: any, vars?: Record<string, string | number>) => string, item: ChecklistItem) {
if (!item.systemKey || item.systemKey.startsWith("learning:")) return { title: item.title, description: item.description };
const token = item.systemKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
return {
title: t(`checklistItem_${token}`),
description: t(`checklistItem_${token}Description`),
};
}
@@ -302,6 +302,14 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
</Alert>
)}
{data?.languageMismatch && (
<Alert severity={data.usedTranslatedJobDescription ? "info" : "warning"} sx={{ borderRadius: 2 }}>
{t(data.usedTranslatedJobDescription
? "intelligenceMatchUsedTranslation"
: "intelligenceMatchLanguageMismatch")}
</Alert>
)}
<Chips label={t("intelligenceMatched")} values={data?.matchedSkills ?? []} color="success" />
<Chips label={t("intelligenceMissing")} values={data?.missingSkills ?? []} color="warning" />
@@ -4,6 +4,8 @@ import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "
import { api, getApiErrorMessage } from "../api";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
import type { FocusPlanResponse, StatusSuggestion, StrategySnapshotOperationResponse, UserOperation } from "../types";
import { useToast } from "../toast";
import { DraftCard, ListCard, TwoColumnSection } from "./JobDetailsPanels";
@@ -14,6 +16,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
const [suggestion, setSuggestion] = useState<StatusSuggestion | null>(null);
const [busy, setBusy] = useState(false);
const { toast } = useToast();
const { t } = useI18n();
useEffect(() => {
let active = true;
@@ -31,9 +34,9 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
await api.patch(`/jobapplications/${jobId}/status`, { status: suggestion.suggestedStatus });
setSuggestion(null);
onApplied();
toast("Application status updated from the latest message.", "success");
toast(t("workspaceStatusSuggestionApplied"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Could not apply the suggested status."), "error");
toast(getApiErrorMessage(error, t("workspaceStatusSuggestionFailed")), "error");
} finally {
setBusy(false);
}
@@ -42,9 +45,12 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
return (
<Alert
severity="info"
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>Apply {suggestion.suggestedStatus}</Button>}
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>{t("workspaceStatusSuggestionApply", { status: statusLabel(t, suggestion.suggestedStatus) })}</Button>}
>
A recent recruiter message suggests moving this application from {suggestion.currentStatus || "its current stage"} to {suggestion.suggestedStatus}.
{t("workspaceStatusSuggestionBody", {
current: suggestion.currentStatus ? statusLabel(t, suggestion.currentStatus) : t("workspaceStatusSuggestionCurrent"),
suggested: statusLabel(t, suggestion.suggestedStatus),
})}
</Alert>
);
}
@@ -52,6 +58,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const { canUseAi } = useAccountPlan();
const { toast } = useToast();
const { t } = useI18n();
const [plan, setPlan] = useState<FocusPlanResponse | null>(null);
const [operation, setOperation] = useState<UserOperation | null>(null);
const [loading, setLoading] = useState(true);
@@ -95,10 +102,10 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
announced.current = key;
if (operation.status === "succeeded") {
void loadPlan();
toast("Strategy snapshot completed.", "success");
} else if (operation.status === "failed") toast("Strategy snapshot failed. You can retry safely.", "error");
else toast("Strategy snapshot cancelled.", "info");
}, [loadPlan, operation, toast]);
toast(t("workspaceStrategyCompleted"), "success");
} else if (operation.status === "failed") toast(t("workspaceStrategyFailed"), "error");
else toast(t("workspaceStrategyCancelled"), "info");
}, [loadPlan, operation, t, toast]);
const generate = async () => {
setLoading(true);
@@ -106,9 +113,9 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const { data } = await api.post<StrategySnapshotOperationResponse>(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: null });
announced.current = null;
setOperation(data.operation);
toast(data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info");
toast(data.created ? t("workspaceStrategyQueued") : t("workspaceStrategyAlreadyQueued"), "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not queue the strategy snapshot."), "error");
toast(getApiErrorMessage(error, t("workspaceStrategyQueueFailed")), "error");
} finally {
setLoading(false);
}
@@ -121,7 +128,7 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const { data } = await api.post<UserOperation>(`/operations/${operation.id}/${action}`);
setOperation(data);
} catch (error) {
toast(getApiErrorMessage(error, `Could not ${action} the strategy snapshot.`), "error");
toast(getApiErrorMessage(error, action === "cancel" ? t("workspaceStrategyCancelFailed") : t("workspaceStrategyRetryFailed")), "error");
}
};
@@ -130,29 +137,29 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1} sx={{ mb: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Strategy snapshot</Typography>
<Typography variant="caption" color="text.secondary">An on-demand plan grounded in this advert and your saved career data.</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
<Typography variant="caption" color="text.secondary">{t("workspaceStrategySubtitle")}</Typography>
</Box>
<Button variant="outlined" size="small" disabled={!canUseAi || loading || working} onClick={() => void generate()}>
{!canUseAi ? "Pro required" : plan ? "Regenerate" : "Generate"}
{!canUseAi ? t("workspaceStrategyProRequired") : plan ? t("workspaceStrategyRegenerate") : t("workspaceStrategyGenerate")}
</Button>
</Stack>
{operation && operation.status !== "succeeded" ? (
<Alert severity={operation.status === "failed" ? "error" : operation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }} action={<>
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>Cancel</Button> : null}
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>Retry</Button> : null}
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>{t("cancel")}</Button> : null}
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>{t("retry")}</Button> : null}
</>}>
{operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""}
</Alert>
) : null}
{loading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : plan ? (
<Stack spacing={2}>
<DraftCard title="Strategic summary" content={plan.strategicSummary} />
<TwoColumnSection leftTitle="Immediate priorities" leftItems={plan.immediatePriorities} rightTitle="Proof points" rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle="CV bullet ideas" leftItems={plan.cvBulletIdeas} rightTitle="Cover letter angles" rightItems={plan.coverLetterAngles} />
<ListCard title="Follow-up approach" items={plan.followUpApproach} />
<DraftCard title={t("jobDetailsFocusSummary")} content={plan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={plan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={plan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={plan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={plan.followUpApproach} />
</Stack>
) : <Typography color="text.secondary">No strategy snapshot yet. Generate one when you want AI-assisted planning.</Typography>}
) : <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography>}
</Paper>
);
}
+95 -103
View File
@@ -104,7 +104,7 @@ function buildLinePath(values: number[], width: number, height: number) {
// theme.ts (see 02-dashboard.png).
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
return (
<Card sx={{ p: { xs: 1.5, sm: 2.25 }, ...sx }}>
<Card sx={{ p: "var(--app-card-padding)", ...sx }}>
{children}
</Card>
);
@@ -208,7 +208,7 @@ export default function DashboardView() {
const trendsView = useMemo(() => {
const appliedValues = analytics.map((x) => x.applied);
const responseValues = analytics.map((x) => x.responses);
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
const chartWidth = 860;
const chartHeight = isMobile ? 210 : 250;
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
@@ -280,29 +280,80 @@ export default function DashboardView() {
}, [navigate]);
const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1;
const hasJobs = (stats?.total ?? 0) > 0;
const hasActivityData = analytics.some((item) => item.applied > 0 || item.responses > 0);
const funnelItems = (overview?.funnel ?? []).filter((item) => item.count > 0);
const hasStageData = Boolean(overview?.timeInStage?.length);
const hasCompanies = Boolean(overview?.topCompanies?.length);
const hasSkills = tags.length > 0;
const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 0.5 }}>{t("dashboardTodayTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardTodayBody")}</Typography>
{summaryView.priorityJobs.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
) : (
<Stack spacing={1.1}>
{summaryView.priorityJobs.map((job) => {
const action = getReminderAction(job);
const tone = reminderTone(job);
const toneColor = theme.palette[tone].main;
const urgent = tone === "error";
return (
<Box
key={job.id}
sx={{
p: 1.5,
pl: 2,
borderRadius: 2,
borderLeft: `4px solid ${toneColor}`,
backgroundColor: alpha(toneColor, 0.07),
display: "flex",
justifyContent: "space-between",
gap: 2,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} {job.jobTitle}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
</Box>
<Button
variant="contained"
disableElevation
onClick={() => openReminderJob(job)}
sx={urgent
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
>
{action?.label ?? t("remindersOpen")}
</Button>
</Box>
);
})}
</Stack>
)}
<Box sx={{ mt: 1.5 }}>
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
) : null;
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ maxWidth: 760 }}>
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
{t("dashboardHeroLabel")}
</Typography>
<Typography variant="h3" sx={{ mt: 0.5, color: "text.primary", overflowWrap: "anywhere" }}>
{t("dashboardOverviewTitle")}
</Typography>
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1 }}>
{t("dashboardOverviewBody")}
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2, flexWrap: "wrap" }}>
{hasJobs ? <Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ flexWrap: "wrap" }}>
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
</Stack>
</Box>
</Stack>
<Box
sx={{
@@ -339,25 +390,27 @@ export default function DashboardView() {
))}
</Menu>
</Box>
</Box>
</Box> : null}
<ViewStateNotice
loading={summaryResource.loading}
error={summaryResource.error}
title="Unable to load dashboard summary"
description="The dashboard summary is unavailable right now."
title={t("dashboardSummaryUnavailableTitle")}
description={t("dashboardSummaryUnavailableBody")}
onRetry={summaryResource.reload}
/>
<ViewStateNotice
loading={trendsResource.loading}
error={trendsResource.error}
title="Unable to load dashboard trends"
description="Charts and trend panels could not reach the API."
title={t("dashboardTrendsUnavailableTitle")}
description={t("dashboardTrendsUnavailableBody")}
onRetry={trendsResource.reload}
compact
/>
{!summaryResource.loading && !summaryResource.error && prefs.cards ? (
{priorityPanel ? <Box sx={{ mt: 2 }}>{priorityPanel}</Box> : null}
{!summaryResource.loading && !summaryResource.error && hasJobs && prefs.cards ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
{metricCards.map((card) => (
<SectionCard
@@ -393,7 +446,7 @@ export default function DashboardView() {
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && prefs.activity ? (
{!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
@@ -412,9 +465,9 @@ export default function DashboardView() {
</Stack>
</Box>
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
<Box sx={{ minWidth: trendsView.chartWidth }}>
<svg width={trendsView.chartWidth} height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`}>
<Box sx={{ mt: 2, minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<svg width="100%" height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`} preserveAspectRatio="none" style={{ display: "block", maxWidth: "100%" }}>
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
<line
key={tick}
@@ -441,10 +494,10 @@ export default function DashboardView() {
</SectionCard>
) : null}
{!summaryResource.loading && !summaryResource.error ? (
{!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? (
<SectionCard>
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
{overview?.timeInStage?.length ? (
{overview?.timeInStage?.length ? (<>
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
{overview.timeInStage.map((item, index) => (
<Box key={item.stage}>
@@ -470,9 +523,7 @@ export default function DashboardView() {
</Box>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{t("dashboardNoTagsYet")}</Typography>
)}
</>) : null}
{tags.length ? (
<Box sx={{ mt: 2.25 }}>
@@ -490,9 +541,10 @@ export default function DashboardView() {
</Box>
) : null}
<Typography variant="h6" sx={{ mt: 2.25 }}>{t("dashboardConversionFunnelTitle")}</Typography>
{funnelItems.length > 0 ? <>
<Typography variant="h6" sx={{ mt: hasStageData || hasSkills ? 2.25 : 0 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Stack spacing={1.2} sx={{ mt: 1 }}>
{(overview?.funnel ?? []).map((item) => {
{funnelItems.map((item) => {
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
return (
<Box key={item.label}>
@@ -517,14 +569,15 @@ export default function DashboardView() {
);
})}
</Stack>
</> : null}
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
{summaryView.topSource ? <Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
<Typography variant="h5" sx={{ mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
</Typography>
</Box>
</Box> : null}
</SectionCard>
) : null}
</Box>
@@ -551,65 +604,8 @@ export default function DashboardView() {
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
{summaryView.priorityJobs.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
) : (
<Stack spacing={1.1}>
{summaryView.priorityJobs.map((job) => {
const action = getReminderAction(job);
const tone = reminderTone(job);
const toneColor = theme.palette[tone].main;
const urgent = tone === "error";
return (
<Box
key={job.id}
sx={{
p: 1.5,
pl: 2,
borderRadius: 2,
borderLeft: `4px solid ${toneColor}`,
backgroundColor: alpha(toneColor, 0.07),
display: "flex",
justifyContent: "space-between",
gap: 2,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} {job.jobTitle}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
</Box>
<Button
variant="contained"
disableElevation
onClick={() => openReminderJob(job)}
sx={urgent
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
>
{action?.label ?? t("remindersOpen")}
</Button>
</Box>
);
})}
</Stack>
)}
<Box sx={{ mt: 1.5 }}>
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
) : null}
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1fr 1fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && hasCompanies && prefs.companies ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
<Stack spacing={1.25}>
@@ -628,13 +624,10 @@ export default function DashboardView() {
</SectionCard>
) : null}
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
{!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopSkills")}</Typography>
{tags.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
) : (
<Stack spacing={1.15}>
<Stack spacing={1.15}>
{tags.slice(0, 8).map((tag, index) => {
const max = Math.max(...tags.map((item) => item.count), 1);
const width = (tag.count / max) * 100;
@@ -650,8 +643,7 @@ export default function DashboardView() {
</Box>
);
})}
</Stack>
)}
</Stack>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
{!tagTrends || tagTrends.series.length === 0 ? (
@@ -1,13 +1,15 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import { Box, Button, IconButton, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CloseIcon from "@mui/icons-material/Close";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { getUserKeyFromToken } from "../themePrefs";
type MeResponse = {
email?: string | null;
@@ -35,6 +37,8 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const dismissalKey = `onboardingDismissed:${getUserKeyFromToken()}`;
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissalKey) === "true");
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
useEffect(() => {
@@ -68,7 +72,7 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
return () => { active = false; };
}, []);
if (!status) return null;
if (!status || dismissed) return null;
const steps = [
{ done: true, label: t("onboardingStepSignup"), actionLabel: "" },
@@ -83,7 +87,19 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
return (
<Paper sx={{ p: 2.25, mb: 2, borderRadius: 4, border: "1px solid", borderColor: alpha(theme.palette.primary.main, 0.25), background: alpha(theme.palette.primary.main, 0.04) }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<IconButton
size="small"
aria-label={t("onboardingDismiss")}
onClick={() => {
window.localStorage.setItem(dismissalKey, "true");
setDismissed(true);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("onboardingBody")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{completed} / {steps.length}</Typography>
<LinearProgress variant="determinate" value={(completed / steps.length) * 100} sx={{ my: 1.5, borderRadius: 2 }} />
+21 -13
View File
@@ -47,7 +47,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<Paper sx={{ p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Paper sx={{ p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
{children}
@@ -97,15 +97,23 @@ export default function SettingsView({
};
return (
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
{t("settingsTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsSubtitle")}
</Typography>
<Paper sx={{ mt: 0, p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", maxWidth: 960, mx: "auto" }}>
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
<InputLabel id="settings-section-label">{t("settingsNavigation")}</InputLabel>
<Select
labelId="settings-section-label"
value={tab}
label={t("settingsNavigation")}
onChange={(event) => setTab(Number(event.target.value))}
>
<MenuItem value={0}>{t("settingsTabGeneral")}</MenuItem>
<MenuItem value={1}>{t("settingsTabFollowUps")}</MenuItem>
<MenuItem value={2}>{t("settingsTabNotifications")}</MenuItem>
<MenuItem value={3}>{t("settingsTabBackup")}</MenuItem>
</Select>
</FormControl>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
@@ -197,8 +205,8 @@ export default function SettingsView({
<AiPrivacySettingsCard />
<AiUsageCard />
<QuickCaptureCard />
<SectionCard title="Connected accounts" subtitle="Manage inbox connections separately from your account and security settings.">
<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>Manage connected accounts</Button>
<SectionCard title={t("connectedAccounts")} subtitle={t("settingsConnectedAccountsBody")}>
<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>{t("settingsManageConnectedAccounts")}</Button>
</SectionCard>
</Box>
</TabPanel>
@@ -215,8 +223,8 @@ export default function SettingsView({
control={<Checkbox checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
label={t("settingsNotificationsFollowUpReminders")}
/>
<Typography variant="caption" color="text.secondary">Disabling this prevents the background reminder worker from sending follow-up email to your account. In-app reminders remain available.</Typography>
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? "Saving" : "Save notification settings"}</Button></Box>
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button></Box>
</Box>}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
{t("settingsNotificationsDelivery")}