Compare commits

...

14 Commits

Author SHA1 Message Date
BOHA
596d21712a chore(release): v1.9.8 — compact mobile filter bars + AdminDatePicker everywhere
Combines the undeployed v1.9.7 (compact 2-up filter bars) with the date-field
unification (all date fields via AdminDatePicker + mode-aware placeholder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:58:11 +02:00
BOHA
b504d9d0b2 fix(dates): use AdminDatePicker everywhere + mode-aware default placeholder
- The project create-modal start/end dates used raw <input type="date">;
  converted to <AdminDatePicker> so every date field in the app now goes
  through the shared component (react-datepicker on desktop, native on touch).
- AdminDatePicker now defaults its placeholder by mode (date 'dd.mm.rrrr',
  month 'mm.rrrr', time 'hh:mm') so empty date fields always show a format
  hint; explicit placeholders (e.g. 'Od'/'Do' range filters) still override.
- Dropped WarehouseReceiptForm's ad-hoc 'dd.mm.yyyy' so it inherits the
  unified Czech default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:57:52 +02:00
BOHA
e082a6ef06 chore(release): v1.9.7 — compact mobile filter bars
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:11:59 +02:00
BOHA
1662453d9c fix(mobile): compact 2-up filter bars instead of full-width column
.admin-search-bar on phones (<=768px) was flex-direction:column with each
control forced full-width (min-width:100% at <=480), so multi-filter bars
(warehouse receipts/issues, sales lists) became a tall stack of huge controls.
Now: the search box spans the row; filter selects/date pickers sit two-per-row;
the reset button spans the row. 44px/16px touch sizing unchanged; applies
consistently to every page using .admin-search-bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:11:46 +02:00
BOHA
9dd2e07f19 chore(release): v1.9.6 — mobile responsiveness (Phase 1+2 + tabs/comboboxes)
Supersedes the undeployed v1.9.5: adds the mobile tab-bar scroll fix and
combobox touch-target fixes on top of the Phase 1+2 responsive rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:54:51 +02:00
BOHA
18064a972c fix(mobile): tab bars scroll on phones + combobox 44px touch targets
- .admin-tabs: horizontal-scroll + 44px tabs at <=640px so 4-tab bars
  (WarehouseReports, Invoices/Offers status filters, OffersTemplates) no longer
  overflow off-screen.
- Customer searchable dropdown + warehouse ItemPicker: option rows >=44px,
  clear button enlarged, list clamped to viewport width at <=640px.
Native selects/toggles were already mobile-safe (44px at <=768px). Mobile-only; desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:54:36 +02:00
BOHA
996835dae5 chore(release): v1.9.5 — mobile responsiveness (Phase 1+2)
Global small-phone rules (form-row stacking, KPI collapse, table-action touch
targets, datepicker/filemanager mobile) + area fixes (Quill picker dropdowns,
invoice month-nav, attendance map/clock-card). Mobile-only; desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:45:47 +02:00
BOHA
c73b980ce7 feat(mobile): responsive Phase 1+2 — global small-phone rules + area fixes
All changes are mobile-only (max-width media queries); desktop unchanged.

Global (systemic):
- forms.css: all .admin-form-row* grids stack to 1 column at <=480px (fixes
  detail-page info grids, settings forms, modal forms staying 2-up on phones).
- components.css: .admin-kpi-4/-3 stat grids collapse to 1 column at <=480px.
- tables.css: row-action icons get 44px touch targets at <=768px.
- datepicker.css: constrain the date-picker popper to the viewport at <=480px.
- filemanager.css: toolbar wraps, new-folder input full-width, breadcrumb scrolls.

Area specifics:
- offers.css: Quill picker dropdowns kept on-screen (max-width) at <=480px.
- invoices.css: month-nav wraps + 44px touch targets at <=640px.
- attendance.css: Leaflet map height scales with viewport; clock-card padding
  tightened at <=480px.

Audit confirmed all data tables (warehouse incl.) are already wrapped in the
scroll container, so no table overflow/markup changes were needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:44:53 +02:00
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
22 changed files with 1491 additions and 1659 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "1.9.3",
"version": "1.9.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "1.9.3",
"version": "1.9.8",
"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.8",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -434,3 +434,22 @@
color: var(--text-muted);
font-family: monospace;
}
/* ============================================================================
Mobile (responsive)
============================================================================ */
/* The Leaflet map at a fixed 400px dominates a phone viewport — let it scale
with the viewport height (with sensible min/max) on small screens. */
@media (max-width: 640px) {
.attendance-location-map {
height: clamp(220px, 45vh, 400px);
}
}
/* The clock card's 2rem padding crushes content at ~360px — tighten it. */
@media (max-width: 480px) {
.attendance-clock-card {
padding: 1rem;
}
}

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;
@@ -599,6 +610,25 @@
0 0 0 1px var(--border-color);
}
@media (max-width: 640px) {
.admin-tabs {
display: flex;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.admin-tabs::-webkit-scrollbar {
display: none;
}
.admin-tab {
flex: 0 0 auto;
min-height: 44px;
}
}
/* ============================================================================
Empty State
============================================================================ */
@@ -834,7 +864,9 @@
}
@media (max-width: 480px) {
.admin-kpi-grid {
.admin-kpi-grid,
.admin-kpi-4,
.admin-kpi-3 {
grid-template-columns: 1fr;
}
}
@@ -1062,37 +1094,44 @@
}
@media (max-width: 768px) {
/* Compact 2-up filter grid instead of a tall full-width column:
the search box spans the row; the filter selects / date pickers sit
two-per-row; the reset button spans the row. Touch sizing (44px height +
16px font from forms.css) is unchanged — this just halves the vertical
footprint of multi-filter bars on phones. */
.admin-search-bar {
flex-direction: column;
align-items: stretch;
}
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
max-width: 100%;
}
.admin-search-bar .react-datepicker-wrapper {
max-width: 100%;
/* Search box spans the full row (handles both the .admin-search wrapper
and a bare text input used directly as the search field). */
.admin-search-bar > .admin-search,
.admin-search-bar > .admin-form-input:not([type="date"]) {
flex: 1 1 100%;
max-width: none;
}
.admin-search {
max-width: 100%;
min-width: 0;
max-width: none;
}
/* Filters (status/supplier selects, native date inputs, datepicker
wrappers): two per row. */
.admin-search-bar > .admin-form-select,
.admin-search-bar > .admin-form-input[type="date"],
.admin-search-bar > .react-datepicker-wrapper {
flex: 1 1 calc(50% - 0.25rem);
min-width: 0;
max-width: none;
}
/* Action buttons (e.g. "Zrušit filtry") span the full row. */
.admin-search-bar > .admin-btn {
flex: 1 1 100%;
}
}
@media (max-width: 480px) {
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
min-width: 100%;
}
.admin-search-bar .react-datepicker-wrapper {
min-width: 100%;
}
}
/* ============================================================================
Item Picker (Warehouse)
============================================================================ */
@@ -1172,3 +1211,14 @@
color: var(--text-secondary);
white-space: nowrap;
}
@media (max-width: 640px) {
.admin-item-picker-list {
max-width: calc(100vw - 24px);
overflow-y: auto;
}
.admin-item-picker-item {
min-height: 44px;
}
}

