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:
@@ -1,6 +1,5 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import FormModal from "./FormModal";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
|
||||
interface BulkAttendanceForm {
|
||||
month: string;
|
||||
@@ -39,182 +38,128 @@ export default function BulkAttendanceModal({
|
||||
toggleUser,
|
||||
toggleAllUsers,
|
||||
}: BulkAttendanceModalProps) {
|
||||
useModalLock(isOpen);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => !submitting && onClose()}
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Vyplnit docházku za měsíc"
|
||||
size="lg"
|
||||
loading={submitting}
|
||||
onSubmit={onSubmit}
|
||||
submitLabel="Vyplnit měsíc"
|
||||
submitDisabled={form.user_ids.length === 0}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
|
||||
Existující záznamy se přeskočí.
|
||||
</p>
|
||||
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Měsíc</label>
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Zaměstnanci
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent-color)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius-sm)",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2
|
||||
id="bulk-attendance-modal-title"
|
||||
className="admin-modal-title"
|
||||
>
|
||||
Vyplnit docházku za měsíc
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
|
||||
označí. Existující záznamy se přeskočí.
|
||||
</p>
|
||||
</div>
|
||||
{users.map((user) => (
|
||||
<label key={user.id} className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<small className="admin-form-hint">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Měsíc</label>
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Zaměstnanci
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent-color)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius-sm)",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<label key={user.id} className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<small className="admin-form-hint">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import FormModal from "./FormModal";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
interface ConfirmationItem {
|
||||
@@ -103,294 +103,250 @@ export default function OrderConfirmationModal({
|
||||
}, [defaultVatRate]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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"
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Potvrzení objednávky ${orderNumber}`}
|
||||
size={step === "edit" ? "lg" : "md"}
|
||||
loading={loading}
|
||||
hideFooter
|
||||
>
|
||||
{step === "choose" ? (
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("cs")}
|
||||
className={
|
||||
lang === "cs"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Potvrzení objednávky {orderNumber}
|
||||
</h2>
|
||||
Čeština
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("en")}
|
||||
className={
|
||||
lang === "en"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
{step === "choose" ? (
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("cs")}
|
||||
className={
|
||||
lang === "cs"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Čeština
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("en")}
|
||||
className={
|
||||
lang === "en"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">DPH</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(true)}
|
||||
className={
|
||||
applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
S DPH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(false)}
|
||||
className={
|
||||
!applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Bez DPH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Obsah potvrzení</label>
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUseExisting}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary w-full"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Použít položky z objednávky"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setItems(initialItems.length > 0 ? initialItems : []);
|
||||
setStep("edit");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-secondary w-full"
|
||||
>
|
||||
Upravit položky
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ minWidth: "200px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "80px" }}
|
||||
step="0.001"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "100px" }}
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "70px" }}
|
||||
step="1"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat položku
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">DPH</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(true)}
|
||||
className={
|
||||
applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
S DPH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(false)}
|
||||
className={
|
||||
!applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Bez DPH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
{step === "edit" && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Obsah potvrzení</label>
|
||||
<p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUseExisting}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary w-full"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("choose")}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zpět
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditGenerate}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={loading || items.length === 0}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Vygenerovat PDF"
|
||||
)}
|
||||
</button>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Použít položky z objednávky"
|
||||
)}
|
||||
{step === "choose" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setItems(initialItems.length > 0 ? initialItems : []);
|
||||
setStep("edit");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-secondary w-full"
|
||||
>
|
||||
Upravit položky
|
||||
</button>
|
||||
</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 className="admin-form">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ minWidth: "200px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "80px" }}
|
||||
step="0.001"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(i, "unit", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "100px" }}
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "70px" }}
|
||||
step="1"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat položku
|
||||
</button>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("choose")}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zpět
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditGenerate}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={loading || items.length === 0}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Vygenerovat PDF"
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import FormModal from "./FormModal";
|
||||
import {
|
||||
calcFormWorkMinutes,
|
||||
calcProjectMinutesTotal,
|
||||
@@ -216,7 +215,6 @@ export default function ShiftFormModal({
|
||||
onShiftDateChange,
|
||||
editingRecord,
|
||||
}: ShiftFormModalProps) {
|
||||
useModalLock(isOpen);
|
||||
const isCreate = mode === "create";
|
||||
const isWorkType = form.leave_type === "work";
|
||||
|
||||
@@ -247,276 +245,209 @@ export default function ShiftFormModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
subtitle={
|
||||
!isCreate && editingRecord
|
||||
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
||||
: undefined
|
||||
}
|
||||
size="lg"
|
||||
onSubmit={onSubmit}
|
||||
submitLabel="Uložit"
|
||||
cancelLabel="Zrušit"
|
||||
>
|
||||
<div className="admin-form">
|
||||
{isCreate ? (
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">Zaměstnanec</label>
|
||||
<select
|
||||
value={form.user_id}
|
||||
onChange={(e) => updateField("user_id", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
{isCreate ? (
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Zaměstnanec
|
||||
</label>
|
||||
<select
|
||||
value={form.user_id}
|
||||
onChange={(e) => updateField("user_id", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Datum směny
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ záznamu</label>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ záznamu</label>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</div>
|
||||
{!isWorkType && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Počet hodin</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
/>
|
||||
{isCreate && (
|
||||
<small className="admin-form-hint">8 hodin = celý den</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isWorkType && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Počet hodin</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
/>
|
||||
{isCreate && (
|
||||
<small className="admin-form-hint">
|
||||
8 hodin = celý den
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Příchod - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Příchod - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val) =>
|
||||
updateField("break_start_date", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
updateField("break_start_time", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Konec pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Konec pauzy - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Odchod - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Projekty</label>
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProjectLog}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat projekt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámka</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val) => updateField("break_start_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => updateField("break_start_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Projekty</label>
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProjectLog}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat projekt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámka</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
import apiFetch from "../../utils/api";
|
||||
import FormModal from "../FormModal";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -71,8 +71,6 @@ export default function DashProfile({
|
||||
last_name: "",
|
||||
});
|
||||
|
||||
useModalLock(showModal);
|
||||
|
||||
const openEditModal = () => {
|
||||
const nameParts = (user?.fullName || "").split(" ");
|
||||
setFormData({
|
||||
@@ -261,482 +259,367 @@ export default function DashProfile({
|
||||
</motion.div>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
<AnimatePresence>
|
||||
{showModal && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jméno</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
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) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Aktuální heslo</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Nové heslo
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.new_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
new_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
<AnimatePresence>
|
||||
{show2FASetup && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => {
|
||||
if (!backupCodes) {
|
||||
setShow2FASetup(false);
|
||||
<FormModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Upravit profil"
|
||||
size="md"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Uložit změny"
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jméno</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
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) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
<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">
|
||||
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
{backupCodes ? (
|
||||
<div>
|
||||
<div className="admin-role-locked-notice mb-4">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
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.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "0.5rem",
|
||||
padding: "1rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{backupCodes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
style={{
|
||||
padding: "0.25rem 0.5rem",
|
||||
textAlign: "center",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(
|
||||
backupCodes.join("\n"),
|
||||
);
|
||||
alert.success("Kódy zkopírovány");
|
||||
}}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
<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>
|
||||
Kopírovat kódy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
|
||||
>
|
||||
Naskenujte QR kód v autentizační aplikaci (Google
|
||||
Authenticator, Authy, Microsoft Authenticator apod.)
|
||||
</p>
|
||||
{totpQrUri && (
|
||||
<div
|
||||
style={{ textAlign: "center", marginBottom: "1rem" }}
|
||||
>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
||||
alt="TOTP QR Code"
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{totpSecret && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
Nebo zadejte klíč ručně:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.375rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.875rem",
|
||||
wordBreak: "break-all",
|
||||
color: "var(--text-primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
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) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
onConfirm2FA();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Aktuální heslo</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Nové heslo</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.new_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
new_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
{/* 2FA Disable Modal */}
|
||||
<AnimatePresence>
|
||||
{show2FADisable && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* 2FA Setup Modal — multi-step: setup → backup codes */}
|
||||
<FormModal
|
||||
isOpen={show2FASetup}
|
||||
onClose={() => {
|
||||
// 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={
|
||||
backupCodes
|
||||
? () => {
|
||||
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}
|
||||
>
|
||||
{backupCodes ? (
|
||||
<div>
|
||||
<div className="admin-role-locked-notice mb-4">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
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.
|
||||
</div>
|
||||
<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 }}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "0.5rem",
|
||||
padding: "1rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Deaktivovat 2FA</h2>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<p
|
||||
{backupCodes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "1rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
textAlign: "center",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
||||
autentizační aplikace.
|
||||
</p>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Ověřovací kód</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) =>
|
||||
setDisableCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder="000000"
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "monospace",
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(backupCodes.join("\n"));
|
||||
alert.success("Kódy zkopírovány");
|
||||
}}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
<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>
|
||||
Kopírovat kódy
|
||||
</button>
|
||||
</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>
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
|
||||
>
|
||||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||||
Authy, Microsoft Authenticator apod.)
|
||||
</p>
|
||||
{totpQrUri && (
|
||||
<div style={{ textAlign: "center", marginBottom: "1rem" }}>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
||||
alt="TOTP QR Code"
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{totpSecret && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
Nebo zadejte klíč ručně:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.375rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.875rem",
|
||||
wordBreak: "break-all",
|
||||
color: "var(--text-primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span>{totpSecret}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(totpSecret);
|
||||
alert.success("Klíč zkopírován");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && disableCode.length === 6) {
|
||||
onDisable2FA();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
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-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>
|
||||
)}
|
||||
<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) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
onConfirm2FA();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</FormModal>
|
||||
|
||||
{/* 2FA Disable Modal */}
|
||||
<FormModal
|
||||
isOpen={show2FADisable}
|
||||
onClose={() => setShow2FADisable(false)}
|
||||
title="Deaktivovat 2FA"
|
||||
size="md"
|
||||
loading={totpSubmitting}
|
||||
onSubmit={onDisable2FA}
|
||||
submitDisabled={disableCode.length !== 6}
|
||||
submitLabel="Deaktivovat 2FA"
|
||||
cancelLabel="Zrušit"
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
||||
autentizační aplikace.
|
||||
</p>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Ověřovací kód</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(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) => {
|
||||
if (e.key === "Enter" && disableCode.length === 6) {
|
||||
onDisable2FA();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
||||
import AdminDatePicker from "../AdminDatePicker";
|
||||
import apiFetch from "../../utils/api";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
import FormModal from "../FormModal";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -69,8 +69,6 @@ export default function DashQuickActions({
|
||||
});
|
||||
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
||||
|
||||
useModalLock(showTripModal);
|
||||
|
||||
const openTripModal = async () => {
|
||||
setTripForm({
|
||||
vehicle_id: "",
|
||||
@@ -316,273 +314,222 @@ export default function DashQuickActions({
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTripModal && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<FormModal
|
||||
isOpen={showTripModal}
|
||||
onClose={() => setShowTripModal(false)}
|
||||
title="Přidat jízdu"
|
||||
size="lg"
|
||||
onSubmit={handleTripSubmit}
|
||||
submitLabel="Uložit"
|
||||
loading={tripSubmitting}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<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 }}
|
||||
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
|
||||
>
|
||||
<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-row">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Vozidlo
|
||||
</label>
|
||||
<select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleTripVehicleChange(e.target.value);
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
vehicle_id: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte vozidlo</option>
|
||||
{tripVehicles.map((v) => (
|
||||
<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>
|
||||
<label className="admin-form-label required">Vozidlo</label>
|
||||
<select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleTripVehicleChange(e.target.value);
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
vehicle_id: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte vozidlo</option>
|
||||
{tripVehicles.map((v) => (
|
||||
<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 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-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">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>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</FormModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import FormModal from "../components/FormModal";
|
||||
import {
|
||||
formatTime,
|
||||
calculateWorkMinutes,
|
||||
@@ -172,8 +172,6 @@ export default function Attendance() {
|
||||
}
|
||||
}, [statusQuery.data]);
|
||||
|
||||
useModalLock(isLeaveModalOpen);
|
||||
|
||||
if (!hasPermission("attendance.record")) return <Forbidden />;
|
||||
|
||||
const handlePunch = (action: string) => {
|
||||
@@ -931,163 +929,127 @@ export default function Attendance() {
|
||||
</div>
|
||||
|
||||
{/* Leave Modal */}
|
||||
<AnimatePresence>
|
||||
{isLeaveModalOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<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 }}
|
||||
<FormModal
|
||||
isOpen={isLeaveModalOpen}
|
||||
onClose={() => setIsLeaveModalOpen(false)}
|
||||
title="Žádost o nepřítomnost"
|
||||
onSubmit={() => {
|
||||
// Preserve original guard: do not submit when there are 0 business days.
|
||||
if (
|
||||
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-form">
|
||||
<FormField label="Typ nepřítomnosti">
|
||||
<select
|
||||
value={leaveForm.leave_type}
|
||||
onChange={(e) =>
|
||||
setLeaveForm({
|
||||
...leaveForm,
|
||||
leave_type: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
|
||||
</div>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<FormField label="Typ nepřítomnosti">
|
||||
<select
|
||||
value={leaveForm.leave_type}
|
||||
onChange={(e) =>
|
||||
setLeaveForm({
|
||||
...leaveForm,
|
||||
leave_type: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<FormField label="Od">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={leaveForm.date_from}
|
||||
onChange={(val: string) => {
|
||||
setLeaveForm((prev) => ({
|
||||
...prev,
|
||||
date_from: val,
|
||||
date_to: prev.date_to < val ? val : prev.date_to,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={leaveForm.date_to}
|
||||
minDate={leaveForm.date_from}
|
||||
onChange={(val: string) =>
|
||||
setLeaveForm({ ...leaveForm, date_to: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<FormField label="Od">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={leaveForm.date_from}
|
||||
onChange={(val: string) => {
|
||||
setLeaveForm((prev) => ({
|
||||
...prev,
|
||||
date_from: val,
|
||||
date_to: prev.date_to < val ? val : prev.date_to,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={leaveForm.date_to}
|
||||
minDate={leaveForm.date_from}
|
||||
onChange={(val: string) =>
|
||||
setLeaveForm({ ...leaveForm, date_to: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{leaveForm.date_from && leaveForm.date_to && (
|
||||
<div className="admin-form-group">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1.5rem",
|
||||
padding: "0.75rem 1rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>
|
||||
{calculateBusinessDays(
|
||||
leaveForm.date_from,
|
||||
leaveForm.date_to,
|
||||
)}
|
||||
</strong>{" "}
|
||||
{czechPlural(
|
||||
calculateBusinessDays(
|
||||
leaveForm.date_from,
|
||||
leaveForm.date_to,
|
||||
),
|
||||
"pracovní den",
|
||||
"pracovní dny",
|
||||
"pracovních dnů",
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
{calculateBusinessDays(
|
||||
leaveForm.date_from,
|
||||
leaveForm.date_to,
|
||||
) * 8}{" "}
|
||||
hodin
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
value={leaveForm.notes}
|
||||
onChange={(e) =>
|
||||
setLeaveForm({ ...leaveForm, notes: e.target.value })
|
||||
}
|
||||
placeholder="Volitelná poznámka..."
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ||
|
||||
{leaveForm.date_from && leaveForm.date_to && (
|
||||
<div className="admin-form-group">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1.5rem",
|
||||
padding: "0.75rem 1rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>
|
||||
{calculateBusinessDays(
|
||||
leaveForm.date_from,
|
||||
leaveForm.date_to,
|
||||
)}
|
||||
</strong>{" "}
|
||||
{czechPlural(
|
||||
calculateBusinessDays(
|
||||
leaveForm.date_from,
|
||||
leaveForm.date_to,
|
||||
) === 0
|
||||
}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"}
|
||||
</button>
|
||||
),
|
||||
"pracovní den",
|
||||
"pracovní dny",
|
||||
"pracovních dnů",
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
{calculateBusinessDays(
|
||||
leaveForm.date_from,
|
||||
leaveForm.date_to,
|
||||
) * 8}{" "}
|
||||
hodin
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
value={leaveForm.notes}
|
||||
onChange={(e) =>
|
||||
setLeaveForm({ ...leaveForm, notes: e.target.value })
|
||||
}
|
||||
placeholder="Volitelná poznámka..."
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={gpsConfirm.isOpen}
|
||||
|
||||
Reference in New Issue
Block a user