diff --git a/src/admin/components/dashboard/DashActivityFeed.tsx b/src/admin/components/dashboard/DashActivityFeed.tsx index 97a3fdb..57c84f1 100644 --- a/src/admin/components/dashboard/DashActivityFeed.tsx +++ b/src/admin/components/dashboard/DashActivityFeed.tsx @@ -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 { ENTITY_TYPE_LABELS, getActivityIconClass, @@ -18,6 +21,16 @@ interface DashActivityFeedProps { 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 = { + success: "success", + info: "info", + danger: "error", + accent: "primary", + muted: "default", +}; + function getActivityIcon(action: string) { switch (action) { case "create": @@ -103,37 +116,92 @@ export default function DashActivityFeed({ } return ( -
-
-

Audit log

- + + Audit log +
-
- {activities.map((act) => ( -
-
- {getActivityIcon(act.action)} -
-
-
{act.description}
-
- {act.username || "Systém"} ·{" "} - {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type} -
-
-
- {formatActivityTime(act.created_at)} -
-
- ))} -
-
+ + + + {activities.length === 0 ? ( + + ) : ( + + {activities.map((act) => { + const badgeColor = + ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ?? + "default"; + const isDefault = badgeColor === "default"; + return ( + + + {getActivityIcon(act.action)} + + + + {act.description} + + + {act.username || "Systém"} ·{" "} + {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type} + + + + {formatActivityTime(act.created_at)} + + + ); + })} + + )} + ); } diff --git a/src/admin/components/dashboard/DashAttendanceToday.tsx b/src/admin/components/dashboard/DashAttendanceToday.tsx index 4fc7787..6dc6c3b 100644 --- a/src/admin/components/dashboard/DashAttendanceToday.tsx +++ b/src/admin/components/dashboard/DashAttendanceToday.tsx @@ -1,9 +1,9 @@ -import { Link } from "react-router-dom"; -import { - LEAVE_TYPE_LABELS, - STATUS_DOT_CLASS, - STATUS_LABELS, -} from "../../utils/dashboardHelpers"; +import { Link as RouterLink } from "react-router-dom"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Avatar from "@mui/material/Avatar"; +import { Card, Button, StatusChip, EmptyState } from "../../ui"; +import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers"; interface AttendanceUser { user_id: number | string; @@ -22,6 +22,19 @@ interface DashAttendanceTodayProps { 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({ attendance, }: DashAttendanceTodayProps) { @@ -30,42 +43,96 @@ export default function DashAttendanceToday({ } return ( -
-
-

Docházka dnes

- + + Docházka dnes +
-
- {attendance.users.map((u, i) => ( -
-
- {u.initials || "?"} -
-
{u.name}
-
- + + + {attendance.users.length === 0 ? ( + + ) : ( + + {attendance.users.map((u, i) => { + const color = STATUS_COLOR[u.status] ?? "default"; + const isDefault = color === "default"; + return ( + - {u.status === "leave" - ? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen" - : STATUS_LABELS[u.status]} - - {u.arrived_at && ( - - {u.arrived_at} - - )} -
-
- ))} -
-
+ + {u.initials || "?"} + + + {u.name} + + + + {u.arrived_at && ( + + {u.arrived_at} + + )} + + + ); + })} + + )} + ); } diff --git a/src/admin/components/dashboard/DashKpiCards.tsx b/src/admin/components/dashboard/DashKpiCards.tsx index 33f4cfe..c162f3e 100644 --- a/src/admin/components/dashboard/DashKpiCards.tsx +++ b/src/admin/components/dashboard/DashKpiCards.tsx @@ -1,4 +1,7 @@ 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"; interface KpiCard { @@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard { }; } -const KPI_CLASS_MAP: Record = { - 4: "admin-kpi-4", - 3: "admin-kpi-3", - 2: "admin-kpi-2", - 1: "admin-kpi-1", +// Maps the legacy KPI color tokens to the StatCard color palette +// (danger → error; the rest are 1:1). +const KPI_COLOR_MAP: Record = { + success: "success", + info: "info", + warning: "warning", + danger: "error", }; export default function DashKpiCards({ dashData }: DashKpiCardsProps) { @@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) { return null; } - const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4"; - return ( - {kpiCards.map((kpi) => ( -
-
{kpi.label}
-
- {kpi.value} - {kpi.sub && ( - - {kpi.sub} - - )} -
- {kpi.footer &&
{kpi.footer}
} -
+ + {kpi.value} + {kpi.sub && ( + + {kpi.sub} + + )} + + } + footer={kpi.footer ?? undefined} + /> ))} -
+ ); } diff --git a/src/admin/components/dashboard/DashProfile.tsx b/src/admin/components/dashboard/DashProfile.tsx index bd6a688..c3bddc5 100644 --- a/src/admin/components/dashboard/DashProfile.tsx +++ b/src/admin/components/dashboard/DashProfile.tsx @@ -1,9 +1,17 @@ import { useState, useRef } from "react"; 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 { useAlert } from "../../context/AlertContext"; 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"; @@ -37,6 +45,34 @@ interface ProfileFormData { last_name: string; } +const EditIcon = ( + + + + +); + +const CopyIcon = ( + + + + +); + export default function DashProfile({ totpEnabled, totpLoading, @@ -61,6 +97,10 @@ export default function DashProfile({ const alert = useAlert(); const totpSetupRef = useRef(null); + // The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and + // locks scroll like the kit dialogs. + useDialogScrollLock(show2FASetup); + const [showModal, setShowModal] = useState(false); const [formData, setFormData] = useState({ username: "", @@ -133,80 +173,94 @@ export default function DashProfile({ 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 ( <> -
-

Váš účet

- -
-
-
-
- Uživatel - {user?.username} -
-
- E-mail - {user?.email} -
-
- Jméno - {user?.fullName} -
-
- Role - - {user?.roleDisplay || String(user?.role || "")} - -
-
- - {/* 2FA Section */} -
+ -
-
-
Váš účet + + + + + {profileItems.map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + + + {/* 2FA Section */} + + + + -
-
-
+ + + Dvoufaktorové ověření (2FA) -
-
+ {getTotpStatusText()} -
-
-
+ + + {!totpLoading && (totpEnabled ? ( - + ) : ( - + ))} -
-
-
+ + +
{/* Edit Profile Modal */} - setShowModal(false)} title="Upravit profil" - size="md" + maxWidth="sm" onSubmit={handleSubmit} - submitLabel="Uložit změny" + submitText="Uložit změny" > -
-
-
- - - setFormData({ - ...formData, - first_name: e.target.value, - }) - } - required - className="admin-form-input" - /> -
-
- - - setFormData({ - ...formData, - last_name: e.target.value, - }) - } - required - className="admin-form-input" - /> -
-
-
- - + + - setFormData({ ...formData, username: e.target.value }) + setFormData({ ...formData, first_name: e.target.value }) } required - className="admin-form-input" /> -
-
- - + + - setFormData({ ...formData, email: e.target.value }) + setFormData({ ...formData, last_name: e.target.value }) } required - className="admin-form-input" /> -
-
- - - setFormData({ - ...formData, - current_password: e.target.value, - }) - } - className="admin-form-input" - placeholder="Pro změnu hesla, zadejte aktuální heslo" - /> -
-
- - - setFormData({ - ...formData, - new_password: e.target.value, - }) - } - className="admin-form-input" - placeholder="Pro změnu hesla, zadejte nové heslo" - /> -
-
-
+ + + + + setFormData({ ...formData, username: e.target.value }) + } + required + /> + + + + setFormData({ ...formData, email: e.target.value }) + } + required + /> + + + + setFormData({ ...formData, current_password: e.target.value }) + } + placeholder="Pro změnu hesla, zadejte aktuální heslo" + /> + + + + setFormData({ ...formData, new_password: e.target.value }) + } + placeholder="Pro změnu hesla, zadejte nové heslo" + /> + + - {/* 2FA Setup Modal — multi-step: setup → backup codes */} - { - // Only reachable on the setup step (backdrop/ESC/× are locked while - // backupCodes is set — that branch is unreachable so removed). - setShow2FASetup(false); - setTotpCode(""); - }} - title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"} - size="md" - loading={backupCodes ? false : totpSubmitting} - onSubmit={ + {/* 2FA Setup Dialog — bespoke, multi-step: setup → backup codes. + On the backup-codes step the footer + close are hidden and + backdrop/ESC are locked; the single deliberate exit lives in the body + and also clears the codes (so unsaved codes can't be silently lost). */} + { + ? undefined + : () => { setShow2FASetup(false); - setBackupCodes(null); + setTotpCode(""); } - : onConfirm2FA } - submitLabel={ - backupCodes ? "Rozumím, uložil jsem si kódy" : "Aktivovat 2FA" - } - submitDisabled={backupCodes ? false : totpCode.length !== 6} - 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} + fullWidth + maxWidth="sm" + disableScrollLock + slotProps={{ paper: { sx: { borderRadius: 3 } } }} > - {backupCodes ? ( -
-
- + {backupCodes ? "Záložní kódy" : "Nastavení 2FA"} + + + {backupCodes ? ( + + - - - - - Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze - jednou. Po zavření tohoto okna je již neuvidíte. -
-
- {backupCodes.map((code) => ( -
+ + + + + + + + Uložte si tyto kódy na bezpečné místo. Každý kód lze použít + pouze jednou. Po zavření tohoto okna je již neuvidíte. + + + + {backupCodes.map((code) => ( + + {code} + + ))} + + +
- ))} -
-
- -
- {/* Footer is hidden on this step; the single deliberate exit lives - in the body and also clears the codes. */} -
- -
-
- ) : ( -
-

- Naskenujte QR kód v autentizační aplikaci (Google Authenticator, - Authy, Microsoft Authenticator apod.) -

- {totpQrUri && ( -
- TOTP QR Code + + + ) : ( + + + Naskenujte QR kód v autentizační aplikaci (Google Authenticator, + Authy, Microsoft Authenticator apod.) + + {totpQrUri && ( + + + + )} + {totpSecret && ( + + + Nebo zadejte klíč ručně: + + + {totpSecret} + { + navigator.clipboard?.writeText(totpSecret); + alert.success("Klíč zkopírován"); + }} + size="small" + title="Kopírovat" + aria-label="Kopírovat" + sx={{ flexShrink: 0 }} + > + {CopyIcon} + + + + )} + + + 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(); + } }} /> -
- )} - {totpSecret && ( -
- -
- {totpSecret} - -
-
- )} -
- - setTotpCode(e.target.value.replace(/\D/g, ""))} - placeholder="000000" - className="admin-form-input" - style={{ - textAlign: "center", - fontSize: "1.25rem", - letterSpacing: "0.4rem", - fontFamily: "monospace", + + + )} + + {/* Footer is hidden on the backup-codes step; the single deliberate exit + lives in the body actions there. */} + + {backupCodes ? ( + + ) : ( + <> +
-
- )} -
+ variant="text" + color="inherit" + disabled={totpSubmitting} + > + Zrušit + + + + )} + + {/* 2FA Disable Modal */} - setShow2FADisable(false)} title="Deaktivovat 2FA" - size="md" + maxWidth="sm" loading={totpSubmitting} onSubmit={onDisable2FA} submitDisabled={disableCode.length !== 6} - submitLabel="Deaktivovat 2FA" - cancelLabel="Zrušit" + submitText="Deaktivovat 2FA" + cancelText="Zrušit" > -

+ Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace. -

-
- - + + setDisableCode(e.target.value.replace(/\D/g, ""))} placeholder="000000" - className="admin-form-input" - style={{ - textAlign: "center", - fontSize: "1.25rem", - letterSpacing: "0.4rem", - fontFamily: "monospace", + autoFocus + 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" && disableCode.length === 6) { onDisable2FA(); } }} - autoFocus /> -
-
+ + ); } diff --git a/src/admin/components/dashboard/DashQuickActions.tsx b/src/admin/components/dashboard/DashQuickActions.tsx index f5d3c17..a2e5d3b 100644 --- a/src/admin/components/dashboard/DashQuickActions.tsx +++ b/src/admin/components/dashboard/DashQuickActions.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; import { motion } from "framer-motion"; +import Box from "@mui/material/Box"; import { useAuth } from "../../context/AuthContext"; import { useAlert } from "../../context/AlertContext"; 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 FormModal from "../FormModal"; const API_BASE = "/api/admin"; @@ -46,6 +46,14 @@ interface DashQuickActionsProps { onPunch: () => void; } +// Maps the legacy quick-action color tokens to the MUI Button palette color. +const ACTION_COLOR: Record = { + success: "success", + danger: "error", + info: "info", + warning: "warning", +}; + export default function DashQuickActions({ dashData, punching, @@ -284,252 +292,192 @@ export default function DashQuickActions({ return ( <> - - {quickActions.map((action) => - action.onClick ? ( - + {action.label} + ) : ( - - {action.icon} - {action.label} - - ), - )} - + {action.label} + + ); + })} + - setShowTripModal(false)} title="Přidat jízdu" - size="lg" + maxWidth="md" onSubmit={handleTripSubmit} - submitLabel="Uložit" + submitText="Uložit" loading={tripSubmitting} > -
-
-
- - - {tripErrors.vehicle_id && ( - - {tripErrors.vehicle_id} - - )} -
-
- - { - setTripForm((prev) => ({ ...prev, trip_date: val })); - setTripErrors((prev) => ({ - ...prev, - trip_date: undefined, - })); - }} - /> - {tripErrors.trip_date && ( - {tripErrors.trip_date} - )} -
-
- -
-
- - { - setTripForm((prev) => ({ - ...prev, - start_km: e.target.value, - })); - setTripErrors((prev) => ({ - ...prev, - start_km: undefined, - })); - }} - className="admin-form-input" - min="0" - /> - {tripErrors.start_km && ( - {tripErrors.start_km} - )} -
-
- - { - setTripForm((prev) => ({ - ...prev, - end_km: e.target.value, - })); - setTripErrors((prev) => ({ - ...prev, - end_km: undefined, - })); - }} - className="admin-form-input" - min="0" - /> - {tripErrors.end_km && ( - {tripErrors.end_km} - )} -
-
- - -
-
- -
-
- - { - setTripForm((prev) => ({ - ...prev, - route_from: e.target.value, - })); - setTripErrors((prev) => ({ - ...prev, - route_from: undefined, - })); - }} - className="admin-form-input" - placeholder="Např. Praha" - /> - {tripErrors.route_from && ( - - {tripErrors.route_from} - - )} -
-
- - { - setTripForm((prev) => ({ - ...prev, - route_to: e.target.value, - })); - setTripErrors((prev) => ({ - ...prev, - route_to: undefined, - })); - }} - className="admin-form-input" - placeholder="Např. Brno" - /> - {tripErrors.route_to && ( - {tripErrors.route_to} - )} -
-
- -
- - -
- -
- -