refactor(modals): migrate 8 ad-hoc modals to the shared FormModal

BulkAttendanceModal, ShiftFormModal, OrderConfirmationModal, DashQuickActions
trip modal, the Attendance leave modal, and DashProfile's 3 modals (edit
profile, 2FA setup, 2FA disable) now use the shared FormModal instead of
hand-rolled overlays, inheriting the canonical open/close animation, blurred
backdrop, ESC-to-close, click-outside, scroll-lock, staggered reveal, mobile
full-screen and reduced-motion. Footers stay pinned via the real FormModal
footer + submitDisabled; multi-step modals (2FA setup, order confirmation)
drive title/footer/close-guards per step. Behaviour preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 14:53:28 +02:00
parent 55fa4045ad
commit 80803cc868
6 changed files with 1231 additions and 1607 deletions

View File

@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from "framer-motion"; import FormModal from "./FormModal";
import AdminDatePicker from "./AdminDatePicker"; import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
interface BulkAttendanceForm { interface BulkAttendanceForm {
month: string; month: string;
@@ -39,52 +38,29 @@ export default function BulkAttendanceModal({
toggleUser, toggleUser,
toggleAllUsers, toggleAllUsers,
}: BulkAttendanceModalProps) { }: BulkAttendanceModalProps) {
useModalLock(isOpen);
return ( return (
<AnimatePresence> <FormModal
{isOpen && ( isOpen={isOpen}
<motion.div onClose={onClose}
className="admin-modal-overlay" title="Vyplnit docházku za měsíc"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} loading={submitting}
exit={{ opacity: 0 }} onSubmit={onSubmit}
transition={{ duration: 0.2 }} submitLabel="Vyplnit měsíc"
submitDisabled={form.user_ids.length === 0}
> >
<div
className="admin-modal-backdrop"
onClick={() => !submitting && onClose()}
/>
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-attendance-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<p <p
style={{ style={{
color: "var(--text-secondary)", color: "var(--text-secondary)",
marginTop: "0.25rem", marginTop: "0.25rem",
marginBottom: "1rem",
fontSize: "0.875rem", fontSize: "0.875rem",
}} }}
> >
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
označí. Existující záznamy se přeskočí. Existující záznamy se přeskočí.
</p> </p>
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Měsíc</label> <label className="admin-form-label">Měsíc</label>
@@ -152,9 +128,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.arrival_time} value={form.arrival_time}
onChange={(val) => onChange={(val) => setForm({ ...form, arrival_time: val })}
setForm({ ...form, arrival_time: val })
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -162,9 +136,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.departure_time} value={form.departure_time}
onChange={(val) => onChange={(val) => setForm({ ...form, departure_time: val })}
setForm({ ...form, departure_time: val })
}
/> />
</div> </div>
</div> </div>
@@ -175,9 +147,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => onChange={(val) => setForm({ ...form, break_start_time: val })}
setForm({ ...form, break_start_time: val })
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -185,36 +155,11 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_end_time} value={form.break_end_time}
onChange={(val) => onChange={(val) => setForm({ ...form, break_end_time: val })}
setForm({ ...form, break_end_time: val })
}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0}
>
{submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import FormModal from "./FormModal";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
interface ConfirmationItem { interface ConfirmationItem {
@@ -103,38 +103,14 @@ export default function OrderConfirmationModal({
}, [defaultVatRate]); }, [defaultVatRate]);
return ( return (
<AnimatePresence> <FormModal
{isOpen && ( isOpen={isOpen}
<motion.div onClose={onClose}
className="admin-modal-overlay" title={`Potvrzení objednávky ${orderNumber}`}
initial={{ opacity: 0 }} size={step === "edit" ? "lg" : "md"}
animate={{ opacity: 1 }} loading={loading}
exit={{ opacity: 0 }} hideFooter
transition={{ duration: 0.2 }}
> >
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
}
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{step === "choose" ? ( {step === "choose" ? (
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-group"> <div className="admin-form-group">
@@ -195,10 +171,7 @@ export default function OrderConfirmationModal({
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label> <label className="admin-form-label">Obsah potvrzení</label>
<p <p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
Jak chcete připravit potvrzení objednávky? Jak chcete připravit potvrzení objednávky?
</p> </p>
<button <button
@@ -227,6 +200,17 @@ export default function OrderConfirmationModal({
Upravit položky Upravit položky
</button> </button>
</div> </div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
</div>
</div> </div>
) : ( ) : (
<div className="admin-form"> <div className="admin-form">
@@ -261,11 +245,7 @@ export default function OrderConfirmationModal({
type="number" type="number"
value={item.quantity} value={item.quantity}
onChange={(e) => onChange={(e) =>
updateItem( updateItem(i, "quantity", Number(e.target.value) || 0)
i,
"quantity",
Number(e.target.value) || 0,
)
} }
className="admin-form-input" className="admin-form-input"
style={{ width: "80px" }} style={{ width: "80px" }}
@@ -276,9 +256,7 @@ export default function OrderConfirmationModal({
<input <input
type="text" type="text"
value={item.unit} value={item.unit}
onChange={(e) => onChange={(e) => updateItem(i, "unit", e.target.value)}
updateItem(i, "unit", e.target.value)
}
className="admin-form-input" className="admin-form-input"
style={{ width: "60px" }} style={{ width: "60px" }}
/> />
@@ -304,11 +282,7 @@ export default function OrderConfirmationModal({
type="number" type="number"
value={item.vat_rate} value={item.vat_rate}
onChange={(e) => onChange={(e) =>
updateItem( updateItem(i, "vat_rate", Number(e.target.value) || 0)
i,
"vat_rate",
Number(e.target.value) || 0,
)
} }
className="admin-form-input" className="admin-form-input"
style={{ width: "70px" }} style={{ width: "70px" }}
@@ -345,13 +319,8 @@ export default function OrderConfirmationModal({
> >
+ Přidat položku + Přidat položku
</button> </button>
</div>
)}
</div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">
{step === "edit" && (
<>
<button <button
type="button" type="button"
onClick={() => setStep("choose")} onClick={() => setStep("choose")}
@@ -375,22 +344,9 @@ export default function OrderConfirmationModal({
"Vygenerovat PDF" "Vygenerovat PDF"
)} )}
</button> </button>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div> </div>
</motion.div> </div>
</motion.div>
)} )}
</AnimatePresence> </FormModal>
); );
} }

View File

@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker"; import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock"; import FormModal from "./FormModal";
import { import {
calcFormWorkMinutes, calcFormWorkMinutes,
calcProjectMinutesTotal, calcProjectMinutesTotal,
@@ -216,7 +215,6 @@ export default function ShiftFormModal({
onShiftDateChange, onShiftDateChange,
editingRecord, editingRecord,
}: ShiftFormModalProps) { }: ShiftFormModalProps) {
useModalLock(isOpen);
const isCreate = mode === "create"; const isCreate = mode === "create";
const isWorkType = form.leave_type === "work"; const isWorkType = form.leave_type === "work";
@@ -247,51 +245,25 @@ export default function ShiftFormModal({
}; };
return ( return (
<AnimatePresence> <FormModal
{isOpen && ( isOpen={isOpen}
<motion.div onClose={onClose}
className="admin-modal-overlay" title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
initial={{ opacity: 0 }} subtitle={
animate={{ opacity: 1 }} !isCreate && editingRecord
exit={{ opacity: 0 }} ? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}`
transition={{ duration: 0.2 }} : undefined
}
size="lg"
onSubmit={onSubmit}
submitLabel="Uložit"
cancelLabel="Zrušit"
> >
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
{isCreate ? ( {isCreate ? (
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label required"> <label className="admin-form-label required">Zaměstnanec</label>
Zaměstnanec
</label>
<select <select
value={form.user_id} value={form.user_id}
onChange={(e) => updateField("user_id", e.target.value)} onChange={(e) => updateField("user_id", e.target.value)}
@@ -306,9 +278,7 @@ export default function ShiftFormModal({
</select> </select>
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label required"> <label className="admin-form-label required">Datum směny</label>
Datum směny
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.shift_date} value={form.shift_date}
@@ -357,9 +327,7 @@ export default function ShiftFormModal({
className="admin-form-input" className="admin-form-input"
/> />
{isCreate && ( {isCreate && (
<small className="admin-form-hint"> <small className="admin-form-hint">8 hodin = celý den</small>
8 hodin = celý den
</small>
)} )}
</div> </div>
)} )}
@@ -368,9 +336,7 @@ export default function ShiftFormModal({
<> <>
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Příchod - datum</label>
Příchod - datum
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.arrival_date} value={form.arrival_date}
@@ -378,9 +344,7 @@ export default function ShiftFormModal({
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Příchod - čas</label>
Příchod - čas
</label>
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.arrival_time} value={form.arrival_time}
@@ -397,30 +361,22 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.break_start_date} value={form.break_start_date}
onChange={(val) => onChange={(val) => updateField("break_start_date", val)}
updateField("break_start_date", val)
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Začátek pauzy - čas</label>
Začátek pauzy - čas
</label>
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => onChange={(val) => updateField("break_start_time", val)}
updateField("break_start_time", val)
}
/> />
</div> </div>
</div> </div>
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Konec pauzy - datum</label>
Konec pauzy - datum
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.break_end_date} value={form.break_end_date}
@@ -428,9 +384,7 @@ export default function ShiftFormModal({
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Konec pauzy - čas</label>
Konec pauzy - čas
</label>
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_end_time} value={form.break_end_time}
@@ -441,9 +395,7 @@ export default function ShiftFormModal({
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Odchod - datum</label>
Odchod - datum
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.departure_date} value={form.departure_date}
@@ -496,27 +448,6 @@ export default function ShiftFormModal({
/> />
</div> </div>
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
); );
} }

View File

@@ -1,9 +1,9 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
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 FormModal from "../FormModal";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -71,8 +71,6 @@ export default function DashProfile({
last_name: "", last_name: "",
}); });
useModalLock(showModal);
const openEditModal = () => { const openEditModal = () => {
const nameParts = (user?.fullName || "").split(" "); const nameParts = (user?.fullName || "").split(" ");
setFormData({ setFormData({
@@ -261,30 +259,14 @@ export default function DashProfile({
</motion.div> </motion.div>
{/* Edit Profile Modal */} {/* Edit Profile Modal */}
<AnimatePresence> <FormModal
{showModal && ( isOpen={showModal}
<motion.div onClose={() => setShowModal(false)}
className="admin-modal-overlay" title="Upravit profil"
initial={{ opacity: 0 }} size="md"
animate={{ opacity: 1 }} onSubmit={handleSubmit}
exit={{ opacity: 0 }} submitLabel="Uložit změny"
transition={{ duration: 0.2 }}
> >
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit profil</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
@@ -319,9 +301,7 @@ export default function DashProfile({
</div> </div>
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Uživatelské jméno</label>
Uživatelské jméno
</label>
<input <input
type="text" type="text"
value={formData.username} value={formData.username}
@@ -360,9 +340,7 @@ export default function DashProfile({
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Nové heslo</label>
Nové heslo
</label>
<input <input
type="password" type="password"
value={formData.new_password} value={formData.new_password}
@@ -377,59 +355,41 @@ export default function DashProfile({
/> />
</div> </div>
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit změny
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 2FA Setup Modal */} {/* 2FA Setup Modal — multi-step: setup → backup codes */}
<AnimatePresence> <FormModal
{show2FASetup && ( isOpen={show2FASetup}
<motion.div onClose={() => {
className="admin-modal-overlay" // Only reachable on the setup step (backdrop/ESC/× are locked while
initial={{ opacity: 0 }} // backupCodes is set — that branch is unreachable so removed).
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => {
if (!backupCodes) {
setShow2FASetup(false); setShow2FASetup(false);
} setTotpCode("");
}} }}
/> title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
<motion.div size="md"
className="admin-modal" loading={backupCodes ? false : totpSubmitting}
initial={{ opacity: 0, scale: 0.95, y: 20 }} onSubmit={
animate={{ opacity: 1, scale: 1, y: 0 }} backupCodes
exit={{ opacity: 0, scale: 0.95, y: 20 }} ? () => {
transition={{ duration: 0.2 }} setShow2FASetup(false);
setBackupCodes(null);
}
: 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}
> >
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
</h2>
</div>
<div className="admin-modal-body">
{backupCodes ? ( {backupCodes ? (
<div> <div>
<div className="admin-role-locked-notice mb-4"> <div className="admin-role-locked-notice mb-4">
@@ -445,9 +405,8 @@ export default function DashProfile({
<line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
Uložte si tyto kódy na bezpečné místo. Každý kód lze Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze
použít pouze jednou. Po zavření tohoto okna je již jednou. Po zavření tohoto okna je již neuvidíte.
neuvidíte.
</div> </div>
<div <div
style={{ style={{
@@ -477,9 +436,7 @@ export default function DashProfile({
<div style={{ marginTop: "0.75rem" }}> <div style={{ marginTop: "0.75rem" }}>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard?.writeText( navigator.clipboard?.writeText(backupCodes.join("\n"));
backupCodes.join("\n"),
);
alert.success("Kódy zkopírovány"); alert.success("Kódy zkopírovány");
}} }}
className="admin-btn admin-btn-secondary admin-btn-sm" className="admin-btn admin-btn-secondary admin-btn-sm"
@@ -492,19 +449,25 @@ export default function DashProfile({
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
> >
<rect <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
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" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg> </svg>
Kopírovat kódy Kopírovat kódy
</button> </button>
</div> </div>
{/* Footer is hidden on this step; the single deliberate exit lives
in the body and also clears the codes. */}
<div style={{ marginTop: "1.25rem", textAlign: "right" }}>
<button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
</div>
</div> </div>
) : ( ) : (
<div> <div>
@@ -512,13 +475,11 @@ export default function DashProfile({
className="text-secondary" className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }} style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
> >
Naskenujte QR kód v autentizační aplikaci (Google Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authenticator, Authy, Microsoft Authenticator apod.) Authy, Microsoft Authenticator apod.)
</p> </p>
{totpQrUri && ( {totpQrUri && (
<div <div style={{ textAlign: "center", marginBottom: "1rem" }}>
style={{ textAlign: "center", marginBottom: "1rem" }}
>
<img <img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`} src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
alt="TOTP QR Code" alt="TOTP QR Code"
@@ -573,14 +534,7 @@ export default function DashProfile({
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
> >
<rect <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
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" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg> </svg>
</button> </button>
@@ -598,9 +552,7 @@ export default function DashProfile({
pattern="[0-9]*" pattern="[0-9]*"
maxLength={6} maxLength={6}
value={totpCode} value={totpCode}
onChange={(e) => onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
setTotpCode(e.target.value.replace(/\D/g, ""))
}
placeholder="000000" placeholder="000000"
className="admin-form-input" className="admin-form-input"
style={{ style={{
@@ -618,67 +570,20 @@ export default function DashProfile({
</div> </div>
</div> </div>
)} )}
</div> </FormModal>
<div className="admin-modal-footer">
{backupCodes ? (
<button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
) : (
<>
<button
onClick={() => setShow2FASetup(false)}
className="admin-btn admin-btn-secondary"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
onClick={onConfirm2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || totpCode.length !== 6}
>
{totpSubmitting ? "Ověřuji..." : "Aktivovat 2FA"}
</button>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 2FA Disable Modal */} {/* 2FA Disable Modal */}
<AnimatePresence> <FormModal
{show2FADisable && ( isOpen={show2FADisable}
<motion.div onClose={() => setShow2FADisable(false)}
className="admin-modal-overlay" title="Deaktivovat 2FA"
initial={{ opacity: 0 }} size="md"
animate={{ opacity: 1 }} loading={totpSubmitting}
exit={{ opacity: 0 }} onSubmit={onDisable2FA}
transition={{ duration: 0.2 }} submitDisabled={disableCode.length !== 6}
submitLabel="Deaktivovat 2FA"
cancelLabel="Zrušit"
> >
<div
className="admin-modal-backdrop"
onClick={() => setShow2FADisable(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Deaktivovat 2FA</h2>
</div>
<div className="admin-modal-body">
<p <p
style={{ style={{
color: "var(--text-secondary)", color: "var(--text-secondary)",
@@ -697,9 +602,7 @@ export default function DashProfile({
pattern="[0-9]*" pattern="[0-9]*"
maxLength={6} maxLength={6}
value={disableCode} value={disableCode}
onChange={(e) => onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
setDisableCode(e.target.value.replace(/\D/g, ""))
}
placeholder="000000" placeholder="000000"
className="admin-form-input" className="admin-form-input"
style={{ style={{
@@ -716,27 +619,7 @@ export default function DashProfile({
autoFocus autoFocus
/> />
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
onClick={() => setShow2FADisable(false)}
className="admin-btn admin-btn-secondary"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
onClick={onDisable2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || disableCode.length !== 6}
>
{totpSubmitting ? "Deaktivuji..." : "Deaktivovat 2FA"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</> </>
); );
} }

View File

@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
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 AdminDatePicker from "../AdminDatePicker";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import useModalLock from "../../hooks/useModalLock"; import FormModal from "../FormModal";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -69,8 +69,6 @@ export default function DashQuickActions({
}); });
const [tripErrors, setTripErrors] = useState<TripErrors>({}); const [tripErrors, setTripErrors] = useState<TripErrors>({});
useModalLock(showTripModal);
const openTripModal = async () => { const openTripModal = async () => {
setTripForm({ setTripForm({
vehicle_id: "", vehicle_id: "",
@@ -316,38 +314,21 @@ export default function DashQuickActions({
)} )}
</motion.div> </motion.div>
<AnimatePresence> <FormModal
{showTripModal && ( isOpen={showTripModal}
<motion.div onClose={() => setShowTripModal(false)}
className="admin-modal-overlay" title="Přidat jízdu"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} onSubmit={handleTripSubmit}
exit={{ opacity: 0 }} submitLabel="Uložit"
transition={{ duration: 0.2 }} loading={tripSubmitting}
> >
<div
className="admin-modal-backdrop"
onClick={() => setShowTripModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
<div <div
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`} className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">Vozidlo</label>
Vozidlo
</label>
<select <select
value={tripForm.vehicle_id} value={tripForm.vehicle_id}
onChange={(e) => { onChange={(e) => {
@@ -375,9 +356,7 @@ export default function DashQuickActions({
<div <div
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`} className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">Datum jízdy</label>
Datum jízdy
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={tripForm.trip_date} value={tripForm.trip_date}
@@ -390,9 +369,7 @@ export default function DashQuickActions({
}} }}
/> />
{tripErrors.trip_date && ( {tripErrors.trip_date && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.trip_date}</span>
{tripErrors.trip_date}
</span>
)} )}
</div> </div>
</div> </div>
@@ -422,9 +399,7 @@ export default function DashQuickActions({
min="0" min="0"
/> />
{tripErrors.start_km && ( {tripErrors.start_km && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.start_km}</span>
{tripErrors.start_km}
</span>
)} )}
</div> </div>
<div <div
@@ -451,9 +426,7 @@ export default function DashQuickActions({
min="0" min="0"
/> />
{tripErrors.end_km && ( {tripErrors.end_km && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.end_km}</span>
{tripErrors.end_km}
</span>
)} )}
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -472,9 +445,7 @@ export default function DashQuickActions({
<div <div
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`} className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">Místo odjezdu</label>
Místo odjezdu
</label>
<input <input
type="text" type="text"
value={tripForm.route_from} value={tripForm.route_from}
@@ -520,9 +491,7 @@ export default function DashQuickActions({
placeholder="Např. Brno" placeholder="Např. Brno"
/> />
{tripErrors.route_to && ( {tripErrors.route_to && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.route_to}</span>
{tripErrors.route_to}
</span>
)} )}
</div> </div>
</div> </div>
@@ -560,29 +529,7 @@ export default function DashQuickActions({
/> />
</div> </div>
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowTripModal(false)}
className="admin-btn admin-btn-secondary"
disabled={tripSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleTripSubmit}
className="admin-btn admin-btn-primary"
disabled={tripSubmitting}
>
{tripSubmitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</> </>
); );
} }

View File

@@ -3,10 +3,10 @@ import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock"; import FormModal from "../components/FormModal";
import { import {
formatTime, formatTime,
calculateWorkMinutes, calculateWorkMinutes,
@@ -172,8 +172,6 @@ export default function Attendance() {
} }
}, [statusQuery.data]); }, [statusQuery.data]);
useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />; if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => { const handlePunch = (action: string) => {
@@ -931,31 +929,24 @@ export default function Attendance() {
</div> </div>
{/* Leave Modal */} {/* Leave Modal */}
<AnimatePresence> <FormModal
{isLeaveModalOpen && ( isOpen={isLeaveModalOpen}
<motion.div onClose={() => setIsLeaveModalOpen(false)}
className="admin-modal-overlay" title="Žádost o nepřítomnost"
initial={{ opacity: 0 }} onSubmit={() => {
animate={{ opacity: 1 }} // Preserve original guard: do not submit when there are 0 business days.
exit={{ opacity: 0 }} if (
transition={{ duration: 0.2 }} calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
)
return;
handleRequestSubmit();
}}
submitLabel="Odeslat žádost"
submitDisabled={
calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
}
loading={requestSubmitting}
> >
<div
className="admin-modal-backdrop"
onClick={() => setIsLeaveModalOpen(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<FormField label="Typ nepřítomnosti"> <FormField label="Typ nepřítomnosti">
<select <select
@@ -1058,36 +1049,7 @@ export default function Attendance() {
/> />
</FormField> </FormField>
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleRequestSubmit}
disabled={
requestSubmitting ||
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) === 0
}
className="admin-btn admin-btn-primary"
>
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal <ConfirmModal
isOpen={gpsConfirm.isOpen} isOpen={gpsConfirm.isOpen}