Compare commits

...

6 Commits

Author SHA1 Message Date
BOHA
dafa6d6aab chore(release): v1.9.4 — modal UX unification + project responsible-person filter
- Unify modals: ConfirmModal now locks scroll; 8 ad-hoc modals migrated to the
  shared FormModal (consistent open/close animation, blurred backdrop, ESC,
  click-outside, scroll-lock, mobile, reduced-motion). FormModal gains
  submitDisabled + subtitle props.
- Projects: the responsible-person picker (create modal + edit page) lists only
  active users with projects.view (edit page keeps the current assignee).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:54:32 +02:00
BOHA
80803cc868 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>
2026-06-06 14:53:28 +02:00
BOHA
55fa4045ad feat(modals): FormModal gains submitDisabled + subtitle props
submitDisabled greys out the primary (so callers can gate it on validity
instead of a clickable-but-inert button); subtitle renders a muted line under
the title in the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:28 +02:00
BOHA
7f156cc721 fix(modals): ConfirmModal locks body scroll + respects loading on backdrop
ConfirmModal never called useModalLock, so the page scrolled behind every
confirmation dialog (8+ usages). Add the ref-counted scroll lock and guard
backdrop-click against the loading state, matching FormModal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:34:48 +02:00
BOHA
e0d2fccf50 fix(projects): edit-page responsible picker shows only active users (keeps current assignee)
Mirror the create-modal fix on ProjectDetail: filter the 'Zodpovědná osoba'
select to active users (already scoped to projects.view), but keep the
project's currently-assigned user selectable even if inactive, labelled
'(neaktivní)', so the saved value still shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:24:41 +02:00
BOHA
a1c251650b fix(projects): responsible-person picker shows only active users with projects.view
The create-project modal's 'Zodpovědná osoba' select listed every user
(including inactive ones). Filter the query server-side by projects.view and
hide inactive users client-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:19:15 +02:00
13 changed files with 1291 additions and 1625 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.9.3", "version": "1.9.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "1.9.3", "version": "1.9.4",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.9.3", "version": "1.9.4",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -255,12 +255,23 @@
gap: 12px; gap: 12px;
} }
.admin-modal-heading {
min-width: 0;
}
.admin-modal-title { .admin-modal-title {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
.admin-modal-subtitle {
margin: 2px 0 0;
font-size: 12.5px;
font-weight: 500;
color: var(--text-secondary);
}
.admin-modal-close { .admin-modal-close {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;

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,182 +38,128 @@ 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" <p
onClick={() => !submitting && onClose()} 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 </div>
className="admin-modal admin-modal-lg"
role="dialog" <div className="admin-form-group">
aria-modal="true" <label className="admin-form-label">
aria-labelledby="bulk-attendance-modal-title" Zaměstnanci
initial={{ opacity: 0, scale: 0.95, y: 20 }} <button
animate={{ opacity: 1, scale: 1, y: 0 }} type="button"
exit={{ opacity: 0, scale: 0.95, y: 20 }} onClick={toggleAllUsers}
transition={{ duration: 0.2 }} 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"> {users.map((user) => (
<h2 <label key={user.id} className="admin-form-checkbox">
id="bulk-attendance-modal-title" <input
className="admin-modal-title" type="checkbox"
> checked={form.user_ids.includes(String(user.id))}
Vyplnit docházku za měsíc onChange={() => toggleUser(user.id)}
</h2> />
<p <span>{user.name}</span>
style={{ </label>
color: "var(--text-secondary)", ))}
marginTop: "0.25rem", </div>
fontSize: "0.875rem", <small className="admin-form-hint">
}} Vybráno: {form.user_ids.length} z {users.length}
> </small>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky </div>
označí. Existující záznamy se přeskočí.
</p>
</div>
<div className="admin-modal-body"> <div className="admin-form-row">
<div className="admin-form"> <div className="admin-form-group">
<div className="admin-form-group"> <label className="admin-form-label">Příchod</label>
<label className="admin-form-label">Měsíc</label> <AdminDatePicker
<AdminDatePicker mode="time"
mode="month" value={form.arrival_time}
value={form.month} onChange={(val) => setForm({ ...form, arrival_time: val })}
onChange={(val) => setForm({ ...form, month: val })} />
/> </div>
</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"> <div className="admin-form-row">
<label className="admin-form-label"> <div className="admin-form-group">
Zaměstnanci <label className="admin-form-label">Začátek pauzy</label>
<button <AdminDatePicker
type="button" mode="time"
onClick={toggleAllUsers} value={form.break_start_time}
style={{ onChange={(val) => setForm({ ...form, break_start_time: val })}
marginLeft: "0.75rem", />
background: "none", </div>
border: "none", <div className="admin-form-group">
color: "var(--accent-color)", <label className="admin-form-label">Konec pauzy</label>
cursor: "pointer", <AdminDatePicker
fontSize: "0.8125rem", mode="time"
fontWeight: 500, value={form.break_end_time}
padding: 0, onChange={(val) => setForm({ ...form, break_end_time: val })}
}} />
> </div>
{form.user_ids.length === users.length </div>
? "Odznačit vše" </div>
: "Vybrat vše"} </FormModal>
</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>
); );
} }

