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:
@@ -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 →
|
Detail →
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 →
|
Detail →
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 →
|
Vše →
|
||||||
</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 →
|
Zobrazit →
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user