feat(mui): migrate Dashboard (Přehled) onto MUI kit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 01:19:49 +02:00
parent 5ca03bd662
commit 5459d8c325
7 changed files with 1205 additions and 968 deletions

View File

@@ -1,4 +1,7 @@
import { Link } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Card, Button, EmptyState } from "../../ui";
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
getActivityIconClass, getActivityIconClass,
@@ -18,6 +21,16 @@ interface DashActivityFeedProps {
activities: Activity[] | null; activities: Activity[] | null;
} }
// Maps the legacy activity-icon class to the MUI palette key used to tint the
// circular icon badge (danger → error, accent → primary, muted → default).
const ACTIVITY_BADGE_COLOR: Record<string, string> = {
success: "success",
info: "info",
danger: "error",
accent: "primary",
muted: "default",
};
function getActivityIcon(action: string) { function getActivityIcon(action: string) {
switch (action) { switch (action) {
case "create": case "create":
@@ -103,37 +116,92 @@ export default function DashActivityFeed({
} }
return ( return (
<div className="admin-card dash-activity-card"> <Card sx={{ display: "flex", flexDirection: "column" }}>
<div className="admin-card-header flex-between"> <Box
<h2 className="admin-card-title">Audit log</h2> sx={{
<Link display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Audit log</Typography>
<Button
component={RouterLink}
to="/audit-log" to="/audit-log"
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
Detail &rarr; Detail &rarr;
</Link> </Button>
</div> </Box>
<div className="admin-card-body" style={{ padding: 0 }}>
{activities.map((act) => ( {activities.length === 0 ? (
<div key={act.id} className="dash-activity-row"> <EmptyState title="Žádná aktivita" />
<div ) : (
className={`dash-activity-icon ${getActivityIconClass(act.action)}`} <Box sx={{ display: "flex", flexDirection: "column" }}>
> {activities.map((act) => {
{getActivityIcon(act.action)} const badgeColor =
</div> ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ??
<div className="dash-activity-main"> "default";
<div className="dash-activity-text">{act.description}</div> const isDefault = badgeColor === "default";
<div className="dash-activity-sub"> return (
{act.username || "Systém"} ·{" "} <Box
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type} key={act.id}
</div> sx={{
</div> display: "flex",
<div className="dash-activity-time admin-mono"> alignItems: "center",
{formatActivityTime(act.created_at)} gap: 1.5,
</div> py: 1.25,
</div> borderBottom: 1,
))} borderColor: "divider",
</div> "&:last-of-type": { borderBottom: 0 },
</div> }}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
bgcolor: isDefault ? "action.hover" : `${badgeColor}.light`,
color: isDefault ? "text.secondary" : `${badgeColor}.main`,
}}
>
{getActivityIcon(act.action)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{ fontWeight: 500 }}
noWrap
title={act.description}
>
{act.description}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{act.username || "Systém"} ·{" "}
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
flexShrink: 0,
}}
>
{formatActivityTime(act.created_at)}
</Typography>
</Box>
);
})}
</Box>
)}
</Card>
); );
} }

View File

@@ -1,9 +1,9 @@
import { Link } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { import Box from "@mui/material/Box";
LEAVE_TYPE_LABELS, import Typography from "@mui/material/Typography";
STATUS_DOT_CLASS, import Avatar from "@mui/material/Avatar";
STATUS_LABELS, import { Card, Button, StatusChip, EmptyState } from "../../ui";
} from "../../utils/dashboardHelpers"; import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
interface AttendanceUser { interface AttendanceUser {
user_id: number | string; user_id: number | string;
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
attendance: AttendanceData | null; attendance: AttendanceData | null;
} }
// Maps the attendance status to the StatusChip / avatar tint color.
// Mirrors the legacy dash-status-* dot classes: in→success, away→warning,
// out→default, leave→error.
const STATUS_COLOR: Record<
string,
"default" | "success" | "warning" | "error"
> = {
in: "success",
away: "warning",
out: "default",
leave: "error",
};
export default function DashAttendanceToday({ export default function DashAttendanceToday({
attendance, attendance,
}: DashAttendanceTodayProps) { }: DashAttendanceTodayProps) {
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
} }
return ( return (
<div className="admin-card dash-attendance-card"> <Card sx={{ display: "flex", flexDirection: "column" }}>
<div className="admin-card-header flex-between"> <Box
<h2 className="admin-card-title">Docházka dnes</h2> sx={{
<Link display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Docházka dnes</Typography>
<Button
component={RouterLink}
to="/attendance/admin" to="/attendance/admin"
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
Detail &rarr; Detail &rarr;
</Link> </Button>
</div> </Box>
<div className="admin-card-body" style={{ padding: 0 }}>
{attendance.users.map((u, i) => ( {attendance.users.length === 0 ? (
<div key={`${u.user_id}-${i}`} className="dash-presence-row"> <EmptyState title="Žádná docházka" />
<div ) : (
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`} <Box sx={{ display: "flex", flexDirection: "column" }}>
> {attendance.users.map((u, i) => {
{u.initials || "?"} const color = STATUS_COLOR[u.status] ?? "default";
</div> const isDefault = color === "default";
<div className="dash-presence-name">{u.name}</div> return (
<div className="dash-presence-end"> <Box
<span key={`${u.user_id}-${i}`}
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`} sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
}}
> >
{u.status === "leave" <Avatar
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen" sx={{
: STATUS_LABELS[u.status]} width: 36,
</span> height: 36,
{u.arrived_at && ( fontSize: "0.8rem",
<span className="admin-mono dash-presence-time"> fontWeight: 700,
{u.arrived_at} bgcolor: isDefault ? "action.hover" : `${color}.light`,
</span> color: isDefault ? "text.secondary" : `${color}.main`,
)} }}
</div> >
</div> {u.initials || "?"}
))} </Avatar>
</div> <Typography
</div> variant="body2"
sx={{ fontWeight: 500, flex: 1, minWidth: 0 }}
noWrap
>
{u.name}
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexShrink: 0,
}}
>
<StatusChip
color={color}
label={
u.status === "leave"
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
: STATUS_LABELS[u.status]
}
/>
{u.arrived_at && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{u.arrived_at}
</Typography>
)}
</Box>
</Box>
);
})}
</Box>
)}
</Card>
); );
} }