View File

@@ -1,6 +1,7 @@
import { useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import useReducedMotion from "../hooks/useReducedMotion"; import useReducedMotion from "../hooks/useReducedMotion";
import useModalLock from "../hooks/useModalLock";
interface ConfirmModalProps { interface ConfirmModalProps {
isOpen: boolean; isOpen: boolean;
@@ -93,6 +94,8 @@ export default function ConfirmModal({
loading, loading,
}: ConfirmModalProps) { }: ConfirmModalProps) {
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
// Lock body scroll while open (ref-counted, nesting-safe) — matches FormModal.
useModalLock(isOpen);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@@ -119,7 +122,10 @@ export default function ConfirmModal({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}
> >
<div className="admin-modal-backdrop" onClick={onClose} /> <div
className="admin-modal-backdrop"
onClick={() => !loading && onClose()}
/>
<motion.div <motion.div
className="admin-modal admin-confirm-modal" className="admin-modal admin-confirm-modal"
role="dialog" role="dialog"

View File

@@ -15,10 +15,15 @@ export interface FormModalProps {
onClose: () => void; onClose: () => void;
onSubmit?: () => void; // called when user clicks primary button (only if provided) onSubmit?: () => void; // called when user clicks primary button (only if provided)
title: string; title: string;
/** Optional muted line shown under the title in the header. */
subtitle?: ReactNode;
children: ReactNode; // modal body (form fields) children: ReactNode; // modal body (form fields)
submitLabel?: string; // primary button text submitLabel?: string; // primary button text
cancelLabel?: string; // cancel button text cancelLabel?: string; // cancel button text
loading?: boolean; loading?: boolean;
/** Disable the primary (submit) button — e.g. until the form is valid.
* Keeps the button visibly greyed out instead of clickable-but-inert. */
submitDisabled?: boolean;
hideFooter?: boolean; // for "view-only" modals hideFooter?: boolean; // for "view-only" modals
size?: "sm" | "md" | "lg"; // optional size?: "sm" | "md" | "lg"; // optional
closeOnBackdrop?: boolean; // default true closeOnBackdrop?: boolean; // default true
@@ -35,10 +40,12 @@ export default function FormModal({
onClose, onClose,
onSubmit, onSubmit,
title, title,
subtitle,
children, children,
submitLabel = "Uložit", submitLabel = "Uložit",
cancelLabel = "Zrušit", cancelLabel = "Zrušit",
loading = false, loading = false,
submitDisabled = false,
hideFooter = false, hideFooter = false,
size = "md", size = "md",
closeOnBackdrop = true, closeOnBackdrop = true,
@@ -107,9 +114,12 @@ export default function FormModal({
transition={{ duration, ease }} transition={{ duration, ease }}
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 id={titleId} className="admin-modal-title"> <div className="admin-modal-heading">
{title} <h2 id={titleId} className="admin-modal-title">
</h2> {title}
</h2>
{subtitle && <p className="admin-modal-subtitle">{subtitle}</p>}
</div>
{!hideCloseButton && ( {!hideCloseButton && (
<button <button
type="button" type="button"
@@ -175,7 +185,7 @@ export default function FormModal({
type="button" type="button"
onClick={handlePrimaryClick} onClick={handlePrimaryClick}
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
disabled={loading} disabled={loading || submitDisabled}
> >
{loading ? "Zpracování..." : submitLabel} {loading ? "Zpracování..." : submitLabel}
</button> </button>

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,294 +103,250 @@ 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 }} >
> {step === "choose" ? (
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-form">
<motion.div <div className="admin-form-group">
className={ <label className="admin-form-label">Jazyk dokumentu</label>
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal" <div className="flex-row gap-2">
} <button
role="dialog" type="button"
aria-modal="true" onClick={() => setLang("cs")}
aria-labelledby="order-confirmation-modal-title" className={
initial={{ opacity: 0, scale: 0.95, y: 20 }} lang === "cs"
animate={{ opacity: 1, scale: 1, y: 0 }} ? "admin-btn admin-btn-primary admin-btn-sm"
exit={{ opacity: 0, scale: 0.95, y: 20 }} : "admin-btn admin-btn-secondary admin-btn-sm"
transition={{ duration: 0.2 }} }
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
> >
Potvrzení objednávky {orderNumber} Čeština
</h2> </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>
<div className="admin-modal-body"> <div className="admin-form-group">
{step === "choose" ? ( <label className="admin-form-label">DPH</label>
<div className="admin-form"> <div className="flex-row gap-2">
<div className="admin-form-group"> <button
<label className="admin-form-label">Jazyk dokumentu</label> type="button"
<div className="flex-row gap-2"> onClick={() => setApplyVatState(true)}
<button className={
type="button" applyVatState
onClick={() => setLang("cs")} ? "admin-btn admin-btn-primary admin-btn-sm"
className={ : "admin-btn admin-btn-secondary admin-btn-sm"
lang === "cs" }
? "admin-btn admin-btn-primary admin-btn-sm" >
: "admin-btn admin-btn-secondary admin-btn-sm" S DPH
} </button>
> <button
Čeština type="button"
</button> onClick={() => setApplyVatState(false)}
<button className={
type="button" !applyVatState
onClick={() => setLang("en")} ? "admin-btn admin-btn-primary admin-btn-sm"
className={ : "admin-btn admin-btn-secondary admin-btn-sm"
lang === "en" }
? "admin-btn admin-btn-primary admin-btn-sm" >
: "admin-btn admin-btn-secondary admin-btn-sm" Bez DPH
} </button>
>
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> </div>
</div>
<div className="admin-modal-footer"> <div className="admin-form-group">
{step === "edit" && ( <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 <div className="admin-spinner admin-spinner-sm" />
type="button" Generuji...
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>
</> </>
) : (
"Použít položky z objednávky"
)} )}
{step === "choose" && ( </button>
<button <button
type="button" onClick={() => {
onClick={onClose} setItems(initialItems.length > 0 ? initialItems : []);
className="admin-btn admin-btn-secondary" setStep("edit");
disabled={loading} }}
> disabled={loading}
Zrušit className="admin-btn admin-btn-secondary w-full"
</button> >
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> </button>
</motion.div> </div>
</motion.div> </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,276 +245,209 @@ 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
> }
<div className="admin-modal-backdrop" onClick={onClose} /> size="lg"
<motion.div onSubmit={onSubmit}
className="admin-modal admin-modal-lg" submitLabel="Uložit"
role="dialog" cancelLabel="Zrušit"
aria-modal="true" >
aria-labelledby="shift-form-modal-title" <div className="admin-form">
initial={{ opacity: 0, scale: 0.95, y: 20 }} {isCreate ? (
animate={{ opacity: 1, scale: 1, y: 0 }} <div className="admin-form-row">
exit={{ opacity: 0, scale: 0.95, y: 20 }} <div className="admin-form-group">
transition={{ duration: 0.2 }} <label className="admin-form-label required">Zaměstnanec</label>
> <select
<div className="admin-modal-header"> value={form.user_id}
<h2 id="shift-form-modal-title" className="admin-modal-title"> onChange={(e) => updateField("user_id", e.target.value)}
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"} className="admin-form-select"
</h2> >
{!isCreate && editingRecord && ( <option value="">Vyberte zaměstnance</option>
<p {users.map((user) => (
style={{ <option key={user.id} value={user.id}>
color: "var(--text-secondary)", {user.name}
marginTop: "0.25rem", </option>
}} ))}
> </select>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div> </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-group">
<div className="admin-form"> <label className="admin-form-label">Typ záznamu</label>
{isCreate ? ( <select
<div className="admin-form-row"> value={form.leave_type}
<div className="admin-form-group"> onChange={(e) => updateField("leave_type", e.target.value)}
<label className="admin-form-label required"> className="admin-form-select"
Zaměstnanec >
</label> <option value="work">Práce</option>
<select <option value="vacation">Dovolená</option>
value={form.user_id} <option value="sick">Nemoc</option>
onChange={(e) => updateField("user_id", e.target.value)} <option value="unpaid">Neplacené volno</option>
className="admin-form-select" </select>
> </div>
<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"> {!isWorkType && (
<label className="admin-form-label">Typ záznamu</label> <div className="admin-form-group">
<select <label className="admin-form-label">Počet hodin</label>
value={form.leave_type} <input
onChange={(e) => updateField("leave_type", e.target.value)} type="number"
className="admin-form-select" inputMode="decimal"
> value={form.leave_hours}
<option value="work">Práce</option> onChange={(e) =>
<option value="vacation">Dovolená</option> updateField("leave_hours", parseFloat(e.target.value))
<option value="sick">Nemoc</option> }
<option value="unpaid">Neplacené volno</option> min="0.5"
</select> max="24"
</div> step="0.5"
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">8 hodin = celý den</small>
)}
</div>
)}
{!isWorkType && ( {isWorkType && (
<div className="admin-form-group"> <>
<label className="admin-form-label">Počet hodin</label> <div className="admin-form-row">
<input <div className="admin-form-group">
type="number" <label className="admin-form-label">Příchod - datum</label>
inputMode="decimal" <AdminDatePicker
value={form.leave_hours} mode="date"
onChange={(e) => value={form.arrival_date}
updateField("leave_hours", parseFloat(e.target.value)) onChange={(val) => updateField("arrival_date", val)}
} />
min="0.5" </div>
max="24" <div className="admin-form-group">
step="0.5" <label className="admin-form-label">Příchod - čas</label>
className="admin-form-input" <AdminDatePicker
/> mode="time"
{isCreate && ( value={form.arrival_time}
<small className="admin-form-hint"> onChange={(val) => updateField("arrival_time", val)}
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>
</div> </div>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-form-row">
<button <div className="admin-form-group">
type="button" <label className="admin-form-label">
onClick={onClose} Začátek pauzy - datum
className="admin-btn admin-btn-secondary" </label>
> <AdminDatePicker
Zrušit mode="date"
</button> value={form.break_start_date}
<button onChange={(val) => updateField("break_start_date", val)}
type="button" />
onClick={onSubmit} </div>
className="admin-btn admin-btn-primary" <div className="admin-form-group">
> <label className="admin-form-label">Začátek pauzy - čas</label>
Uložit <AdminDatePicker
</button> mode="time"
value={form.break_start_time}
onChange={(val) => updateField("break_start_time", val)}
/>
</div>
</div> </div>
</motion.div>
</motion.div> <div className="admin-form-row">
)} <div className="admin-form-group">
</AnimatePresence> <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>
); );
} }

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,482 +259,367 @@ 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-form">
<div <div className="admin-form-row">
className="admin-modal-backdrop" <div className="admin-form-group">
onClick={() => setShowModal(false)} <label className="admin-form-label">Jméno</label>
/> <input
<motion.div type="text"
className="admin-modal" value={formData.first_name}
initial={{ opacity: 0, scale: 0.95, y: 20 }} onChange={(e) =>
animate={{ opacity: 1, scale: 1, y: 0 }} setFormData({
exit={{ opacity: 0, scale: 0.95, y: 20 }} ...formData,
transition={{ duration: 0.2 }} first_name: e.target.value,
> })
<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);
} }
}} 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 </div>
className="admin-modal" <div className="admin-form-group">
initial={{ opacity: 0, scale: 0.95, y: 20 }} <label className="admin-form-label">E-mail</label>
animate={{ opacity: 1, scale: 1, y: 0 }} <input
exit={{ opacity: 0, scale: 0.95, y: 20 }} type="email"
transition={{ duration: 0.2 }} value={formData.email}
> onChange={(e) =>
<div className="admin-modal-header"> setFormData({ ...formData, email: e.target.value })
<h2 className="admin-modal-title"> }
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"} required
</h2> className="admin-form-input"
</div> />
<div className="admin-modal-body"> </div>
{backupCodes ? ( <div className="admin-form-group">
<div> <label className="admin-form-label">Aktuální heslo</label>
<div className="admin-role-locked-notice mb-4"> <input
<svg type="password"
width="16" value={formData.current_password}
height="16" onChange={(e) =>
viewBox="0 0 24 24" setFormData({
fill="none" ...formData,
stroke="currentColor" current_password: e.target.value,
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" /> className="admin-form-input"
<line x1="12" y1="9" x2="12" y2="13" /> placeholder="Pro změnu hesla, zadejte aktuální heslo"
<line x1="12" y1="17" x2="12.01" y2="17" /> />
</svg> </div>
Uložte si tyto kódy na bezpečné místo. Každý kód lze <div className="admin-form-group">
použít pouze jednou. Po zavření tohoto okna je již <label className="admin-form-label">Nové heslo</label>
neuvidíte. <input
</div> type="password"
<div value={formData.new_password}
style={{ onChange={(e) =>
display: "grid", setFormData({
gridTemplateColumns: "repeat(2, 1fr)", ...formData,
gap: "0.5rem", new_password: e.target.value,
padding: "1rem", })
background: "var(--bg-secondary)", }
borderRadius: "0.5rem", className="admin-form-input"
fontFamily: "monospace", placeholder="Pro změnu hesla, zadejte nové heslo"
fontSize: "1rem", />
}} </div>
> </div>
{backupCodes.map((code) => ( </FormModal>
<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>
{/* 2FA Disable Modal */} {/* 2FA Setup Modal — multi-step: setup → backup codes */}
<AnimatePresence> <FormModal
{show2FADisable && ( 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 }} setShow2FASetup(false);
exit={{ opacity: 0 }} setTotpCode("");
transition={{ duration: 0.2 }} }}
> 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 <div
className="admin-modal-backdrop" style={{
onClick={() => setShow2FADisable(false)} display: "grid",
/> gridTemplateColumns: "repeat(2, 1fr)",
<motion.div gap: "0.5rem",
className="admin-modal" padding: "1rem",
initial={{ opacity: 0, scale: 0.95, y: 20 }} background: "var(--bg-secondary)",
animate={{ opacity: 1, scale: 1, y: 0 }} borderRadius: "0.5rem",
exit={{ opacity: 0, scale: 0.95, y: 20 }} fontFamily: "monospace",
transition={{ duration: 0.2 }} fontSize: "1rem",
}}
> >
<div className="admin-modal-header"> {backupCodes.map((code) => (
<h2 className="admin-modal-title">Deaktivovat 2FA</h2> <div
</div> key={code}
<div className="admin-modal-body">
<p
style={{ style={{
color: "var(--text-secondary)", padding: "0.25rem 0.5rem",
fontSize: "0.875rem", textAlign: "center",
marginBottom: "1rem", color: "var(--text-primary)",
}} }}
> >
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z {code}
autentizační aplikace. </div>
</p> ))}
<div className="admin-form-group"> </div>
<label className="admin-form-label">Ověřovací kód</label> <div style={{ marginTop: "0.75rem" }}>
<input <button
type="text" onClick={() => {
inputMode="numeric" navigator.clipboard?.writeText(backupCodes.join("\n"));
pattern="[0-9]*" alert.success("Kódy zkopírovány");
maxLength={6} }}
value={disableCode} className="admin-btn admin-btn-secondary admin-btn-sm"
onChange={(e) => >
setDisableCode(e.target.value.replace(/\D/g, "")) <svg
} width="14"
placeholder="000000" height="14"
className="admin-form-input" viewBox="0 0 24 24"
style={{ fill="none"
textAlign: "center", stroke="currentColor"
fontSize: "1.25rem", strokeWidth="2"
letterSpacing: "0.4rem", >
fontFamily: "monospace", <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) => { className="admin-btn-icon"
if (e.key === "Enter" && disableCode.length === 6) { title="Kopírovat"
onDisable2FA(); aria-label="Kopírovat"
} style={{ flexShrink: 0 }}
}} >
autoFocus <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> </div>
<div className="admin-modal-footer"> )}
<button <div className="admin-form-group">
onClick={() => setShow2FADisable(false)} <label className="admin-form-label">
className="admin-btn admin-btn-secondary" Ověřovací kód z aplikace
disabled={totpSubmitting} </label>
> <input
Zrušit ref={totpSetupRef}
</button> type="text"
<button inputMode="numeric"
onClick={onDisable2FA} pattern="[0-9]*"
className="admin-btn admin-btn-primary" maxLength={6}
disabled={totpSubmitting || disableCode.length !== 6} value={totpCode}
> onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
{totpSubmitting ? "Deaktivuji..." : "Deaktivovat 2FA"} placeholder="000000"
</button> className="admin-form-input"
</div> style={{
</motion.div> textAlign: "center",
</motion.div> 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>
</> </>
); );
} }

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,273 +314,222 @@ 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-form">
<div className="admin-form-row">
<div <div
className="admin-modal-backdrop" className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
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"> <label className="admin-form-label required">Vozidlo</label>
<h2 className="admin-modal-title">Přidat jízdu</h2> <select
</div> value={tripForm.vehicle_id}
<div className="admin-modal-body"> onChange={(e) => {
<div className="admin-form"> handleTripVehicleChange(e.target.value);
<div className="admin-form-row"> setTripErrors((prev) => ({
<div ...prev,
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`} vehicle_id: undefined,
> }));
<label className="admin-form-label required"> }}
Vozidlo className="admin-form-select"
</label> >
<select <option value="">Vyberte vozidlo</option>
value={tripForm.vehicle_id} {tripVehicles.map((v) => (
onChange={(e) => { <option key={v.id} value={v.id}>
handleTripVehicleChange(e.target.value); {v.spz} - {v.name}
setTripErrors((prev) => ({ </option>
...prev, ))}
vehicle_id: undefined, </select>
})); {tripErrors.vehicle_id && (
}} <span className="admin-form-error">
className="admin-form-select" {tripErrors.vehicle_id}
> </span>
<option value="">Vyberte vozidlo</option> )}
{tripVehicles.map((v) => ( </div>
<option key={v.id} value={v.id}> <div
{v.spz} - {v.name} className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
</option> >
))} <label className="admin-form-label required">Datum jízdy</label>
</select> <AdminDatePicker
{tripErrors.vehicle_id && ( mode="date"
<span className="admin-form-error"> value={tripForm.trip_date}
{tripErrors.vehicle_id} onChange={(val: string) => {
</span> setTripForm((prev) => ({ ...prev, trip_date: val }));
)} setTripErrors((prev) => ({
</div> ...prev,
<div trip_date: undefined,
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`} }));
> }}
<label className="admin-form-label required"> />
Datum jízdy {tripErrors.trip_date && (
</label> <span className="admin-form-error">{tripErrors.trip_date}</span>
<AdminDatePicker )}
mode="date" </div>
value={tripForm.trip_date} </div>
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-row admin-form-row-3">
<div <div
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`} className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">
Počáteční stav km Počáteční stav km
</label> </label>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={tripForm.start_km} value={tripForm.start_km}
onChange={(e) => { onChange={(e) => {
setTripForm((prev) => ({ setTripForm((prev) => ({
...prev, ...prev,
start_km: e.target.value, start_km: e.target.value,
})); }));
setTripErrors((prev) => ({ setTripErrors((prev) => ({
...prev, ...prev,
start_km: undefined, start_km: undefined,
})); }));
}} }}
className="admin-form-input" className="admin-form-input"
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> className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
<div >
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`} <label className="admin-form-label required">
> Konečný stav km
<label className="admin-form-label required"> </label>
Konečný stav km <input
</label> type="number"
<input inputMode="numeric"
type="number" value={tripForm.end_km}
inputMode="numeric" onChange={(e) => {
value={tripForm.end_km} setTripForm((prev) => ({
onChange={(e) => { ...prev,
setTripForm((prev) => ({ end_km: e.target.value,
...prev, }));
end_km: e.target.value, setTripErrors((prev) => ({
})); ...prev,
setTripErrors((prev) => ({ end_km: undefined,
...prev, }));
end_km: undefined, }}
})); className="admin-form-input"
}} min="0"
className="admin-form-input" />
min="0" {tripErrors.end_km && (
/> <span className="admin-form-error">{tripErrors.end_km}</span>
{tripErrors.end_km && ( )}
<span className="admin-form-error"> </div>
{tripErrors.end_km} <div className="admin-form-group">
</span> <label className="admin-form-label">Vzdálenost</label>
)} <input
</div> type="text"
<div className="admin-form-group"> value={`${formatKm(tripDistance())} km`}
<label className="admin-form-label">Vzdálenost</label> className="admin-form-input"
<input readOnly
type="text" disabled
value={`${formatKm(tripDistance())} km`} />
className="admin-form-input" </div>
readOnly </div>
disabled
/>
</div>
</div>
<div className="admin-form-row"> <div className="admin-form-row">
<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 <input
</label> type="text"
<input value={tripForm.route_from}
type="text" onChange={(e) => {
value={tripForm.route_from} setTripForm((prev) => ({
onChange={(e) => { ...prev,
setTripForm((prev) => ({ route_from: e.target.value,
...prev, }));
route_from: e.target.value, setTripErrors((prev) => ({
})); ...prev,
setTripErrors((prev) => ({ route_from: undefined,
...prev, }));
route_from: undefined, }}
})); className="admin-form-input"
}} placeholder="Např. Praha"
className="admin-form-input" />
placeholder="Např. Praha" {tripErrors.route_from && (
/> <span className="admin-form-error">
{tripErrors.route_from && ( {tripErrors.route_from}
<span className="admin-form-error"> </span>
{tripErrors.route_from} )}
</span> </div>
)} <div
</div> className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
<div >
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`} <label className="admin-form-label required">
> Místo příjezdu
<label className="admin-form-label required"> </label>
Místo příjezdu <input
</label> type="text"
<input value={tripForm.route_to}
type="text" onChange={(e) => {
value={tripForm.route_to} setTripForm((prev) => ({
onChange={(e) => { ...prev,
setTripForm((prev) => ({ route_to: e.target.value,
...prev, }));
route_to: e.target.value, setTripErrors((prev) => ({
})); ...prev,
setTripErrors((prev) => ({ route_to: undefined,
...prev, }));
route_to: undefined, }}
})); className="admin-form-input"
}} placeholder="Např. Brno"
className="admin-form-input" />
placeholder="Např. Brno" {tripErrors.route_to && (
/> <span className="admin-form-error">{tripErrors.route_to}</span>
{tripErrors.route_to && ( )}
<span className="admin-form-error"> </div>
{tripErrors.route_to} </div>
</span>
)}
</div>
</div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label> <label className="admin-form-label">Typ jízdy</label>
<select <select
value={tripForm.is_business} value={tripForm.is_business}
onChange={(e) => onChange={(e) =>
setTripForm((prev) => ({ setTripForm((prev) => ({
...prev, ...prev,
is_business: parseInt(e.target.value), is_business: parseInt(e.target.value),
})) }))
} }
className="admin-form-select" className="admin-form-select"
> >
<option value={1}>Služební</option> <option value={1}>Služební</option>
<option value={0}>Soukromá</option> <option value={0}>Soukromá</option>
</select> </select>
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Poznámky</label> <label className="admin-form-label">Poznámky</label>
<textarea <textarea
value={tripForm.notes} value={tripForm.notes}
onChange={(e) => onChange={(e) =>
setTripForm((prev) => ({ setTripForm((prev) => ({
...prev, ...prev,
notes: e.target.value, notes: e.target.value,
})) }))
} }
className="admin-form-textarea" className="admin-form-textarea"
rows={2} rows={2}
placeholder="Volitelné poznámky..." placeholder="Volitelné poznámky..."
/> />
</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,163 +929,127 @@ 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
> )
<div return;
className="admin-modal-backdrop" handleRequestSubmit();
onClick={() => setIsLeaveModalOpen(false)} }}
/> submitLabel="Odeslat žádost"
<motion.div submitDisabled={
className="admin-modal" calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
initial={{ opacity: 0, scale: 0.95, y: 20 }} }
animate={{ opacity: 1, scale: 1, y: 0 }} loading={requestSubmitting}
exit={{ opacity: 0, scale: 0.95, y: 20 }} >
transition={{ duration: 0.2 }} <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"> <option value="vacation">Dovolená</option>
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2> <option value="sick">Nemoc</option>
</div> <option value="unpaid">Neplacené volno</option>
</select>
</FormField>
<div className="admin-modal-body"> <div
<div className="admin-form"> style={{
<FormField label="Typ nepřítomnosti"> display: "grid",
<select gridTemplateColumns: "1fr 1fr",
value={leaveForm.leave_type} gap: "1rem",
onChange={(e) => }}
setLeaveForm({ >
...leaveForm, <FormField label="Od">
leave_type: e.target.value, <AdminDatePicker
}) mode="date"
} value={leaveForm.date_from}
className="admin-form-select" onChange={(val: string) => {
> setLeaveForm((prev) => ({
<option value="vacation">Dovolená</option> ...prev,
<option value="sick">Nemoc</option> date_from: val,
<option value="unpaid">Neplacené volno</option> date_to: prev.date_to < val ? val : prev.date_to,
</select> }));
</FormField> }}
/>
</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 {leaveForm.date_from && leaveForm.date_to && (
style={{ <div className="admin-form-group">
display: "grid", <div
gridTemplateColumns: "1fr 1fr", style={{
gap: "1rem", display: "flex",
}} gap: "1.5rem",
> padding: "0.75rem 1rem",
<FormField label="Od"> background: "var(--bg-tertiary)",
<AdminDatePicker borderRadius: "var(--border-radius)",
mode="date" fontSize: "0.875rem",
value={leaveForm.date_from} }}
onChange={(val: string) => { >
setLeaveForm((prev) => ({ <span>
...prev, <strong>
date_from: val, {calculateBusinessDays(
date_to: prev.date_to < val ? val : prev.date_to, leaveForm.date_from,
})); leaveForm.date_to,
}} )}
/> </strong>{" "}
</FormField> {czechPlural(
<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 ||
calculateBusinessDays( calculateBusinessDays(
leaveForm.date_from, leaveForm.date_from,
leaveForm.date_to, leaveForm.date_to,
) === 0 ),
} "pracovní den",
className="admin-btn admin-btn-primary" "pracovní dny",
> "pracovních dnů",
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"} )}
</button> </span>
<span className="text-muted">
{calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) * 8}{" "}
hodin
</span>
</div> </div>
</motion.div> </div>
</motion.div> )}
)}
</AnimatePresence> <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 <ConfirmModal
isOpen={gpsConfirm.isOpen} isOpen={gpsConfirm.isOpen}

View File

@@ -81,10 +81,18 @@ export default function ProjectDetail() {
const notes: ProjectNote[] = project?.project_notes || []; const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view")); const { data: usersData } = useQuery(userListOptions("projects.view"));
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({ // Only active users with projects.view — but keep the project's CURRENT
id: u.id, // assignee in the list even if they're now inactive, so the select still
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username, // reflects the saved value (marked "(neaktivní)" so it's clear).
})); const assignedUserId = project?.responsible_user_id || "";
const users: User[] = (usersData ?? [])
.filter((u: ApiUser) => u.is_active || String(u.id) === assignedUserId)
.map((u: ApiUser) => ({
id: u.id,
name:
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
(u.is_active ? "" : " (neaktivní)"),
}));
// Reset form sync when navigating to a different project // Reset form sync when navigating to a different project
const formInitialized = useRef(false); const formInitialized = useRef(false);

View File

@@ -98,7 +98,12 @@ export default function Projects() {
...offerCustomersOptions(), ...offerCustomersOptions(),
enabled: showCreate, enabled: showCreate,
}); });
const usersQuery = useQuery({ ...userListOptions(), enabled: showCreate }); // Responsible-person picker: only users whose role can view projects
// (projects.view, filtered server-side) and who are active.
const usersQuery = useQuery({
...userListOptions("projects.view"),
enabled: showCreate,
});
const nextNumberQuery = useQuery({ const nextNumberQuery = useQuery({
...projectNextNumberOptions(), ...projectNextNumberOptions(),
enabled: showCreate, enabled: showCreate,
@@ -512,11 +517,13 @@ export default function Projects() {
className="admin-form-input" className="admin-form-input"
> >
<option value="">— nepřiřazeno —</option> <option value="">— nepřiřazeno —</option>
{(usersQuery.data ?? []).map((u) => ( {(usersQuery.data ?? [])
<option key={u.id} value={u.id}> .filter((u) => u.is_active)
{u.first_name} {u.last_name} .map((u) => (
</option> <option key={u.id} value={u.id}>
))} {u.first_name} {u.last_name}
</option>
))}
</select> </select>
</FormField> </FormField>
</div> </div>