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",
"version": "1.9.3",
"version": "1.9.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "1.9.3",
"version": "1.9.4",
"license": "ISC",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

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

View File

@@ -255,12 +255,23 @@
gap: 12px;
}
.admin-modal-heading {
min-width: 0;
}
.admin-modal-title {
font-size: 16px;
font-weight: 700;
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 {
flex-shrink: 0;
display: inline-flex;

View File

@@ -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>
);
}

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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}

View File

@@ -81,10 +81,18 @@ export default function ProjectDetail() {
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
}));
// Only active users with projects.view — but keep the project's CURRENT
// assignee in the list even if they're now inactive, so the select still
// 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
const formInitialized = useRef(false);

View File

@@ -98,7 +98,12 @@ export default function Projects() {
...offerCustomersOptions(),
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({
...projectNextNumberOptions(),
enabled: showCreate,
@@ -512,11 +517,13 @@ export default function Projects() {
className="admin-form-input"
>
<option value="">— nepřiřazeno —</option>
{(usersQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.first_name} {u.last_name}
</option>
))}
{(usersQuery.data ?? [])
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.first_name} {u.last_name}
</option>
))}
</select>
</FormField>
</div>