feat(dashboard): customise widget visibility order and size

This commit is contained in:
cesnimda
2026-10-21 21:07:37 +02:00
parent 68ee6cacb0
commit 77968da90c
9 changed files with 423 additions and 120 deletions
@@ -0,0 +1,120 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useBlocker, useBeforeUnload } from "react-router-dom";
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
import { useDragReorder } from "../hooks/useDragReorder";
import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
import { PageSection } from "../layout/PageLayout";
export function DashboardNavigationGuard({ dirty, saving }: { dirty: boolean; saving: boolean }) {
const { t } = useI18n();
const { confirm } = useConfirm();
const blocker = useBlocker(dirty || saving);
const [waiting, setWaiting] = useState(false);
const pending = useRef(false);
useBeforeUnload(React.useCallback(event => {
if (!dirty) return;
event.preventDefault(); event.returnValue = "";
}, [dirty]));
useEffect(() => {
if (blocker.state !== "blocked") { pending.current = false; return; }
if (saving) { setWaiting(true); blocker.reset(); return; }
if (pending.current) return;
pending.current = true;
void confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), cancelLabel: t("workspaceKeepEditing"), destructive: true })
.then(approved => { if (approved) blocker.proceed(); else blocker.reset(); });
}, [blocker, confirm, saving, t]);
return waiting && saving ? <Typography role="status">{t("dashboardWaitForSave")}</Typography> : null;
}
type Props = {
draft: DashboardLayout;
dirty: boolean;
saving: boolean;
saveError: string | null;
conflict: boolean;
onChange: (next: DashboardLayout) => void;
onSave: () => void;
onCancel: () => void;
onReload: () => void;
};
export default function DashboardLayoutEditor({ draft, dirty, saving, saveError, conflict, onChange, onSave, onCancel, onReload }: Props) {
const { t } = useI18n();
const { confirm } = useConfirm();
const panel = useRef<HTMLDivElement>(null);
const pendingFocus = useRef<{ id: string; direction: number } | null>(null);
const [announcement, announce] = useState("");
useEffect(() => {
panel.current?.focus({ preventScroll: true });
panel.current?.scrollIntoView?.({ block: "start" });
}, []);
useLayoutEffect(() => {
const target = pendingFocus.current;
if (!target) return;
pendingFocus.current = null;
const row = panel.current?.querySelector(`[data-layout-row="${target.id}"]`);
const preferred = row?.querySelector<HTMLButtonElement>(`button[data-move="${target.direction}"]:not(:disabled)`);
(preferred ?? row?.querySelector<HTMLButtonElement>("button[data-move]:not(:disabled)"))?.focus();
}, [draft]);
const reorder = (from: number, to: number) => {
if (saving || from === to || to < 0 || to >= draft.widgets.length) return;
const widgets = [...draft.widgets];
const [moved] = widgets.splice(from, 1); widgets.splice(to, 0, moved);
onChange({ ...draft, widgets });
const name = t(WIDGETS.find(widget => widget.id === moved.id)!.titleKey);
announce(t("dashboardMoved", { name, position: to + 1, total: widgets.length }));
};
const drag = useDragReorder(reorder);
const change = (id: string, patch: Partial<DashboardLayout["widgets"][number]>) => onChange({ ...draft, widgets: draft.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget) });
const cancel = async () => {
if (!dirty || await confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), destructive: true })) onCancel();
};
const reset = async () => {
if (await confirm({ title: t("dashboardReset"), message: t("dashboardResetBody"), confirmLabel: t("dashboardReset") })) onChange({ ...defaultDashboardLayout(), revision: draft.revision });
};
const reload = async () => {
if (await confirm({ title: t("dashboardReload"), message: t("dashboardReloadBody"), confirmLabel: t("dashboardReload"), destructive: true })) onReload();
};
return <Box ref={panel} tabIndex={-1} role="group" aria-label={t("dashboardEditing")} sx={{ scrollMarginTop: "calc(var(--app-shell-header-height, 124px) + 16px)" }} onKeyDown={event => {
if (event.key === "Escape" && !saving && !event.defaultPrevented) { event.preventDefault(); void cancel(); }
}}>
<PageSection surface title={t("dashboardEditing")} description={t("dashboardEditingBody")}>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} sx={{ mb: 2 }}>
<Button variant="contained" disabled={saving || conflict} onClick={onSave}>{t(saving ? "dashboardSaving" : "dashboardSave")}</Button>
<Button disabled={saving} onClick={() => void cancel()}>{t("cancel")}</Button>
<Button disabled={saving} onClick={() => void reset()}>{t("dashboardReset")}</Button>
</Stack>
{saveError ? <Alert severity="error" sx={{ mb: 2 }}>{t(conflict ? "dashboardConflict" : "dashboardSaveFailed")}
{conflict ? <Button disabled={saving} onClick={() => void reload()}>{t("dashboardReload")}</Button> : null}
</Alert> : null}
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
<Box component="ol" aria-label={t("dashboardManageWidgets")} sx={{ listStyle: "none", m: 0, p: 0, display: "grid", gap: 1 }}>
{draft.widgets.map((widget, index) => {
const entry = WIDGETS.find(item => item.id === widget.id)!;
const name = t(entry.titleKey);
const gesture = drag.getItemProps(index);
return <Box component="li" key={widget.id} data-layout-row={widget.id} onDragOver={saving ? undefined : gesture.onDragOver} onDrop={saving ? undefined : gesture.onDrop}
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1, p: 1, border: "1px solid", borderColor: drag.overIndex === index ? "primary.main" : "divider", borderRadius: 1, bgcolor: drag.overIndex === index ? "action.hover" : "background.paper", opacity: drag.dragIndex === index ? 0.6 : 1, minWidth: 0 }}>
<Button {...gesture} disabled={saving} draggable={!saving} aria-label={t("dashboardDrag", { name })} aria-describedby="dashboard-drag-help" sx={{ minWidth: 44, minHeight: 44, cursor: "grab" }}><DragIndicatorIcon /></Button>
<FormControlLabel sx={{ flex: "1 1 14rem", minWidth: 0, mr: 0, overflowWrap: "anywhere" }} control={<Checkbox checked={widget.visible} disabled={saving} onChange={event => change(widget.id, { visible: event.target.checked })} />} label={name} />
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
{([-1, 1] as const).map(direction => <Button key={direction} data-move={direction} disabled={saving || index + direction < 0 || index + direction >= draft.widgets.length}
aria-label={t(direction === -1 ? "dashboardMoveEarlierNamed" : "dashboardMoveLaterNamed", { name })}
onClick={() => { pendingFocus.current = { id: widget.id, direction }; reorder(index, index + direction); }} sx={{ minHeight: 44 }}>{t(direction === -1 ? "dashboardEarlier" : "dashboardLater")}</Button>)}
</Stack>
<TextField select size="small" label={t("dashboardSize")} value={widget.size} disabled={saving || entry.sizes.length === 1}
onChange={event => change(widget.id, { size: event.target.value as "standard" | "full" })}
slotProps={{ select: { native: true }, htmlInput: { "aria-label": t("dashboardSizeFor", { name }) } }} sx={{ width: { xs: "100%", sm: 190 }, minWidth: 0, "& select": { minHeight: 28 } }}>
{entry.sizes.map(size => <option key={size} value={size}>{t(size === "full" ? "dashboardSizeFull" : "dashboardSizeStandard")}</option>)}
</TextField>
</Box>;
})}
</Box>
<Typography id="dashboard-drag-help" variant="body2" color="text.secondary" sx={{ mt: 2 }}>{t("dashboardReorderHelp")}</Typography>
</PageSection>
</Box>;
}
+94 -111
View File
@@ -1,11 +1,10 @@
import React, { useCallback, useMemo, useState } from "react";
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
import React, { useCallback, useContext, useMemo, useRef, useState } from "react";
import { Link as RouterLink, useNavigate, useSearchParams, UNSAFE_DataRouterContext } from "react-router-dom";
import {
Box,
Button,
Menu,
MenuItem,
Alert,
Stack,
Link,
Table, TableBody, TableCell, TableHead, TableRow,
@@ -13,13 +12,14 @@ import {
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import TuneIcon from "@mui/icons-material/Tune";
import CheckBoxIcon from "@mui/icons-material/CheckBox";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
import { useDashboardLayout } from "../hooks/useDashboardLayout";
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
import { useOptionalToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel, statusTone } from "../pipeline";
import { PageSection } from "../layout/PageLayout";
@@ -52,32 +52,6 @@ type OverviewAnalytics = {
};
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
type Prefs = {
cards: boolean;
activity: boolean;
funnel: boolean;
companies: boolean;
skills: boolean;
};
function prefsKey() {
return `dashboardPrefs:${getUserKeyFromToken()}`;
}
function loadPrefs(): Prefs {
try {
const raw = window.localStorage.getItem(prefsKey());
if (!raw) return { cards: true, activity: true, funnel: true, companies: true, skills: true };
return { cards: true, activity: true, funnel: true, companies: true, skills: true, ...JSON.parse(raw) };
} catch {
return { cards: true, activity: true, funnel: true, companies: true, skills: true };
}
}
function savePrefs(next: Prefs) {
window.localStorage.setItem(prefsKey(), JSON.stringify(next));
}
// "Needs your attention" row accent -- red for overdue follow-ups, amber for interview prep,
// blue/info for everything else (package work, readiness review). No "positive/offer" tier
// exists in the real reminder feed (see DashboardView report), so only these three appear.
@@ -103,9 +77,21 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
next.set("months", String(value));
setQuery(next);
};
const [dashboardPrefs, setPrefs] = useState<Prefs>(() => reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : loadPrefs());
const prefs = reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : dashboardPrefs;
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
const layoutState = useDashboardLayout(!reporting);
const activeLayout = reporting ? defaultDashboardLayout() : layoutState.draft ?? layoutState.layout;
const prefs = Object.fromEntries(activeLayout.widgets.map(widget => [widget.id, widget.visible]));
const dataRouter = useContext(UNSAFE_DataRouterContext);
const customizeButton = useRef<HTMLButtonElement>(null);
const optionalToast = useOptionalToast();
const [savedMessage, setSavedMessage] = useState(false);
const cancelLayout = () => { layoutState.cancelEditing(); requestAnimationFrame(() => customizeButton.current?.focus()); };
const saveLayout = async () => {
if (await layoutState.save()) {
setSavedMessage(true);
optionalToast?.toast(t("dashboardSaved"), "success");
requestAnimationFrame(() => customizeButton.current?.focus());
}
};
const loadSummary = useCallback(async () => {
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
api.get<JobStats>("/jobapplications/stats"),
@@ -177,14 +163,6 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
const monthSpan = analytics.length ? `${monthLabel(analytics[0].month)} – ${monthLabel(analytics[analytics.length - 1].month)}` : t("dashboardNoWindowData");
const stageCount = (stage: string) => overview?.funnel.find(item => item.label === stage)?.count ?? 0;
const togglePref = useCallback((key: keyof Prefs) => {
setPrefs(current => {
const next = { ...current, [key]: !current[key] };
savePrefs(next);
return next;
});
}, []);
// Both series share a zero baseline and maximum; independent scaling implies false equality.
const chart = useMemo(() => {
const max = Math.max(1, ...analytics.flatMap(point => [point.applied, point.responses]));
@@ -197,7 +175,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
<Box sx={{ width: `${Math.max(0, Math.min(100, value / Math.max(1, max) * 100))}%`, height: "100%", bgcolor: color, borderRadius: 1 }} />
</Box>;
const detailsSx = { mt: 2, "& summary": { cursor: "pointer", color: "text.primary", fontWeight: 600, py: 1 }, "& th, & td": { px: 1, py: 0.75, overflowWrap: "anywhere" } };
const prefsButton = <Button variant="outlined" startIcon={<TuneIcon />} aria-haspopup="menu" aria-expanded={Boolean(prefsAnchor)} onClick={event => setPrefsAnchor(event.currentTarget)}>{t("dashboardCustomize")}</Button>;
const prefsButton = !layoutState.editing ? <Button ref={customizeButton} variant="outlined" startIcon={<TuneIcon />} disabled={layoutState.loading || Boolean(layoutState.loadError)} onClick={() => { setSavedMessage(false); layoutState.startEditing(); }}>{t("dashboardCustomize")}</Button> : null;
const pipelineReport = <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
<Box sx={reporting ? grid : undefined}>
<Stack spacing={1.5}>
@@ -219,35 +197,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Box>
</PageSection>;
return (
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
</Box>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{([
["cards", "dashboardSummaryCards"], ["activity", "dashboardActivityChart"],
["funnel", "dashboardConversionFunnel"], ["companies", "dashboardTopCompanies"], ["skills", "dashboardSkillsInsights"],
] as const).map(([key, label]) => (
<MenuItem key={key} role="menuitemcheckbox" aria-checked={prefs[key]} onClick={() => togglePref(key)}>
{prefs[key] ? <CheckBoxIcon aria-hidden sx={{ m: 1, color: "primary.main" }} /> : <CheckBoxOutlineBlankIcon aria-hidden sx={{ m: 1, color: "text.secondary" }} />}
{t(label)}
</MenuItem>
))}
</Menu>
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
</PageSection> : null}
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
{!reporting && summaryReady && hasJobs && prefs.cards ? (
<Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
const reports: Record<WidgetId, React.ReactNode> = {
cards: summaryReady && hasJobs ? <Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
{[
{ label: t("dashboardTrackedJobs"), value: stats!.active, caption: t("dashboardTrackedBody"), to: "/jobs" },
{ label: t("dashboardApplied30Days"), value: stats!.appliedLast30Days, caption: t("dashboardRecordedDates") },
@@ -260,11 +211,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Typography>
<Typography component="dd" variant="caption" color="text.secondary" sx={{ m: 0 }}>{item.caption}</Typography>
</Box>)}
</Box>
) : null}
{!reporting && summaryReady && hasJobs ? <Box sx={grid}>
{!reporting ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
</Box> : null,
attention: summaryReady && hasJobs ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionSample", { count: number(reminderJobs.length), shown: number(Math.min(5, reminderJobs.length)) })}</Typography>
<Typography variant="caption" component="p" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionBreakdown", {
followUp: number(reminderJobs.filter(job => job.workflowSignal?.actionKey === "follow-up").length),
@@ -285,20 +233,9 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
})}
{!reminderJobs.length ? <Typography color="text.secondary">{t("remindersNothing")}</Typography> : null}
</Stack>
</PageSection> : null}
{prefs.funnel ? pipelineReport : null}
</Box> : null}
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
</Box> : null}
{trendsReady && (reporting || hasJobs || hasActivity) && prefs.activity ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
</PageSection> : null,
funnel: summaryReady && hasJobs ? pipelineReport : null,
activity: trendsReady && (reporting || hasJobs || hasActivity) ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
<Typography variant="body2" color="text.secondary">{monthSpan}</Typography>
{totalApplied > 0 ? <Typography variant="body2">{t("dashboardResponseSample", { responses: number(totalResponses), total: number(totalApplied) })} ({number(totalResponses / totalApplied * 100)}%)</Typography> : null}
@@ -322,25 +259,20 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
<TableBody>{analytics.map(point => <TableRow key={point.month}><TableCell component="th" scope="row">{monthLabel(point.month)}</TableCell><TableCell>{number(point.applied)}</TableCell><TableCell>{number(point.responses)}</TableCell></TableRow>)}</TableBody>
</Table>
</Box>
</PageSection> : null}
{reporting && summaryReady && hasJobs ? pipelineReport : null}
{summaryReady && hasJobs ? <Box sx={grid}>
{(overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
</PageSection> : null,
stageAge: summaryReady && hasJobs && (overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
<Stack spacing={1.5}>{overview!.timeInStage!.map(item => <Box key={item.stage}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}><Typography variant="body2">{statusLabel(t, item.stage)}</Typography><Typography variant="body2">{t("dashboardTimeInStageValue", { days: number(item.medianDays), count: number(item.count) })}</Typography></Box>
{bar(item.medianDays, Math.max(1, ...overview!.timeInStage!.map(stage => stage.medianDays)), "info.main")}
</Box>)}</Stack>
</PageSection> : null}
{(overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
</PageSection> : null,
sources: summaryReady && hasJobs && (overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
<Stack spacing={1.5}>{overview!.responseRateBySource.map(item => <Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{item.label}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.rate)}%</Typography></Box>
<Typography variant="caption" color="text.secondary">{t("dashboardRecordedResponseSample", { responses: number(item.responses), total: number(item.total) })}</Typography>
</Box>)}</Stack>
</PageSection> : null}
</Box> : null}
{summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
</PageSection> : null,
salary: summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{overview!.salaryInsights!.map(item => {
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
@@ -351,17 +283,15 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Box>;
})}
</Box>
</PageSection> : null}
<Box sx={grid}>
{summaryReady && hasJobs && prefs.companies ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
</PageSection> : null,
companies: summaryReady && hasJobs ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
<Stack spacing={1.5}>{(overview?.topCompanies ?? []).map(item => <Box key={item.companyId} sx={{ borderBottom: "1px solid", borderColor: "divider", pb: 1.5 }}>
<Link component={RouterLink} to={`/jobs?companyId=${item.companyId}`} sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>{item.company}</Link>
<Typography variant="body2" color="text.secondary">{t("dashboardCompanyJobsResponses", { jobs: number(item.count), responses: number(item.responses) })} ({number(item.responseRate)}%)</Typography>
</Box>)}</Stack>
{!overview?.topCompanies.length ? <Typography color="text.secondary">{t("companiesEmpty")}</Typography> : null}
</PageSection> : null}
{trendsReady && (reporting || hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
</PageSection> : null,
skills: trendsReady && (reporting || hasJobs || tags.length > 0) ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
<Stack spacing={1.5}>{tags.slice(0, reporting ? 10 : 8).map(tag => <Box key={tag.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{tag.tag}</Typography><Typography variant="body2">{number(tag.count)}</Typography></Box>
{bar(tag.count, Math.max(1, ...tags.map(item => item.count)), "primary.main")}
@@ -388,8 +318,61 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Table>
</Box>)}
</Box>
</PageSection> : null}
</PageSection> : null,
};
return (
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
</Box>
{!reporting && layoutState.editing && layoutState.draft ? <>
<DashboardLayoutEditor draft={layoutState.draft} dirty={layoutState.dirty} saving={layoutState.saving || layoutState.loading}
saveError={layoutState.saveError} conflict={layoutState.conflict} onChange={layoutState.setDraft}
onSave={() => void saveLayout()} onCancel={cancelLayout} onReload={() => void layoutState.reload()} />
{dataRouter ? <DashboardNavigationGuard dirty={layoutState.dirty} saving={layoutState.saving} /> : null}
</> : null}
{!reporting && layoutState.loadError ? <Alert severity="warning" action={<Button onClick={() => void layoutState.reload()}>{t("retry")}</Button>}>
{t(layoutState.loadError === "unsupportedVersion" ? "dashboardUnsupportedVersion" : "dashboardLoadFailed")}
</Alert> : null}
{!reporting && savedMessage ? <Typography role="status">{t("dashboardSaved")}</Typography> : null}
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
</PageSection> : null}
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
</Box> : null}
{reporting ? <>
{reports.activity}
{reports.funnel}
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
{reports.salary}
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
</> : <Box aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
const content = reports[widget.id];
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
if (!content && !(ready && (hasJobs || layoutState.editing))) return null;
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size} sx={{ minWidth: 0, gridColumn: { xs: "auto", md: widget.size === "full" ? "1 / -1" : "auto" } }}>
{content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>}
</Box>;
})}
</Box>}
</Box>
);
}
@@ -0,0 +1,94 @@
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import DashboardView from './components/DashboardView';
import { I18nProvider } from './i18n/I18nProvider';
import { ConfirmProvider } from './confirm';
import { api } from './api';
const mocked = api as jest.Mocked<typeof api>;
let stored: any;
const stats = { total: 2, active: 2, byStatus: { Applied: 2 }, appliedLast30Days: 2 };
function response(url: string) {
if (url.endsWith('/preferences/dashboard')) return stored;
if (url.endsWith('/stats')) return stats;
if (url.endsWith('/analytics-overview')) return { funnel: [{ label: 'Applied', count: 2 }], totalActive: 2, totalResponses: 0, responseRateBySource: [], topCompanies: [], timeInStage: [], salaryInsights: [] };
if (url.endsWith('/tag-trends')) return { months: [], series: [] };
return [];
}
function open() {
const router = createMemoryRouter([{ path: '/dashboard', element: <DashboardView /> }, { path: '/jobs', element: <div>Jobs destination</div> }], { initialEntries: ['/dashboard'] });
return render(<I18nProvider><ConfirmProvider><RouterProvider router={router} /></ConfirmProvider></I18nProvider>);
}
beforeEach(() => {
localStorage.clear(); stored = { version: 1, revision: 0, widgets: [] };
mocked.get.mockImplementation((url: string) => Promise.resolve({ data: response(url) }) as any);
mocked.put.mockReset(); mocked.put.mockImplementation((_url: string, body: any) => { stored = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: stored }) as any; });
});
async function edit() { fireEvent.click(await screen.findByRole('button', { name: 'Customize dashboard' })); return screen.findByRole('region', { name: 'Editing dashboard' }); }
test('visibility is a preview; cancel restores the saved layout without a write', async () => {
open(); await screen.findByText('Tracked jobs');
const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: 'Cancel' }));
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Discard changes' }));
await screen.findByText('Tracked jobs'); expect(mocked.put).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByRole('button', { name: 'Customize dashboard' })).toHaveFocus());
});
test('failed save keeps the draft and retry persists visibility, order and size', async () => {
open(); const editor = await edit();
fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
fireEvent.click(within(editor).getByRole('button', { name: 'Move Today earlier' }));
fireEvent.change(within(editor).getByLabelText('Size for Today'), { target: { value: 'full' } });
mocked.put.mockRejectedValueOnce(new Error('offline'));
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await screen.findByText('Your layout could not be saved. Your changes are still here. Try again.');
expect(within(editor).getByRole('checkbox', { name: 'Summary cards' })).not.toBeChecked();
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument());
expect(stored.widgets[0]).toEqual({ id: 'attention', visible: true, size: 'full' });
expect(stored.widgets.find((widget: any) => widget.id === 'cards').visible).toBe(false);
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
});
test('all hidden widgets leave an accessible recovery and reset remains cancellable', async () => {
open(); const editor = await edit();
for (const checkbox of within(editor).getAllByRole('checkbox')) fireEvent.click(checkbox);
await screen.findByText('Your dashboard sections are hidden.');
fireEvent.click(within(editor).getByRole('button', { name: 'Reset to default' }));
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Reset to default' }));
await waitFor(() => expect(within(editor).getAllByRole('checkbox').every(item => (item as HTMLInputElement).checked)).toBe(true));
expect(mocked.put).not.toHaveBeenCalled();
});
test('keyboard ordering keeps focus and announces the resulting position without refetching reports', async () => {
open(); const editor = await edit(); const before = mocked.get.mock.calls.length;
const move = within(editor).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move);
await waitFor(() => expect(within(editor).getByRole('button', { name: 'Move Today later' })).toHaveFocus());
expect(screen.getByText('Today moved to position 1 of 9.')).toBeInTheDocument();
expect(mocked.get.mock.calls).toHaveLength(before);
});
test('dirty route navigation uses the existing confirmation and can stay on the dashboard', async () => {
open(); const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Job tags' }));
fireEvent.click(screen.getByRole('link', { name: 'Tracked jobs: 2' }));
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Keep editing' }));
expect(await screen.findByRole('region', { name: 'Editing dashboard' })).toBeVisible();
expect(mocked.put).not.toHaveBeenCalled();
});
test('navigation waits for an in-flight save instead of promising to discard a possible commit', async () => {
open(); const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Job tags' }));
let complete!: (value: any) => void;
mocked.put.mockImplementationOnce((_url: string, body: any) => new Promise(resolve => { complete = resolve; stored = { ...body, revision: 1 }; }) as any);
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
fireEvent.click(screen.getByRole('link', { name: 'Tracked jobs: 2' }));
expect(await screen.findByText('Please wait for your layout to finish saving before leaving.')).toBeVisible();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Jobs destination')).not.toBeInTheDocument();
await act(async () => { complete({ data: stored }); });
expect(await screen.findByRole('button', { name: 'Customize dashboard' })).toBeVisible();
});
@@ -5,6 +5,7 @@ import { MemoryRouter, useLocation } from 'react-router-dom';
import DashboardView from './components/DashboardView';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
import { ConfirmProvider } from './confirm';
jest.mock('./components/OnboardingChecklist', () => ({ hasJobs }: { hasJobs: boolean | null }) => <><div>Setup retained</div><output aria-label="Setup job status">{hasJobs === null ? 'unknown' : String(hasJobs)}</output></>);
const mockedApi = api as jest.Mocked<typeof api>;
@@ -12,8 +13,10 @@ const stats = { total: 9, active: 8, deleted: 1, byStatus: { Applied: 3, Intervi
const overview = { funnel: [{ label: 'Applied', count: 3 }, { label: 'Interview', count: 2 }, { label: 'Offer', count: 1 }], totalActive: 8, totalResponses: 3, responseRateBySource: [{ label: 'NAV', total: 8, responses: 3, rate: 37.5 }], topCompanies: [{ companyId: 7, company: 'Nordlys AS', count: 8, responses: 3, responseRate: 37.5 }], timeInStage: [{ stage: 'Applied', medianDays: 4, count: 3 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 600000, maximum: 800000, averageMidpoint: 700000 }] };
const monthly = [{ month: '2026-08', applied: 4, responses: 1 }, { month: '2026-09', applied: 2, responses: 1 }];
function Location() { return <output aria-label="Destination">{useLocation().pathname}{useLocation().search}</output>; }
function open() { return render(<I18nProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></I18nProvider>); }
function open() { return render(<I18nProvider><ConfirmProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></ConfirmProvider></I18nProvider>); }
let layout: any;
function data(url: string) {
if (url.endsWith('/preferences/dashboard')) return layout;
if (url.endsWith('/stats')) return stats;
if (url.endsWith('/analytics-overview')) return overview;
if (url.endsWith('/analytics')) return monthly;
@@ -21,7 +24,11 @@ function data(url: string) {
if (url.endsWith('/tag-trends')) return { months: ['2026-08', '2026-09'], series: [{ tag: 'TypeScript', counts: [3, 2] }] };
return [];
}
beforeEach(() => { localStorage.clear(); mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any); });
beforeEach(() => {
localStorage.clear(); layout = { version: 1, revision: 0, widgets: [] };
mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any);
mockedApi.put.mockImplementation((_url: string, body: any) => { layout = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: layout }) as any; });
});
test('[PRES-S01] exact monthly values, sample, custom statuses and separate salary group remain readable', async () => {
open();
@@ -37,11 +44,14 @@ test('[PRES-S01] exact monthly values, sample, custom statuses and separate sala
expect(screen.queryByText(/upcoming|conversion funnel|active applications/i)).not.toBeInTheDocument();
});
test('[PRES-S02] all five stored visibility flags hide their groups and survive remount', async () => {
test('[PRES-S02] all widget visibility choices save and survive remount', async () => {
let view = open(); await screen.findByText('Tracked jobs');
fireEvent.click(screen.getByRole('button', { name: 'Customize dashboard' }));
for (const item of screen.getAllByRole('menuitemcheckbox')) fireEvent.click(item);
expect(screen.getAllByRole('menuitemcheckbox').every(item => item.getAttribute('aria-checked') === 'false')).toBe(true);
const editor = await screen.findByRole('region', { name: 'Editing dashboard' });
for (const item of within(editor).getAllByRole('checkbox')) fireEvent.click(item);
expect(within(editor).getAllByRole('checkbox').every(item => !(item as HTMLInputElement).checked)).toBe(true);
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument());
view.unmount(); view = open();
await screen.findByText('Your dashboard sections are hidden.');
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
+64
View File
@@ -1743,6 +1743,38 @@ export const translations = {
dashboardCompanyJobsResponses: "{jobs} jobs · {responses} responses",
dashboardOverviewBody: "A quick view of your application activity, follow-ups, and momentum.",
dashboardCustomize: "Customize dashboard",
dashboardEditing: "Editing dashboard",
dashboardEditingBody: "Choose what matters to you. Changes appear below; save to use this layout on your other devices.",
dashboardManageWidgets: "Manage dashboard widgets",
dashboardSave: "Save layout",
dashboardSaving: "Saving layout…",
dashboardWaitForSave: "Please wait for your layout to finish saving before leaving.",
dashboardSaved: "Dashboard layout saved.",
dashboardDraftSaved: "Your saved layout. Changes are only applied when you save.",
dashboardSaveFailed: "Your layout could not be saved. Your changes are still here. Try again.",
dashboardLoadFailed: "Your saved layout could not be loaded. Retry before editing to protect your settings.",
dashboardUnsupportedVersion: "This layout was saved by a newer version. Reload or update the app before editing.",
dashboardConflict: "Your dashboard changed in another session. Reload the latest layout before applying your changes again.",
dashboardReload: "Reload latest layout",
dashboardReloadBody: "Replace your unsaved changes with the latest saved layout?",
dashboardReset: "Reset to default",
dashboardResetBody: "Restore the default dashboard widgets, order and sizes? This only changes your draft. Your jobs and other settings are kept.",
dashboardDiscardTitle: "Discard dashboard changes?",
dashboardDiscardBody: "Your layout has unsaved changes. Discard them and return to the saved layout?",
dashboardDiscard: "Discard changes",
dashboardEarlier: "Earlier",
dashboardLater: "Later",
dashboardMoveEarlierNamed: "Move {name} earlier",
dashboardMoveLaterNamed: "Move {name} later",
dashboardDrag: "Drag {name} to reorder",
dashboardMoved: "{name} moved to position {position} of {total}.",
dashboardSizeFor: "Size for {name}",
dashboardSize: "Size",
dashboardSizeStandard: "Standard",
dashboardSizeFull: "Full width",
dashboardReorderHelp: "Drag a handle to reorder, or use Earlier and Later with keyboard or touch. Hidden widgets keep their position and size. On small screens every widget fills the width.",
dashboardPreview: "Dashboard preview",
dashboardNoSample: "Not enough recorded data for this widget yet.",
dashboardSummaryCards: "Summary cards",
dashboardActivityChart: "Activity chart",
dashboardConversionFunnel: "Current pipeline",
@@ -4584,6 +4616,38 @@ export const translations = {
dashboardCompanyJobsResponses: "{jobs} jobber · {responses} svar",
dashboardOverviewBody: "En rask oversikt over aktivitet, oppfølginger og fremdrift i jobbsøket ditt.",
dashboardCustomize: "Tilpass dashboard",
dashboardEditing: "Tilpasser dashboard",
dashboardEditingBody: "Velg det som er viktig for deg. Endringene vises nedenfor. Lagre for å bruke oppsettet på de andre enhetene dine.",
dashboardManageWidgets: "Administrer dashboard-widgeter",
dashboardSave: "Lagre oppsett",
dashboardSaving: "Lagrer oppsett…",
dashboardWaitForSave: "Vent til oppsettet er ferdig lagret før du forlater siden.",
dashboardSaved: "Dashboard-oppsettet er lagret.",
dashboardDraftSaved: "Ditt lagrede oppsett. Endringene tas i bruk når du lagrer.",
dashboardSaveFailed: "Oppsettet kunne ikke lagres. Endringene dine er beholdt. Prøv igjen.",
dashboardLoadFailed: "Det lagrede oppsettet kunne ikke lastes. Prøv igjen før du redigerer, slik at innstillingene dine bevares.",
dashboardUnsupportedVersion: "Oppsettet ble lagret av en nyere versjon. Last inn på nytt eller oppdater appen før du redigerer.",
dashboardConflict: "Dashboardet ble endret i en annen økt. Last inn det nyeste oppsettet før du gjør endringene på nytt.",
dashboardReload: "Last inn nyeste oppsett",
dashboardReloadBody: "Erstatte endringene du ikke har lagret, med det nyeste lagrede oppsettet?",
dashboardReset: "Tilbakestill til standard",
dashboardResetBody: "Gjenopprette standard widgeter, rekkefølge og størrelser? Dette endrer bare utkastet. Jobbene og de andre innstillingene dine beholdes.",
dashboardDiscardTitle: "Forkaste dashboard-endringene?",
dashboardDiscardBody: "Oppsettet har endringer som ikke er lagret. Vil du forkaste dem og gå tilbake til det lagrede oppsettet?",
dashboardDiscard: "Forkast endringer",
dashboardEarlier: "Tidligere",
dashboardLater: "Senere",
dashboardMoveEarlierNamed: "Flytt {name} tidligere",
dashboardMoveLaterNamed: "Flytt {name} senere",
dashboardDrag: "Dra {name} for å endre rekkefølgen",
dashboardMoved: "{name} er flyttet til plass {position} av {total}.",
dashboardSizeFor: "Størrelse for {name}",
dashboardSize: "Størrelse",
dashboardSizeStandard: "Standard",
dashboardSizeFull: "Full bredde",
dashboardReorderHelp: "Dra i håndtaket for å endre rekkefølgen, eller bruk Tidligere og Senere med tastatur eller berøring. Skjulte widgeter beholder plass og størrelse. På små skjermer fyller alle widgeter bredden.",
dashboardPreview: "Forhåndsvisning av dashboard",
dashboardNoSample: "Det er ikke nok registrerte data for denne widgeten ennå.",
dashboardSummaryCards: "Oppsummeringskort",
dashboardActivityChart: "Aktivitetsgraf",
dashboardConversionFunnel: "Nåværende statusfordeling",
+3
View File
@@ -37,6 +37,9 @@ jest.mock('./components/MicrosoftAuthCard', () => () => null);
beforeEach(() => {
const { api } = require('./api');
api.get.mockImplementation((url: string) => {
if (url === '/auth/preferences/dashboard') {
return Promise.resolve({ data: { version: 1, revision: 0, widgets: [] } });
}
if (url === '/auth/config') {
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } });
}