Files
app/src/admin/pages/Dashboard.tsx
BOHA 27c690285a fix(theme): white icon glyphs on solid tiles + single theme storage key
Two issues:

1. Icon badges read as a coloured glyph on a same-hue LIGHT tile
   (bgcolor X.light + color X.main) — low contrast, "badly visible".
   The AttendanceHistory month tile and the Dashboard 2FA-banner icon now
   use a SOLID tile (X.main) + white glyph, matching the StatCard badge
   convention. Readable in both schemes (verified: info.main #1d4ed8 light
   / #3b82f6 dark, error.main, white glyph).

2. Theme reverted to light on F5 while the toggle still showed dark.
   Cause: two independent stores — our ThemeContext key (boha-theme) and
   MUI's own cssVariables mode key (mui-mode). The toggle only wrote
   boha-theme, so on mount MUI restored its stale mui-mode and overrode
   data-theme. Collapsed to a SINGLE source of truth: ThemeContext now
   reads/writes MUI's `mui-mode` key directly (with a one-time migration
   from the legacy boha-theme, which is then removed). Verified: toggle
   dark -> F5 -> stays dark; only `mui-mode` remains in localStorage.

tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:12:20 +02:00

513 lines
17 KiB
TypeScript

import { useState, useCallback } from "react";
import { Link as RouterLink } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
import { Card, Button, StatusChip, PageEnter } from "../ui";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
const API_BASE = "/api/admin";
interface DashData {
my_shift?: { has_ongoing: boolean };
attendance?: {
present_today: number;
total_active: number;
on_leave: number;
users: Array<{
user_id: number | string;
name: string;
initials?: string;
status: string;
leave_type?: string;
arrived_at?: string;
}>;
};
offers?: {
open_count: number;
converted_count: number;
expired_count: number;
created_this_month: number;
};
projects?: {
active_projects: Array<{
id: number;
name: string;
customer_name: string | null;
}>;
};
invoices?: {
revenue_this_month: Array<{ amount: number; currency: string }>;
unpaid_count: number;
revenue_czk: number | null;
};
leave_pending?: { count: number };
recent_activity?: Array<{
id: number | string;
action: string;
entity_type: string;
description: string;
username?: string;
created_at: string;
}>;
users_count?: number;
active_projects?: number;
pending_orders?: number;
unpaid_invoices?: number;
pending_leave_requests?: number;
[key: string]: unknown;
}
export default function Dashboard() {
const { user, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [punching, setPunching] = useState(false);
const queryClient = useQueryClient();
const { data: dashDataRaw, isPending: dashLoading } =
useQuery(dashboardOptions());
const dashData = dashDataRaw as DashData | undefined;
const { data: totpData, isPending: totpLoading } =
useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "dashboard"],
});
// 2FA state - sdileny mezi profilem a bannerem
const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false);
const [totpSecret, setTotpSecret] = useState<string | null>(null);
const [totpQrUri, setTotpQrUri] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState("");
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [disableCode, setDisableCode] = useState("");
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
setPunching(true);
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
try {
const result = await punchMutation.mutateAsync({
punch_action: action,
...gpsData,
});
alert.success(result?.message || "Docházka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba pripojeni");
} finally {
setPunching(false);
}
};
if (!navigator.geolocation) {
submitPunch({});
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude, accuracy } = pos.coords;
submitPunch({ latitude, longitude, accuracy, address: "" });
},
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
}, [dashData, alert, punchMutation]);
// 2FA handlery
const handleStart2FASetup = async () => {
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpSecret(data.data.secret);
setTotpQrUri(data.data.uri || data.data.qr_uri);
setTotpCode("");
setBackupCodes(null);
setShow2FASetup(true);
} else {
alert.error(data.error || "Nepodařilo se vygenerovat 2FA klíč");
}
} catch {
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false);
}
};
const handleConfirm2FA = async () => {
if (!totpCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/enable`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() }),
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
updateUser({ totpEnabled: true });
alert.success("2FA bylo aktivováno");
} else {
alert.error(data.error || "Neplatný kód");
setTotpCode("");
}
} catch {
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false);
}
};
const handleDisable2FA = async () => {
if (!disableCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/disable`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: disableCode.trim() }),
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
alert.success("2FA bylo deaktivováno");
} else {
alert.error(data.error || "Neplatný kód");
setDisableCode("");
}
} catch {
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false);
}
};
return (
<PageEnter>
{/* Header */}
<Box sx={{ mb: 3 }}>
<Typography variant="h4">
Vítejte zpět, {user?.fullName || user?.username}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{getCzechDate()}
</Typography>
</Box>
{/* 2FA Required Banner */}
{user?.require2FA && !user?.totpEnabled && (
<Card
sx={{
mb: 3,
border: 2,
borderColor: "error.main",
backgroundColor:
"rgba(var(--mui-palette-error-mainChannel) / 0.12)",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "error.main",
color: "#fff",
flexShrink: 0,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</Box>
<Box>
<Typography sx={{ fontWeight: 600 }}>
Dvoufaktorové ověření je povinné
</Typography>
<Typography variant="body2" color="text.secondary">
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</Typography>
</Box>
</Box>
<Button
onClick={handleStart2FASetup}
disabled={totpSubmitting}
sx={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
</Button>
</Box>
</Card>
)}
{/* Loading spinner */}
{dashLoading && (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress />
</Box>
)}
{/* KPI cards — only show if user has any admin-level permissions */}
{!dashLoading &&
(hasPermission("offers.view") ||
hasPermission("invoices.view") ||
hasPermission("projects.view") ||
hasPermission("orders.view")) && (
<DashKpiCards dashData={dashData ?? null} />
)}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions
dashData={dashData ?? null}
punching={punching}
onPunch={handleQuickPunch}
/>
)}
{/* Main content grid */}
{!dashLoading && (
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
gap: 3,
alignItems: "start",
mb: 3,
}}
>
{hasPermission("settings.audit") && (
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
)}
{hasPermission("attendance.manage") && (
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
)}
{/* Pravy sloupec: projekty + nabidky */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{dashData?.projects && (
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Aktivní projekty</Typography>
<Button
component={RouterLink}
to="/projects"
size="small"
sx={{ flexShrink: 0 }}
>
Vše &rarr;
</Button>
</Box>
{dashData.projects.active_projects.length === 0 && (
<Typography
variant="body2"
color="text.secondary"
sx={{ py: 1 }}
>
Žádné aktivní projekty
</Typography>
)}
{dashData.projects.active_projects.map((p) => (
<Box
key={p.id}
component={RouterLink}
to={`/projects/${p.id}`}
sx={{
display: "block",
py: 1.25,
textDecoration: "none",
color: "inherit",
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
"&:hover": { color: "primary.main" },
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{p.name}
</Typography>
{p.customer_name && (
<Typography variant="caption" color="text.secondary">
{p.customer_name}
</Typography>
)}
</Box>
))}
</Card>
)}
{dashData?.offers && (
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Nabídky</Typography>
<Button
component={RouterLink}
to="/offers"
size="small"
sx={{ flexShrink: 0 }}
>
Zobrazit &rarr;
</Button>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
py: 1,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="body2">Otevřené</Typography>
<StatusChip color="info" label={dashData.offers.open_count} />
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
py: 1,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="body2">
Převedené na objednávku
</Typography>
<StatusChip
color="success"
label={dashData.offers.converted_count}
/>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
py: 1,
}}
>
<Typography variant="body2">Zneplatněné</Typography>
<StatusChip
color="warning"
label={dashData.offers.expired_count}
/>
</Box>
</Card>
)}
</Box>
</Box>
)}
{/* Profile + Sessions */}
{!dashLoading && (
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
gap: 3,
alignItems: "start",
}}
>
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
totpSubmitting={totpSubmitting}
onStart2FASetup={handleStart2FASetup}
onConfirm2FA={handleConfirm2FA}
onDisable2FA={handleDisable2FA}
totpSecret={totpSecret}
totpQrUri={totpQrUri}
totpCode={totpCode}
setTotpCode={setTotpCode}
backupCodes={backupCodes}
setBackupCodes={setBackupCodes}
show2FASetup={show2FASetup}
setShow2FASetup={setShow2FASetup}
show2FADisable={show2FADisable}
setShow2FADisable={setShow2FADisable}
disableCode={disableCode}
setDisableCode={setDisableCode}
/>
<DashSessions />
</Box>
)}
</PageEnter>
);
}