View File

@@ -58,6 +58,7 @@ interface NativeInputProps {
minDate?: string;
maxDate?: string;
disabled?: boolean;
placeholder?: string;
}
const modeToInputType: Record<string, string> = {
@@ -73,6 +74,7 @@ function NativeInput({
minDate,
maxDate,
disabled,
placeholder,
}: NativeInputProps) {
const type = modeToInputType[mode] || "date";
// For time inputs, min/max must be in HH:mm format, not date format
@@ -95,6 +97,7 @@ function NativeInput({
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="admin-form-input"
placeholder={placeholder}
required={required}
disabled={disabled}
min={minProp}
@@ -126,6 +129,12 @@ export default function AdminDatePicker({
}: AdminDatePickerProps) {
const useNative = isTouchDevice();
// Mode-aware default placeholder so empty date fields always show a format
// hint (callers can still override, e.g. "Od"/"Do" on range filters).
const effectivePlaceholder =
placeholder ??
(mode === "time" ? "hh:mm" : mode === "month" ? "mm.rrrr" : "dd.mm.rrrr");
if (useNative) {
return (
<NativeInput
@@ -136,6 +145,7 @@ export default function AdminDatePicker({
minDate={minDate}
maxDate={maxDate}
disabled={disabled}
placeholder={effectivePlaceholder}
/>
);
}
@@ -181,7 +191,7 @@ export default function AdminDatePicker({
const customInput = (
<CustomInput
required={required}
placeholder={placeholder}
placeholder={effectivePlaceholder}
disabled={disabled}
/>
);
@@ -191,7 +201,7 @@ export default function AdminDatePicker({
onChange: handleChange,
locale: "cs",
customInput,
placeholderText: placeholder,
placeholderText: effectivePlaceholder,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const,

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,52 +38,29 @@ 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 }}
<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}
>
<div
className="admin-modal-backdrop"
onClick={() => !submitting && onClose()}
/>
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-attendance-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<p
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čí.
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
Existující záznamy se přeskočí.
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Měsíc</label>
@@ -152,9 +128,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) =>
setForm({ ...form, arrival_time: val })
}
onChange={(val) => setForm({ ...form, arrival_time: val })}
/>
</div>
<div className="admin-form-group">
@@ -162,9 +136,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) =>
setForm({ ...form, departure_time: val })
}
onChange={(val) => setForm({ ...form, departure_time: val })}
/>
</div>
</div>
@@ -175,9 +147,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
onChange={(val) => setForm({ ...form, break_start_time: val })}
/>
</div>
<div className="admin-form-group">
@@ -185,36 +155,11 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) =>
setForm({ ...form, break_end_time: val })
}
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>
</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">
<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,38 +103,14 @@ 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 }}
<FormModal
isOpen={isOpen}
onClose={onClose}
title={`Potvrzení objednávky ${orderNumber}`}
size={step === "edit" ? "lg" : "md"}
loading={loading}
hideFooter
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
}
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{step === "choose" ? (
<div className="admin-form">
<div className="admin-form-group">
@@ -195,10 +171,7 @@ export default function OrderConfirmationModal({
<div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label>
<p
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
<p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
Jak chcete připravit potvrzení objednávky?
</p>
<button
@@ -227,6 +200,17 @@ export default function OrderConfirmationModal({
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">
@@ -261,11 +245,7 @@ export default function OrderConfirmationModal({
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
updateItem(i, "quantity", Number(e.target.value) || 0)
}
className="admin-form-input"
style={{ width: "80px" }}
@@ -276,9 +256,7 @@ export default function OrderConfirmationModal({
<input
type="text"
value={item.unit}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
onChange={(e) => updateItem(i, "unit", e.target.value)}
className="admin-form-input"
style={{ width: "60px" }}
/>
@@ -304,11 +282,7 @@ export default function OrderConfirmationModal({
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
updateItem(i, "vat_rate", Number(e.target.value) || 0)
}
className="admin-form-input"
style={{ width: "70px" }}
@@ -345,13 +319,8 @@ export default function OrderConfirmationModal({
>
+ Přidat položku
</button>
</div>
)}
</div>
<div className="admin-modal-footer">
{step === "edit" && (
<>
<button
type="button"
onClick={() => setStep("choose")}
@@ -375,22 +344,9 @@ export default function OrderConfirmationModal({
"Vygenerovat PDF"
)}
</button>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div>
</motion.div>
</motion.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,51 +245,25 @@ 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 }}
<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-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<div className="admin-form">
{isCreate ? (
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label required">
Zaměstnanec
</label>
<label className="admin-form-label required">Zaměstnanec</label>
<select
value={form.user_id}
onChange={(e) => updateField("user_id", e.target.value)}
@@ -306,9 +278,7 @@ export default function ShiftFormModal({
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">
Datum směny
</label>
<label className="admin-form-label required">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
@@ -357,9 +327,7 @@ export default function ShiftFormModal({
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">
8 hodin = celý den
</small>
<small className="admin-form-hint">8 hodin = celý den</small>
)}
</div>
)}
@@ -368,9 +336,7 @@ export default function ShiftFormModal({
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - datum
</label>
<label className="admin-form-label">Příchod - datum</label>
<AdminDatePicker
mode="date"
value={form.arrival_date}
@@ -378,9 +344,7 @@ export default function ShiftFormModal({
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - čas
</label>
<label className="admin-form-label">Příchod - čas</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
@@ -397,30 +361,22 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val) =>
updateField("break_start_date", val)
}
onChange={(val) => updateField("break_start_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - čas
</label>
<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)
}
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>
<label className="admin-form-label">Konec pauzy - datum</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
@@ -428,9 +384,7 @@ export default function ShiftFormModal({
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - čas
</label>
<label className="admin-form-label">Konec pauzy - čas</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
@@ -441,9 +395,7 @@ export default function ShiftFormModal({
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - datum
</label>
<label className="admin-form-label">Odchod - datum</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
@@ -496,27 +448,6 @@ export default function ShiftFormModal({
/>
</div>
</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>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</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,30 +259,14 @@ 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 }}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Upravit profil"
size="md"
onSubmit={handleSubmit}
submitLabel="Uložit změny"
>
<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">
@@ -319,9 +301,7 @@ export default function DashProfile({
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Uživatelské jméno
</label>
<label className="admin-form-label">Uživatelské jméno</label>
<input
type="text"
value={formData.username}
@@ -360,9 +340,7 @@ export default function DashProfile({
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Nové heslo
</label>
<label className="admin-form-label">Nové heslo</label>
<input
type="password"
value={formData.new_password}
@@ -377,59 +355,41 @@ export default function DashProfile({
/>
</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>
</FormModal>
{/* 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) {
{/* 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("");
}}
/>
<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 }}
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}
>
<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">
@@ -445,9 +405,8 @@ export default function DashProfile({
<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.
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={{
@@ -477,9 +436,7 @@ export default function DashProfile({
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={() => {
navigator.clipboard?.writeText(
backupCodes.join("\n"),
);
navigator.clipboard?.writeText(backupCodes.join("\n"));
alert.success("Kódy zkopírovány");
}}
className="admin-btn admin-btn-secondary admin-btn-sm"
@@ -492,19 +449,25 @@ export default function DashProfile({
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="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>
@@ -512,13 +475,11 @@ export default function DashProfile({
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
Naskenujte QR kód v autentizační aplikaci (Google
Authenticator, Authy, Microsoft Authenticator apod.)
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<div
style={{ textAlign: "center", marginBottom: "1rem" }}
>
<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"
@@ -573,14 +534,7 @@ export default function DashProfile({
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="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>
@@ -598,9 +552,7 @@ export default function DashProfile({
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/\D/g, ""))
}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
@@ -618,67 +570,20 @@ export default function DashProfile({
</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>
</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 }}
<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"
>
<div
className="admin-modal-backdrop"
onClick={() => setShow2FADisable(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Deaktivovat 2FA</h2>
</div>
<div className="admin-modal-body">
<p
style={{
color: "var(--text-secondary)",
@@ -697,9 +602,7 @@ export default function DashProfile({
pattern="[0-9]*"
maxLength={6}
value={disableCode}
onChange={(e) =>
setDisableCode(e.target.value.replace(/\D/g, ""))
}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
@@ -716,27 +619,7 @@ export default function DashProfile({
autoFocus
/>
</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>
)}
</AnimatePresence>
</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,38 +314,21 @@ 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-modal-backdrop"
onClick={() => setShowTripModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Vozidlo
</label>
<label className="admin-form-label required">Vozidlo</label>
<select
value={tripForm.vehicle_id}
onChange={(e) => {
@@ -375,9 +356,7 @@ export default function DashQuickActions({
<div
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Datum jízdy
</label>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={tripForm.trip_date}
@@ -390,9 +369,7 @@ export default function DashQuickActions({
}}
/>
{tripErrors.trip_date && (
<span className="admin-form-error">
{tripErrors.trip_date}
</span>
<span className="admin-form-error">{tripErrors.trip_date}</span>
)}
</div>
</div>
@@ -422,9 +399,7 @@ export default function DashQuickActions({
min="0"
/>
{tripErrors.start_km && (
<span className="admin-form-error">
{tripErrors.start_km}
</span>
<span className="admin-form-error">{tripErrors.start_km}</span>
)}
</div>
<div
@@ -451,9 +426,7 @@ export default function DashQuickActions({
min="0"
/>
{tripErrors.end_km && (
<span className="admin-form-error">
{tripErrors.end_km}
</span>
<span className="admin-form-error">{tripErrors.end_km}</span>
)}
</div>
<div className="admin-form-group">
@@ -472,9 +445,7 @@ export default function DashQuickActions({
<div
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Místo odjezdu
</label>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
value={tripForm.route_from}
@@ -520,9 +491,7 @@ export default function DashQuickActions({
placeholder="Např. Brno"
/>
{tripErrors.route_to && (
<span className="admin-form-error">
{tripErrors.route_to}
</span>
<span className="admin-form-error">{tripErrors.route_to}</span>
)}
</div>
</div>
@@ -560,29 +529,7 @@ export default function DashQuickActions({
/>
</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>
</FormModal>
</>
);
}

View File

@@ -197,3 +197,15 @@
.react-datepicker__close-icon::after {
background-color: var(--accent-color) !important;
}
/* Mobile safety net — keep the popper inside the viewport on small phones.
(Native date input is used on touch; this guards the rare desktop-style popper.) */
@media (max-width: 480px) {
.react-datepicker-popper {
max-width: calc(100vw - 24px);
}
.react-datepicker {
font-size: 0.8rem !important;
}
}

View File

@@ -169,3 +169,31 @@
color: var(--text-tertiary);
cursor: help;
}
/* ============================================================================
File Manager — mobile
============================================================================ */
@media (max-width: 640px) {
/* Toolbar wraps so actions drop below the path instead of overflowing */
.fm-toolbar {
flex-wrap: wrap;
}
/* New-folder input goes full width (override the 250px desktop cap) */
.fm-new-folder .admin-form-input {
max-width: none;
width: 100%;
}
/* Breadcrumb scrolls horizontally instead of overflowing the container */
.fm-breadcrumb {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.fm-breadcrumb-segment {
flex-shrink: 0;
}
}

View File

@@ -310,6 +310,8 @@
}
@media (max-width: 480px) {
.admin-form-row,
.admin-form-row-3,
.admin-form-row-4,
.admin-form-row-5 {
grid-template-columns: 1fr;
@@ -486,3 +488,23 @@
color: var(--text-tertiary);
font-size: 0.8125rem;
}
@media (max-width: 640px) {
.admin-customer-dropdown {
max-width: calc(100vw - 24px);
overflow-y: auto;
}
.admin-customer-dropdown-item {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 44px;
}
.admin-customer-selected .admin-btn-icon {
min-width: 44px;
min-height: 44px;
margin-right: -10px;
}
}

View File

@@ -127,9 +127,17 @@
}
@media (max-width: 640px) {
/* Month-nav row may wrap so it never forces horizontal page overflow */
.invoice-month-nav {
flex-wrap: wrap;
}
/* 44px touch target for the prev/next month buttons */
.invoice-month-btn {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
}
.received-upload-row {

View File

@@ -464,6 +464,26 @@
display: none;
}
/* Keep Quill picker dropdowns on-screen on narrow phones.
The font picker hard-codes min-width: 11rem and the color picker a fixed
176px width — both overflow a ~360px viewport. */
@media (max-width: 480px) {
.admin-rich-editor .ql-snow .ql-picker-options,
.admin-rich-editor .ql-snow .ql-font .ql-picker-options,
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
min-width: 0;
max-width: calc(100vw - 32px);
}
.admin-rich-editor
.ql-snow
.ql-color-picker
.ql-picker-options[aria-hidden="false"] {
width: auto;
max-width: calc(100vw - 32px);
}
}
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;

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,31 +929,24 @@ 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 }}
<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-modal-backdrop"
onClick={() => setIsLeaveModalOpen(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Typ nepřítomnosti">
<select
@@ -1058,36 +1049,7 @@ export default function Attendance() {
/>
</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(
leaveForm.date_from,
leaveForm.date_to,
) === 0
}
className="admin-btn admin-btn-primary"
>
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
<ConfirmModal
isOpen={gpsConfirm.isOpen}

View File

@@ -81,9 +81,17 @@ export default function ProjectDetail() {
const notes: ProjectNote[] = project?.project_notes || [];
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
// 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,
name:
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
(u.is_active ? "" : " (neaktivní)"),
}));
// Reset form sync when navigating to a different project

View File

@@ -8,6 +8,7 @@ import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import { formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
@@ -98,7 +99,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,7 +518,9 @@ export default function Projects() {
className="admin-form-input"
>
<option value="">— nepřiřazeno —</option>
{(usersQuery.data ?? []).map((u) => (
{(usersQuery.data ?? [])
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.first_name} {u.last_name}
</option>
@@ -523,23 +531,21 @@ export default function Projects() {
<div className="admin-form-row">
<FormField label="Začátek">
<input
type="date"
<AdminDatePicker
mode="date"
value={createForm.start_date}
onChange={(e) =>
setCreateForm({ ...createForm, start_date: e.target.value })
onChange={(val) =>
setCreateForm({ ...createForm, start_date: val })
}
className="admin-form-input"
/>
</FormField>
<FormField label="Konec">
<input
type="date"
<AdminDatePicker
mode="date"
value={createForm.end_date}
onChange={(e) =>
setCreateForm({ ...createForm, end_date: e.target.value })
onChange={(val) =>
setCreateForm({ ...createForm, end_date: val })
}
className="admin-form-input"
/>
</FormField>
</div>

View File

@@ -378,7 +378,6 @@ export default function WarehouseReceiptForm() {
delivery_note_date: val,
}))
}
placeholder="dd.mm.yyyy"
/>
</FormField>
</div>

View File

@@ -65,6 +65,16 @@
}
}
/* Row actions — enlarge touch targets so icon buttons are tappable */
@media (max-width: 768px) {
.admin-table-actions a,
.admin-table-actions button,
.admin-table-actions .admin-btn-icon {
min-width: 44px;
min-height: 44px;
}
}
.admin-table-user {
display: flex;
align-items: center;