View File

@@ -1,4 +1,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { StatCard, type StatCardColor } from "../../ui";
import { formatCurrency } from "../../utils/formatters"; import { formatCurrency } from "../../utils/formatters";
interface KpiCard { interface KpiCard {
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
}; };
} }
const KPI_CLASS_MAP: Record<number, string> = { // Maps the legacy KPI color tokens to the StatCard color palette
4: "admin-kpi-4", // (danger → error; the rest are 1:1).
3: "admin-kpi-3", const KPI_COLOR_MAP: Record<string, StatCardColor> = {
2: "admin-kpi-2", success: "success",
1: "admin-kpi-1", info: "info",
warning: "warning",
danger: "error",
}; };
export default function DashKpiCards({ dashData }: DashKpiCardsProps) { export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
return null; return null;
} }
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
return ( return (
<motion.div <Box
className={`admin-kpi-grid ${kpiClass}`} component={motion.div}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
},
gap: 2,
mb: 3,
}}
> >
{kpiCards.map((kpi) => ( {kpiCards.map((kpi) => (
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}> <StatCard
<div className="admin-stat-label">{kpi.label}</div> key={kpi.label}
<div className="admin-stat-value admin-mono"> color={KPI_COLOR_MAP[kpi.color] ?? "default"}
{kpi.value} label={kpi.label}
{kpi.sub && ( value={
<small <>
className="text-muted" {kpi.value}
style={{ {kpi.sub && (
fontSize: "0.75em", <Typography
fontWeight: 500, component="span"
marginLeft: "0.25rem", color="text.secondary"
}} sx={{
> fontSize: "0.75em",
{kpi.sub} fontWeight: 500,
</small> ml: 0.5,
)} fontFamily: "inherit",
</div> }}
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>} >
</div> {kpi.sub}
</Typography>
)}
</>
}
footer={kpi.footer ?? undefined}
/>
))} ))}
</motion.div> </Box>
); );
} }

View File

@@ -1,9 +1,17 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import FormModal from "../FormModal"; import { Card, Button, Modal, Field, TextField } from "../../ui";
import useDialogScrollLock from "../../ui/useDialogScrollLock";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -37,6 +45,34 @@ interface ProfileFormData {
last_name: string; last_name: string;
} }
const EditIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const CopyIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
export default function DashProfile({ export default function DashProfile({
totpEnabled, totpEnabled,
totpLoading, totpLoading,
@@ -61,6 +97,10 @@ export default function DashProfile({
const alert = useAlert(); const alert = useAlert();
const totpSetupRef = useRef<HTMLInputElement>(null); const totpSetupRef = useRef<HTMLInputElement>(null);
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
// locks <html> scroll like the kit dialogs.
useDialogScrollLock(show2FASetup);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({ const [formData, setFormData] = useState<ProfileFormData>({
username: "", username: "",
@@ -133,80 +173,94 @@ export default function DashProfile({
return totpEnabled ? "Aktivní" : "Neaktivní"; return totpEnabled ? "Aktivní" : "Neaktivní";
} }
const profileItems: Array<{ label: string; value: string }> = [
{ label: "Uživatel", value: user?.username || "" },
{ label: "E-mail", value: user?.email || "" },
{ label: "Jméno", value: user?.fullName || "" },
{
label: "Role",
value: user?.roleDisplay || String(user?.role || ""),
},
];
return ( return (
<> <>
<motion.div <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
> >
<div className="admin-card-header flex-between"> <Card>
<h2 className="admin-card-title">Váš účet</h2> <Box
<button sx={{
onClick={openEditModal} display: "flex",
className="admin-btn admin-btn-secondary admin-btn-sm" alignItems: "center",
> justifyContent: "space-between",
<svg mb: 2,
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Upravit
</button>
</div>
<div className="admin-card-body">
<div className="dash-profile-grid">
<div className="dash-profile-item">
<span className="dash-profile-label">Uživatel</span>
<span className="dash-profile-value">{user?.username}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">E-mail</span>
<span className="dash-profile-value">{user?.email}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Jméno</span>
<span className="dash-profile-value">{user?.fullName}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Role</span>
<span className="dash-profile-value">
{user?.roleDisplay || String(user?.role || "")}
</span>
</div>
</div>
{/* 2FA Section */}
<div
style={{
borderTop: "1px solid var(--border-color)",
marginTop: "1rem",
paddingTop: "1rem",
}} }}
> >
<div className="flex-between"> <Typography variant="h6">Váš účet</Typography>
<div className="flex-row-gap"> <Button
<div onClick={openEditModal}
style={{ variant="outlined"
color="inherit"
size="small"
startIcon={EditIcon}
>
Upravit
</Button>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 1.5,
}}
>
{profileItems.map((item) => (
<Box key={item.label}>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 600,
}}
>
{item.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.value}
</Typography>
</Box>
))}
</Box>
{/* 2FA Section */}
<Box sx={{ borderTop: 1, borderColor: "divider", mt: 2, pt: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1.5,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 36, width: 36,
height: 36, height: 36,
borderRadius: "50%", borderRadius: "50%",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
background: totpEnabled flexShrink: 0,
? "var(--success-light)" bgcolor: totpEnabled ? "success.light" : "action.hover",
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)", color: totpEnabled ? "success.main" : "text.secondary",
color: totpEnabled
? "var(--success)"
: "var(--text-secondary)",
}} }}
> >
<svg <svg
@@ -220,406 +274,390 @@ export default function DashProfile({
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
</div> </Box>
<div> <Box>
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}> <Typography variant="body2" sx={{ fontWeight: 500 }}>
Dvoufaktorové ověření (2FA) Dvoufaktorové ověření (2FA)
</div> </Typography>
<div <Typography
className={totpEnabled ? "text-success" : "text-secondary"} variant="caption"
style={{ fontSize: "0.75rem" }} sx={{
color: totpEnabled ? "success.main" : "text.secondary",
}}
> >
{getTotpStatusText()} {getTotpStatusText()}
</div> </Typography>
</div> </Box>
</div> </Box>
{!totpLoading && {!totpLoading &&
(totpEnabled ? ( (totpEnabled ? (
<button <Button
onClick={() => { onClick={() => {
setDisableCode(""); setDisableCode("");
setShow2FADisable(true); setShow2FADisable(true);
}} }}
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
Deaktivovat Deaktivovat
</button> </Button>
) : ( ) : (
<button <Button
onClick={onStart2FASetup} onClick={onStart2FASetup}
disabled={totpSubmitting} disabled={totpSubmitting}
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
{totpSubmitting ? "Generuji..." : "Aktivovat"} {totpSubmitting ? "Generuji..." : "Aktivovat"}
</button> </Button>
))} ))}
</div> </Box>
</div> </Box>
</div> </Card>
</motion.div> </motion.div>
{/* Edit Profile Modal */} {/* Edit Profile Modal */}
<FormModal <Modal
isOpen={showModal} isOpen={showModal}
onClose={() => setShowModal(false)} onClose={() => setShowModal(false)}
title="Upravit profil" title="Upravit profil"
size="md" maxWidth="sm"
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitLabel="Uložit změny" submitText="Uložit změny"
> >
<div className="admin-form"> <Box
<div className="admin-form-row"> sx={{
<div className="admin-form-group"> display: "grid",
<label className="admin-form-label">Jméno</label> gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
<input gap: 2,
type="text" }}
value={formData.first_name} >
onChange={(e) => <Field label="Jméno">
setFormData({ <TextField
...formData, value={formData.first_name}
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input
type="text"
value={formData.username}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, username: e.target.value }) setFormData({ ...formData, first_name: e.target.value })
} }
required required
className="admin-form-input"
/> />
</div> </Field>
<div className="admin-form-group"> <Field label="Příjmení">
<label className="admin-form-label">E-mail</label> <TextField
<input value={formData.last_name}
type="email"
value={formData.email}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, email: e.target.value }) setFormData({ ...formData, last_name: e.target.value })
} }
required required
className="admin-form-input"
/> />
</div> </Field>
<div className="admin-form-group"> </Box>
<label className="admin-form-label">Aktuální heslo</label> <Field label="Uživatelské jméno">
<input <TextField
type="password" value={formData.username}
value={formData.current_password} onChange={(e) =>
onChange={(e) => setFormData({ ...formData, username: e.target.value })
setFormData({ }
...formData, required
current_password: e.target.value, />
}) </Field>
} <Field label="E-mail">
className="admin-form-input" <TextField
placeholder="Pro změnu hesla, zadejte aktuální heslo" type="email"
/> value={formData.email}
</div> onChange={(e) =>
<div className="admin-form-group"> setFormData({ ...formData, email: e.target.value })
<label className="admin-form-label">Nové heslo</label> }
<input required
type="password" />
value={formData.new_password} </Field>
onChange={(e) => <Field label="Aktuální heslo">
setFormData({ <TextField
...formData, type="password"
new_password: e.target.value, value={formData.current_password}
}) onChange={(e) =>
} setFormData({ ...formData, current_password: e.target.value })
className="admin-form-input" }
placeholder="Pro změnu hesla, zadejte nové heslo" placeholder="Pro změnu hesla, zadejte aktuální heslo"
/> />
</div> </Field>
</div> <Field label="Nové heslo">
</FormModal> <TextField
type="password"
value={formData.new_password}
onChange={(e) =>
setFormData({ ...formData, new_password: e.target.value })
}
placeholder="Pro změnu hesla, zadejte nové heslo"
/>
</Field>
</Modal>
{/* 2FA Setup Modal — multi-step: setup → backup codes */} {/* 2FA Setup Dialog — bespoke, multi-step: setup → backup codes.
<FormModal On the backup-codes step the footer + close are hidden and
isOpen={show2FASetup} backdrop/ESC are locked; the single deliberate exit lives in the body
onClose={() => { and also clears the codes (so unsaved codes can't be silently lost). */}
// Only reachable on the setup step (backdrop/ESC/× are locked while <Dialog
// backupCodes is set — that branch is unreachable so removed). open={show2FASetup}
setShow2FASetup(false); onClose={
setTotpCode("");
}}
title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
size="md"
loading={backupCodes ? false : totpSubmitting}
onSubmit={
backupCodes backupCodes
? () => { ? undefined
: () => {
setShow2FASetup(false); setShow2FASetup(false);
setBackupCodes(null); setTotpCode("");
} }
: onConfirm2FA
} }
submitLabel={ fullWidth
backupCodes ? "Rozumím, uložil jsem si kódy" : "Aktivovat 2FA" maxWidth="sm"
} disableScrollLock
submitDisabled={backupCodes ? false : totpCode.length !== 6} slotProps={{ paper: { sx: { borderRadius: 3 } } }}
cancelLabel="Zrušit"
// On the backup-codes step the cancel/× would silently dismiss unsaved
// codes, so hide the footer + × and lock backdrop/ESC; the explicit
// in-body primary is the single deliberate exit.
hideFooter={!!backupCodes}
hideCloseButton={!!backupCodes}
closeOnBackdrop={!backupCodes}
closeOnEsc={!backupCodes}
> >
{backupCodes ? ( <DialogTitle>
<div> {backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
<div className="admin-role-locked-notice mb-4"> </DialogTitle>
<svg <DialogContent>
width="16" {backupCodes ? (
height="16" <Box>
viewBox="0 0 24 24" <Box
fill="none" sx={{
stroke="currentColor" display: "flex",
strokeWidth="2" alignItems: "flex-start",
gap: 1,
p: 1.5,
mb: 2,
borderRadius: 2,
bgcolor: "warning.light",
color: "warning.main",
fontSize: "0.875rem",
}}
> >
<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" /> <Box sx={{ flexShrink: 0, mt: "2px" }}>
<line x1="12" y1="9" x2="12" y2="13" /> <svg
<line x1="12" y1="17" x2="12.01" y2="17" /> width="16"
</svg> height="16"
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze viewBox="0 0 24 24"
jednou. Po zavření tohoto okna je již neuvidíte. fill="none"
</div> stroke="currentColor"
<div strokeWidth="2"
style={{ >
display: "grid", <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" />
gridTemplateColumns: "repeat(2, 1fr)", <line x1="12" y1="9" x2="12" y2="13" />
gap: "0.5rem", <line x1="12" y1="17" x2="12.01" y2="17" />
padding: "1rem", </svg>
background: "var(--bg-secondary)", </Box>
borderRadius: "0.5rem", <span>
fontFamily: "monospace", Uložte si tyto kódy na bezpečné místo. Každý kód lze použít
fontSize: "1rem", pouze jednou. Po zavření tohoto okna je již neuvidíte.
}} </span>
> </Box>
{backupCodes.map((code) => ( <Box
<div sx={{
key={code} display: "grid",
style={{ gridTemplateColumns: "repeat(2, 1fr)",
padding: "0.25rem 0.5rem", gap: 1,
textAlign: "center", p: 2,
color: "var(--text-primary)", bgcolor: "action.hover",
borderRadius: 2,
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "1rem",
}}
>
{backupCodes.map((code) => (
<Box
key={code}
sx={{
px: 1,
py: 0.5,
textAlign: "center",
color: "text.primary",
}}
>
{code}
</Box>
))}
</Box>
<Box sx={{ mt: 1.5 }}>
<Button
onClick={() => {
navigator.clipboard?.writeText(backupCodes.join("\n"));
alert.success("Kódy zkopírovány");
}} }}
variant="outlined"
color="inherit"
size="small"
startIcon={CopyIcon}
> >
{code} Kopírovat kódy
</div> </Button>
))} </Box>
</div> </Box>
<div style={{ marginTop: "0.75rem" }}> ) : (
<button <Box>
onClick={() => { <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
navigator.clipboard?.writeText(backupCodes.join("\n")); Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
alert.success("Kódy zkopírovány"); Authy, Microsoft Authenticator apod.)
}} </Typography>
className="admin-btn admin-btn-secondary admin-btn-sm" {totpQrUri && (
> <Box sx={{ textAlign: "center", mb: 2 }}>
<svg <Box
width="14" component="img"
height="14" src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
viewBox="0 0 24 24" alt="TOTP QR Code"
fill="none" sx={{
stroke="currentColor" width: 200,
strokeWidth="2" height: 200,
> borderRadius: 2,
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /> border: 1,
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> borderColor: "divider",
</svg> }}
Kopírovat kódy />
</button> </Box>
</div> )}
{/* Footer is hidden on this step; the single deliberate exit lives {totpSecret && (
in the body and also clears the codes. */} <Box sx={{ mb: 2 }}>
<div style={{ marginTop: "1.25rem", textAlign: "right" }}> <Typography
<button component="label"
onClick={() => { variant="caption"
setShow2FASetup(false); color="text.secondary"
setBackupCodes(null); sx={{ display: "block", mb: 0.5 }}
}} >
className="admin-btn admin-btn-primary" Nebo zadejte klíč ručně:
> </Typography>
Rozumím, uložil jsem si kódy <Box
</button> sx={{
</div> display: "flex",
</div> alignItems: "center",
) : ( justifyContent: "space-between",
<div> gap: 1,
<p px: 1.5,
className="text-secondary" py: 1,
style={{ fontSize: "0.875rem", marginBottom: "1rem" }} bgcolor: "action.hover",
> borderRadius: 1.5,
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, fontFamily: "'DM Mono', Menlo, monospace",
Authy, Microsoft Authenticator apod.) fontSize: "0.875rem",
</p> wordBreak: "break-all",
{totpQrUri && ( color: "text.primary",
<div style={{ textAlign: "center", marginBottom: "1rem" }}> }}
<img >
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`} <span>{totpSecret}</span>
alt="TOTP QR Code" <IconButton
style={{ onClick={() => {
width: 200, navigator.clipboard?.writeText(totpSecret);
height: 200, alert.success("Klíč zkopírován");
borderRadius: "0.5rem", }}
border: "1px solid var(--border-color)", size="small"
title="Kopírovat"
aria-label="Kopírovat"
sx={{ flexShrink: 0 }}
>
{CopyIcon}
</IconButton>
</Box>
</Box>
)}
<Field label="Ověřovací kód z aplikace">
<TextField
inputRef={totpSetupRef}
inputMode="numeric"
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/\D/g, ""))
}
placeholder="000000"
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
sx={{
"& input": {
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "'DM Mono', Menlo, monospace",
},
}}
onKeyDown={(e) => {
if (e.key === "Enter" && totpCode.length === 6) {
onConfirm2FA();
}
}} }}
/> />
</div> </Field>
)} </Box>
{totpSecret && ( )}
<div className="mb-4"> </DialogContent>
<label {/* Footer is hidden on the backup-codes step; the single deliberate exit
className="admin-form-label" lives in the body actions there. */}
style={{ fontSize: "0.75rem" }} <DialogActions sx={{ px: 3, pb: 2 }}>
> {backupCodes ? (
Nebo zadejte klíč ručně: <Button
</label> onClick={() => {
<div setShow2FASetup(false);
style={{ setBackupCodes(null);
padding: "0.5rem 0.75rem", }}
background: "var(--bg-secondary)", >
borderRadius: "0.375rem", Rozumím, uložil jsem si kódy
fontFamily: "monospace", </Button>
fontSize: "0.875rem", ) : (
wordBreak: "break-all", <>
color: "var(--text-primary)", <Button
display: "flex", onClick={() => {
alignItems: "center", setShow2FASetup(false);
justifyContent: "space-between", setTotpCode("");
gap: "0.5rem",
}}
>
<span>{totpSecret}</span>
<button
onClick={() => {
navigator.clipboard?.writeText(totpSecret);
alert.success("Klíč zkopírován");
}}
className="admin-btn-icon"
title="Kopírovat"
aria-label="Kopírovat"
style={{ flexShrink: 0 }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">
Ověřovací kód z aplikace
</label>
<input
ref={totpSetupRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
}} }}
onKeyDown={(e) => { variant="text"
if (e.key === "Enter" && totpCode.length === 6) { color="inherit"
onConfirm2FA(); disabled={totpSubmitting}
} >
}} Zrušit
/> </Button>
</div> <Button
</div> onClick={onConfirm2FA}
)} disabled={totpSubmitting || totpCode.length !== 6}
</FormModal> >
{totpSubmitting ? "Ukládám…" : "Aktivovat 2FA"}
</Button>
</>
)}
</DialogActions>
</Dialog>
{/* 2FA Disable Modal */} {/* 2FA Disable Modal */}
<FormModal <Modal
isOpen={show2FADisable} isOpen={show2FADisable}
onClose={() => setShow2FADisable(false)} onClose={() => setShow2FADisable(false)}
title="Deaktivovat 2FA" title="Deaktivovat 2FA"
size="md" maxWidth="sm"
loading={totpSubmitting} loading={totpSubmitting}
onSubmit={onDisable2FA} onSubmit={onDisable2FA}
submitDisabled={disableCode.length !== 6} submitDisabled={disableCode.length !== 6}
submitLabel="Deaktivovat 2FA" submitText="Deaktivovat 2FA"
cancelLabel="Zrušit" cancelText="Zrušit"
> >
<p <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
style={{
color: "var(--text-secondary)",
fontSize: "0.875rem",
marginBottom: "1rem",
}}
>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
autentizační aplikace. autentizační aplikace.
</p> </Typography>
<div className="admin-form-group"> <Field label="Ověřovací kód">
<label className="admin-form-label">Ověřovací kód</label> <TextField
<input
type="text"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={disableCode} value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000" placeholder="000000"
className="admin-form-input" autoFocus
style={{ slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
textAlign: "center", sx={{
fontSize: "1.25rem", "& input": {
letterSpacing: "0.4rem", textAlign: "center",
fontFamily: "monospace", fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "'DM Mono', Menlo, monospace",
},
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && disableCode.length === 6) { if (e.key === "Enter" && disableCode.length === 6) {
onDisable2FA(); onDisable2FA();
} }
}} }}
autoFocus
/> />
</div> </Field>
</FormModal> </Modal>
</> </>
); );
} }

View File

@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import { formatKm, todayLocalStr } from "../../utils/formatters"; import { formatKm, todayLocalStr } from "../../utils/formatters";
import AdminDatePicker from "../AdminDatePicker"; import { Button, Modal, Field, Select, DateField, TextField } from "../../ui";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import FormModal from "../FormModal";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -46,6 +46,14 @@ interface DashQuickActionsProps {
onPunch: () => void; onPunch: () => void;
} }
// Maps the legacy quick-action color tokens to the MUI Button palette color.
const ACTION_COLOR: Record<string, "success" | "error" | "info" | "warning"> = {
success: "success",
danger: "error",
info: "info",
warning: "warning",
};
export default function DashQuickActions({ export default function DashQuickActions({
dashData, dashData,
punching, punching,
@@ -284,252 +292,192 @@ export default function DashQuickActions({
return ( return (
<> <>
<motion.div <Box
className="dash-quick-actions" component={motion.div}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }} transition={{ duration: 0.25, delay: 0.08 }}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
mb: 3,
}}
> >
{quickActions.map((action) => {quickActions.map((action) => {
action.onClick ? ( const color = ACTION_COLOR[action.color] ?? "primary";
<button const common = {
variant: "contained" as const,
color,
startIcon: action.icon,
sx: { py: 1.5, justifyContent: "flex-start" },
};
return action.onClick ? (
<Button
key={action.label} key={action.label}
onClick={action.onClick} onClick={action.onClick}
disabled={action.disabled} disabled={action.disabled}
className={`dash-quick-btn dash-quick-btn-${action.color}`} {...common}
> >
{action.icon} {action.label}
<span>{action.label}</span> </Button>
</button>
) : ( ) : (
<Link <Button
key={action.label} key={action.label}
component={RouterLink}
to={action.path!} to={action.path!}
className={`dash-quick-btn dash-quick-btn-${action.color}`} {...common}
> >
{action.icon} {action.label}
<span>{action.label}</span> </Button>
</Link> );
), })}
)} </Box>
</motion.div>
<FormModal <Modal
isOpen={showTripModal} isOpen={showTripModal}
onClose={() => setShowTripModal(false)} onClose={() => setShowTripModal(false)}
title="Přidat jízdu" title="Přidat jízdu"
size="lg" maxWidth="md"
onSubmit={handleTripSubmit} onSubmit={handleTripSubmit}
submitLabel="Uložit" submitText="Uložit"
loading={tripSubmitting} loading={tripSubmitting}
> >
<div className="admin-form"> <Box
<div className="admin-form-row"> sx={{
<div display: "grid",
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`} gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
> gap: 2,
<label className="admin-form-label required">Vozidlo</label> }}
<select >
value={tripForm.vehicle_id} <Field label="Vozidlo" required error={tripErrors.vehicle_id}>
onChange={(e) => { <Select
handleTripVehicleChange(e.target.value); value={tripForm.vehicle_id}
setTripErrors((prev) => ({ onChange={(val) => {
...prev, handleTripVehicleChange(val);
vehicle_id: undefined, setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
})); }}
}} options={[
className="admin-form-select" { value: "", label: "Vyberte vozidlo" },
> ...tripVehicles.map((v) => ({
<option value="">Vyberte vozidlo</option> value: String(v.id),
{tripVehicles.map((v) => ( label: `${v.spz} - ${v.name}`,
<option key={v.id} value={v.id}> })),
{v.spz} - {v.name} ]}
</option>
))}
</select>
{tripErrors.vehicle_id && (
<span className="admin-form-error">
{tripErrors.vehicle_id}
</span>
)}
</div>
<div
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={tripForm.trip_date}
onChange={(val: string) => {
setTripForm((prev) => ({ ...prev, trip_date: val }));
setTripErrors((prev) => ({
...prev,
trip_date: undefined,
}));
}}
/>
{tripErrors.trip_date && (
<span className="admin-form-error">{tripErrors.trip_date}</span>
)}
</div>
</div>
<div className="admin-form-row admin-form-row-3">
<div
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Počáteční stav km
</label>
<input
type="number"
inputMode="numeric"
value={tripForm.start_km}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
start_km: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
start_km: undefined,
}));
}}
className="admin-form-input"
min="0"
/>
{tripErrors.start_km && (
<span className="admin-form-error">{tripErrors.start_km}</span>
)}
</div>
<div
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Konečný stav km
</label>
<input
type="number"
inputMode="numeric"
value={tripForm.end_km}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
end_km: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
end_km: undefined,
}));
}}
className="admin-form-input"
min="0"
/>
{tripErrors.end_km && (
<span className="admin-form-error">{tripErrors.end_km}</span>
)}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label>
<input
type="text"
value={`${formatKm(tripDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</div>
</div>
<div className="admin-form-row">
<div
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
value={tripForm.route_from}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
route_from: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
route_from: undefined,
}));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
{tripErrors.route_from && (
<span className="admin-form-error">
{tripErrors.route_from}
</span>
)}
</div>
<div
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Místo příjezdu
</label>
<input
type="text"
value={tripForm.route_to}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
route_to: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
route_to: undefined,
}));
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
{tripErrors.route_to && (
<span className="admin-form-error">{tripErrors.route_to}</span>
)}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label>
<select
value={tripForm.is_business}
onChange={(e) =>
setTripForm((prev) => ({
...prev,
is_business: parseInt(e.target.value),
}))
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Poznámky</label>
<textarea
value={tripForm.notes}
onChange={(e) =>
setTripForm((prev) => ({
...prev,
notes: e.target.value,
}))
}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/> />
</div> </Field>
</div> <Field label="Datum jízdy" required error={tripErrors.trip_date}>
</FormModal> <DateField
value={tripForm.trip_date}
onChange={(val) => {
setTripForm((prev) => ({ ...prev, trip_date: val }));
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
}}
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
gap: 2,
}}
>
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
<TextField
type="number"
inputMode="numeric"
value={tripForm.start_km}
onChange={(e) => {
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
}}
slotProps={{ htmlInput: { min: 0 } }}
/>
</Field>
<Field label="Konečný stav km" required error={tripErrors.end_km}>
<TextField
type="number"
inputMode="numeric"
value={tripForm.end_km}
onChange={(e) => {
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
}}
slotProps={{ htmlInput: { min: 0 } }}
/>
</Field>
<Field label="Vzdálenost">
<TextField
value={`${formatKm(tripDistance())} km`}
InputProps={{ readOnly: true }}
disabled
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
<TextField
value={tripForm.route_from}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
route_from: e.target.value,
}));
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
}}
placeholder="Např. Praha"
/>
</Field>
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
<TextField
value={tripForm.route_to}
onChange={(e) => {
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
}}
placeholder="Např. Brno"
/>
</Field>
</Box>
<Field label="Typ jízdy">
<Select
value={String(tripForm.is_business)}
onChange={(val) =>
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
}
options={[
{ value: "1", label: "Služební" },
{ value: "0", label: "Soukromá" },
]}
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={2}
value={tripForm.notes}
onChange={(e) =>
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky..."
/>
</Field>
</Modal>
</> </>
); );
} }

View File

@@ -1,9 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import ConfirmModal from "../ConfirmModal"; import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers"; import { formatSessionDate } from "../../utils/dashboardHelpers";
import { sessionsOptions, type Session } from "../../lib/queries/dashboard"; import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
@@ -76,8 +79,6 @@ export default function DashSessions() {
const [deleteAllModal, setDeleteAllModal] = useState(false); const [deleteAllModal, setDeleteAllModal] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
useModalLock(deleteAllModal);
const handleDeleteSession = async () => { const handleDeleteSession = async () => {
if (!deleteModal.session) { if (!deleteModal.session) {
return; return;
@@ -127,113 +128,140 @@ export default function DashSessions() {
return ( return (
<> <>
<motion.div <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
> >
<div <Card sx={{ display: "flex", flexDirection: "column" }}>
className="admin-card-header" <Box
style={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: "0.75rem", gap: 1.5,
}} mb: 2,
> }}
<h2 className="admin-card-title">Přihlášená zařízení</h2> >
{sessions.filter((s) => !s.is_current).length > 0 && ( <Typography variant="h6">Přihlášená zařízení</Typography>
<button {sessions.filter((s) => !s.is_current).length > 0 && (
onClick={() => setDeleteAllModal(true)} <Button
className="admin-btn admin-btn-secondary admin-btn-sm" onClick={() => setDeleteAllModal(true)}
> variant="outlined"
Odhlásit ostatní color="inherit"
</button> size="small"
)} sx={{ flexShrink: 0 }}
</div> >
<div className="admin-card-body" style={{ padding: 0 }}> Odhlásit ostatní
</Button>
)}
</Box>
{sessionsLoading ? ( {sessionsLoading ? (
<div className="admin-loading"> <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<div className="admin-spinner" /> <CircularProgress size={28} />
</div> </Box>
) : sessions.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 3 }}
>
Žádné aktivní relace
</Typography>
) : ( ) : (
<> <Box sx={{ display: "flex", flexDirection: "column" }}>
{sessions.length === 0 && ( {sessions.map((session) => (
<div <Box
className="text-secondary" key={session.id}
style={{ sx={{
padding: "1.5rem", display: "flex",
textAlign: "center", alignItems: "center",
fontSize: "0.875rem", gap: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
...(session.is_current
? { bgcolor: "action.hover", borderRadius: 2, px: 1 }
: {}),
}} }}
> >
Žádné aktivní relace <Box
</div> sx={{
)} display: "flex",
{sessions.length > 0 && ( alignItems: "center",
<div className="dash-sessions-list"> justifyContent: "center",
{sessions.map((session) => ( color: "text.secondary",
<div flexShrink: 0,
key={session.id} }}
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`} >
{getDeviceIcon(session.device_info?.icon)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
> >
<div className="dash-session-icon"> <Typography variant="body2" sx={{ fontWeight: 500 }}>
{getDeviceIcon(session.device_info?.icon)} {session.device_info?.browser} na{" "}
</div> {session.device_info?.os}
<div className="dash-session-info"> </Typography>
<div className="dash-session-device"> {session.is_current && (
{session.device_info?.browser} na{" "} <StatusChip color="success" label="Aktuální" />
{session.device_info?.os} )}
{session.is_current && ( </Box>
<span <Box
className="admin-badge admin-badge-success" sx={{
style={{ marginLeft: "0.5rem" }} display: "flex",
> alignItems: "center",
Aktuální gap: 0.75,
</span> color: "text.secondary",
)} fontSize: "0.75rem",
</div> }}
<div className="dash-session-meta"> >
<span>{session.ip_address}</span> <span>{session.ip_address}</span>
<span className="dash-session-meta-separator">|</span> <span>|</span>
<span>{formatSessionDate(session.created_at)}</span> <span>{formatSessionDate(session.created_at)}</span>
</div> </Box>
</div> </Box>
<div className="dash-session-actions"> <Box sx={{ flexShrink: 0 }}>
{!session.is_current && ( {!session.is_current && (
<button <IconButton
onClick={() => onClick={() =>
setDeleteModal({ isOpen: true, session }) setDeleteModal({ isOpen: true, session })
} }
className="admin-btn-icon danger" color="error"
title="Ukončit relaci" size="small"
aria-label="Ukončit relaci" title="Ukončit relaci"
> aria-label="Ukončit relaci"
<svg >
width="16" <svg
height="16" width="16"
viewBox="0 0 24 24" height="16"
fill="none" viewBox="0 0 24 24"
stroke="currentColor" fill="none"
strokeWidth="2" stroke="currentColor"
> strokeWidth="2"
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> >
<polyline points="16 17 21 12 16 7" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<line x1="21" y1="12" x2="9" y2="12" /> <polyline points="16 17 21 12 16 7" />
</svg> <line x1="21" y1="12" x2="9" y2="12" />
</button> </svg>
)} </IconButton>
</div> )}
</div> </Box>
))} </Box>
</div> ))}
)} </Box>
</>
)} )}
</div> </Card>
</motion.div> </motion.div>
<ConfirmModal <ConfirmDialog
isOpen={deleteModal.isOpen} isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, session: null })} onClose={() => setDeleteModal({ isOpen: false, session: null })}
onConfirm={handleDeleteSession} onConfirm={handleDeleteSession}
@@ -241,10 +269,10 @@ export default function DashSessions() {
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`} message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
confirmText="Ukončit" confirmText="Ukončit"
cancelText="Zrušit" cancelText="Zrušit"
type="danger" confirmVariant="danger"
loading={deleting} loading={deleting}
/> />
<ConfirmModal <ConfirmDialog
isOpen={deleteAllModal} isOpen={deleteAllModal}
onClose={() => setDeleteAllModal(false)} onClose={() => setDeleteAllModal(false)}
onConfirm={handleDeleteAllSessions} onConfirm={handleDeleteAllSessions}
@@ -252,7 +280,7 @@ export default function DashSessions() {
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto." message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
confirmText="Odhlásit vše" confirmText="Odhlásit vše"
cancelText="Zrušit" cancelText="Zrušit"
type="warning" confirmVariant="primary"
loading={deleting} loading={deleting}
/> />
</> </>

View File

@@ -1,15 +1,18 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard"; import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings"; import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers"; import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { Card, Button, StatusChip } from "../ui";
import DashKpiCards from "../components/dashboard/DashKpiCards"; import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions"; import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed"; import DashActivityFeed from "../components/dashboard/DashActivityFeed";
@@ -102,9 +105,6 @@ export default function Dashboard() {
const [backupCodes, setBackupCodes] = useState<string[] | null>(null); const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [disableCode, setDisableCode] = useState(""); const [disableCode, setDisableCode] = useState("");
useModalLock(show2FASetup);
useModalLock(show2FADisable);
// Punch (prichod/odchod) primo z dashboardu // Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = useCallback(() => { const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival"; const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
@@ -221,99 +221,101 @@ export default function Dashboard() {
}; };
return ( return (
<div className="dash"> <Box>
{/* Header */} {/* Header */}
<motion.div <Box
className="admin-page-header" component={motion.div}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
sx={{ mb: 3 }}
> >
<div> <Typography variant="h4">
<h1 className="admin-page-title"> Vítejte zpět, {user?.fullName || user?.username}
Vítejte zpět, {user?.fullName || user?.username} </Typography>
</h1> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
<p className="admin-page-subtitle">{getCzechDate()}</p> {getCzechDate()}
</div> </Typography>
</motion.div> </Box>
{/* 2FA Required Banner */} {/* 2FA Required Banner */}
{user?.require2FA && !user?.totpEnabled && ( {user?.require2FA && !user?.totpEnabled && (
<motion.div <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
style={{
border: "2px solid var(--danger)",
background: "var(--danger-light)",
}}
> >
<div <Card
className="admin-card-body" sx={{
style={{ mb: 3,
display: "flex", border: 2,
alignItems: "center", borderColor: "error.main",
justifyContent: "space-between", bgcolor: "error.light",
gap: "1rem",
flexWrap: "wrap",
}} }}
> >
<div className="flex-row-gap"> <Box
<div sx={{
style={{ display: "flex",
width: 40, alignItems: "center",
height: 40, justifyContent: "space-between",
borderRadius: "50%", gap: 2,
display: "flex", flexWrap: "wrap",
alignItems: "center", }}
justifyContent: "center",
background: "var(--danger-light)",
color: "var(--danger)",
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>
</div>
<div>
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
<div
className="text-secondary"
style={{ fontSize: "0.875rem" }}
>
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</div>
</div>
</div>
<button
onClick={handleStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary"
style={{ flexShrink: 0 }}
> >
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"} <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
</button> <Box
</div> sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "error.light",
color: "error.main",
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>
</motion.div> </motion.div>
)} )}
{/* Loading spinner */} {/* Loading spinner */}
{dashLoading && ( {dashLoading && (
<div className="admin-loading"> <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<div className="admin-spinner" /> <CircularProgress />
</div> </Box>
)} )}
{/* KPI cards — only show if user has any admin-level permissions */} {/* KPI cards — only show if user has any admin-level permissions */}
@@ -336,11 +338,18 @@ export default function Dashboard() {
{/* Main content grid */} {/* Main content grid */}
{!dashLoading && ( {!dashLoading && (
<motion.div <Box
className="dash-main-grid" component={motion.div}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
gap: 3,
alignItems: "start",
mb: 3,
}}
> >
{hasPermission("settings.audit") && ( {hasPermission("settings.audit") && (
<DashActivityFeed activities={dashData?.recent_activity ?? null} /> <DashActivityFeed activities={dashData?.recent_activity ?? null} />
@@ -351,86 +360,146 @@ export default function Dashboard() {
)} )}
{/* Pravy sloupec: projekty + nabidky */} {/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col"> <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{dashData?.projects && ( {dashData?.projects && (
<div className="admin-card"> <Card sx={{ display: "flex", flexDirection: "column" }}>
<div className="admin-card-header flex-between"> <Box
<h2 className="admin-card-title">Aktivní projekty</h2> sx={{
<Link display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Aktivní projekty</Typography>
<Button
component={RouterLink}
to="/projects" to="/projects"
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
Vše &rarr; Vše &rarr;
</Link> </Button>
</div> </Box>
<div className="admin-card-body" style={{ padding: 0 }}> {dashData.projects.active_projects.length === 0 && (
{dashData.projects.active_projects.length === 0 && ( <Typography
<div className="dash-empty-row">Žádné aktivní projekty</div> variant="body2"
)} color="text.secondary"
{dashData.projects.active_projects.map( sx={{ py: 1 }}
(p: { >
id: number; Žádné aktivní projekty
name: string; </Typography>
customer_name: string | null; )}
}) => ( {dashData.projects.active_projects.map((p) => (
<Link <Box
key={p.id} key={p.id}
to={`/projects/${p.id}`} component={RouterLink}
className="dash-project-row" to={`/projects/${p.id}`}
> sx={{
<div className="dash-project-name">{p.name}</div> display: "block",
{p.customer_name && ( py: 1.25,
<div className="dash-project-customer"> textDecoration: "none",
{p.customer_name} color: "inherit",
</div> borderBottom: 1,
)} borderColor: "divider",
</Link> "&:last-of-type": { borderBottom: 0 },
), "&:hover": { color: "primary.main" },
)} }}
</div> >
</div> <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 && ( {dashData?.offers && (
<div className="admin-card"> <Card sx={{ display: "flex", flexDirection: "column" }}>
<div className="admin-card-header flex-between"> <Box
<h2 className="admin-card-title">Nabídky</h2> sx={{
<Link display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Nabídky</Typography>
<Button
component={RouterLink}
to="/offers" to="/offers"
className="admin-btn admin-btn-primary admin-btn-sm" size="small"
sx={{ flexShrink: 0 }}
> >
Zobrazit &rarr; Zobrazit &rarr;
</Link> </Button>
</div> </Box>
<div className="admin-card-body" style={{ padding: 0 }}> <Box
<div className="dash-stat-row"> sx={{
<span>Otevřené</span> display: "flex",
<span className="admin-badge admin-badge-info"> alignItems: "center",
{dashData.offers.open_count} justifyContent: "space-between",
</span> py: 1,
</div> borderBottom: 1,
<div className="dash-stat-row"> borderColor: "divider",
<span>Převedené na objednávku</span> }}
<span className="admin-badge admin-badge-success"> >
{dashData.offers.converted_count} <Typography variant="body2">Otevřené</Typography>
</span> <StatusChip color="info" label={dashData.offers.open_count} />
</div> </Box>
<div className="dash-stat-row"> <Box
<span>Zneplatněné</span> sx={{
<span className="admin-badge admin-badge-warning"> display: "flex",
{dashData.offers.expired_count} alignItems: "center",
</span> justifyContent: "space-between",
</div> py: 1,
</div> borderBottom: 1,
</div> 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>
)} )}
</div> </Box>
</motion.div> </Box>
)} )}
{/* Profile + Sessions */} {/* Profile + Sessions */}
{!dashLoading && ( {!dashLoading && (
<div className="dash-bottom"> <Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
gap: 3,
alignItems: "start",
}}
>
<DashProfile <DashProfile
totpEnabled={totpEnabled} totpEnabled={totpEnabled}
totpLoading={totpLoading} totpLoading={totpLoading}
@@ -452,8 +521,8 @@ export default function Dashboard() {
setDisableCode={setDisableCode} setDisableCode={setDisableCode}
/> />
<DashSessions /> <DashSessions />
</div> </Box>
)} )}
</div> </Box>
); );
} }