style: run prettier on entire codebase

This commit is contained in:
BOHA
2026-03-24 19:59:14 +01:00
parent 872be42107
commit 3c167cf5c4
148 changed files with 26740 additions and 13990 deletions

View File

@@ -1,48 +1,48 @@
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { AlertProvider } from './context/AlertContext'
import ErrorBoundary from './components/ErrorBoundary'
import AdminLayout from './components/AdminLayout'
import AlertContainer from './components/AlertContainer'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import './admin.css'
import './login.css'
import './dashboard.css'
import './attendance.css'
import './settings.css'
import './offers.css'
import './invoices.css'
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from "./context/AlertContext";
import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from "./components/AdminLayout";
import AlertContainer from "./components/AlertContainer";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import "./admin.css";
import "./login.css";
import "./dashboard.css";
import "./attendance.css";
import "./settings.css";
import "./offers.css";
import "./invoices.css";
const Users = lazy(() => import('./pages/Users'))
const Attendance = lazy(() => import('./pages/Attendance'))
const AttendanceHistory = lazy(() => import('./pages/AttendanceHistory'))
const AttendanceAdmin = lazy(() => import('./pages/AttendanceAdmin'))
const AttendanceBalances = lazy(() => import('./pages/AttendanceBalances'))
const AttendanceCreate = lazy(() => import('./pages/AttendanceCreate'))
const LeaveRequests = lazy(() => import('./pages/LeaveRequests'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
const AttendanceLocation = lazy(() => import('./pages/AttendanceLocation'))
const Trips = lazy(() => import('./pages/Trips'))
const TripsHistory = lazy(() => import('./pages/TripsHistory'))
const TripsAdmin = lazy(() => import('./pages/TripsAdmin'))
const Vehicles = lazy(() => import('./pages/Vehicles'))
const Offers = lazy(() => import('./pages/Offers'))
const OfferDetail = lazy(() => import('./pages/OfferDetail'))
const OffersCustomers = lazy(() => import('./pages/OffersCustomers'))
const OffersTemplates = lazy(() => import('./pages/OffersTemplates'))
const CompanySettings = lazy(() => import('./pages/CompanySettings'))
const Orders = lazy(() => import('./pages/Orders'))
const OrderDetail = lazy(() => import('./pages/OrderDetail'))
const Projects = lazy(() => import('./pages/Projects'))
const ProjectCreate = lazy(() => import('./pages/ProjectCreate'))
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
const Invoices = lazy(() => import('./pages/Invoices'))
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
const Settings = lazy(() => import('./pages/Settings'))
const AuditLog = lazy(() => import('./pages/AuditLog'))
const NotFound = lazy(() => import('./pages/NotFound'))
const Users = lazy(() => import("./pages/Users"));
const Attendance = lazy(() => import("./pages/Attendance"));
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin"));
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances"));
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
const Trips = lazy(() => import("./pages/Trips"));
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
const Vehicles = lazy(() => import("./pages/Vehicles"));
const Offers = lazy(() => import("./pages/Offers"));
const OfferDetail = lazy(() => import("./pages/OfferDetail"));
const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const CompanySettings = lazy(() => import("./pages/CompanySettings"));
const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import("./pages/Projects"));
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
const Settings = lazy(() => import("./pages/Settings"));
const AuditLog = lazy(() => import("./pages/AuditLog"));
const NotFound = lazy(() => import("./pages/NotFound"));
export default function AdminApp() {
return (
@@ -50,20 +50,38 @@ export default function AdminApp() {
<AlertProvider>
<AlertContainer />
<ErrorBoundary>
<Suspense fallback={<div className="admin-loading"><div className="admin-spinner" /></div>}>
<Suspense
fallback={
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<Routes>
<Route path="login" element={<Login />} />
<Route element={<AdminLayout />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
<Route path="attendance/history" element={<AttendanceHistory />} />
<Route
path="attendance/history"
element={<AttendanceHistory />}
/>
<Route path="attendance/admin" element={<AttendanceAdmin />} />
<Route path="attendance/balances" element={<AttendanceBalances />} />
<Route
path="attendance/balances"
element={<AttendanceBalances />}
/>
<Route path="attendance/requests" element={<LeaveRequests />} />
<Route path="attendance/approval" element={<LeaveApproval />} />
<Route path="attendance/create" element={<AttendanceCreate />} />
<Route path="attendance/location/:id" element={<AttendanceLocation />} />
<Route
path="attendance/create"
element={<AttendanceCreate />}
/>
<Route
path="attendance/location/:id"
element={<AttendanceLocation />}
/>
<Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} />
@@ -91,5 +109,5 @@ export default function AdminApp() {
</ErrorBoundary>
</AlertProvider>
</AuthProvider>
)
);
}

View File

@@ -1,33 +1,40 @@
import { forwardRef, useMemo } from 'react'
import DatePicker, { registerLocale } from 'react-datepicker'
import { cs } from 'date-fns/locale'
import { parse, format } from 'date-fns'
import 'react-datepicker/dist/react-datepicker.css'
import { forwardRef, useMemo } from "react";
import DatePicker, { registerLocale } from "react-datepicker";
import { cs } from "date-fns/locale";
import { parse, format } from "date-fns";
import "react-datepicker/dist/react-datepicker.css";
registerLocale('cs', cs)
registerLocale("cs", cs);
// Ensure portal root exists
if (typeof document !== 'undefined' && !document.getElementById('datepicker-portal')) {
const el = document.createElement('div')
el.id = 'datepicker-portal'
document.body.appendChild(el)
if (
typeof document !== "undefined" &&
!document.getElementById("datepicker-portal")
) {
const el = document.createElement("div");
el.id = "datepicker-portal";
document.body.appendChild(el);
}
const isTouchDevice = () =>
typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
typeof window !== "undefined" &&
("ontouchstart" in window || navigator.maxTouchPoints > 0);
interface CustomInputProps {
value?: string
onClick?: () => void
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
required?: boolean
readOnly?: boolean
disabled?: boolean
value?: string;
onClick?: () => void;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
required?: boolean;
readOnly?: boolean;
disabled?: boolean;
}
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
({ value, onClick, onChange, placeholder, required, readOnly, disabled }, ref) => (
(
{ value, onClick, onChange, placeholder, required, readOnly, disabled },
ref,
) => (
<input
className="admin-form-input"
onClick={onClick}
@@ -40,28 +47,39 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
disabled={disabled}
autoComplete="off"
/>
)
)
),
);
interface NativeInputProps {
mode: string
value: string
onChange: (value: string) => void
required?: boolean
minDate?: string
maxDate?: string
disabled?: boolean
mode: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
minDate?: string;
maxDate?: string;
disabled?: boolean;
}
const modeToInputType: Record<string, string> = { month: 'month', time: 'time' }
const modeToInputType: Record<string, string> = {
month: "month",
time: "time",
};
function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabled }: NativeInputProps) {
const type = modeToInputType[mode] || 'date'
function NativeInput({
mode,
value,
onChange,
required,
minDate,
maxDate,
disabled,
}: NativeInputProps) {
const type = modeToInputType[mode] || "date";
return (
<input
type={type}
lang="cs"
value={value || ''}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="admin-form-input"
required={required}
@@ -69,22 +87,22 @@ function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabl
min={minDate || undefined}
max={maxDate || undefined}
/>
)
);
}
interface AdminDatePickerProps {
mode?: 'date' | 'month' | 'datetime' | 'time'
value: string
onChange: (value: string) => void
minDate?: string
maxDate?: string
disabled?: boolean
placeholder?: string
required?: boolean
mode?: "date" | "month" | "datetime" | "time";
value: string;
onChange: (value: string) => void;
minDate?: string;
maxDate?: string;
disabled?: boolean;
placeholder?: string;
required?: boolean;
}
export default function AdminDatePicker({
mode = 'date',
mode = "date",
value,
onChange,
required,
@@ -93,7 +111,7 @@ export default function AdminDatePicker({
disabled,
placeholder,
}: AdminDatePickerProps) {
const useNative = useMemo(() => isTouchDevice(), [])
const useNative = useMemo(() => isTouchDevice(), []);
if (useNative) {
return (
@@ -106,53 +124,66 @@ export default function AdminDatePicker({
maxDate={maxDate}
disabled={disabled}
/>
)
);
}
const toDate = (val: string | null | undefined): Date | null => {
if (!val) return null
if (!val) return null;
try {
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
if (mode === 'time') {
const [h, m] = val.split(':')
const d = new Date()
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0)
return d
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
if (mode === "time") {
const [h, m] = val.split(":");
const d = new Date();
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
return d;
}
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
} catch { return null }
return null
}
if (mode === "month") return parse(val, "yyyy-MM", new Date());
} catch {
return null;
}
return null;
};
const handleChange = (date: Date | null) => {
if (!date) { onChange(''); return }
if (mode === 'date') onChange(format(date, 'yyyy-MM-dd'))
else if (mode === 'time') onChange(format(date, 'HH:mm'))
else if (mode === 'month') onChange(format(date, 'yyyy-MM'))
}
if (!date) {
onChange("");
return;
}
if (mode === "date") onChange(format(date, "yyyy-MM-dd"));
else if (mode === "time") onChange(format(date, "HH:mm"));
else if (mode === "month") onChange(format(date, "yyyy-MM"));
};
const parseMinMax = (val: string | undefined): Date | undefined => {
if (!val) return undefined
if (!val) return undefined;
try {
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
} catch { return undefined }
return undefined
}
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
if (mode === "month") return parse(val, "yyyy-MM", new Date());
} catch {
return undefined;
}
return undefined;
};
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: 'cs',
customInput: <CustomInput required={required} placeholder={placeholder} disabled={disabled} />,
locale: "cs",
customInput: (
<CustomInput
required={required}
placeholder={placeholder}
disabled={disabled}
/>
),
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: 'bottom-start' as const,
portalId: 'datepicker-portal',
popperPlacement: "bottom-start" as const,
portalId: "datepicker-portal",
disabled,
}
};
if (mode === 'time') {
if (mode === "time") {
return (
<DatePicker
{...commonProps}
@@ -163,23 +194,14 @@ export default function AdminDatePicker({
dateFormat="HH:mm"
timeFormat="HH:mm"
/>
)
);
}
if (mode === 'month') {
if (mode === "month") {
return (
<DatePicker
{...commonProps}
showMonthYearPicker
dateFormat="MM/yyyy"
/>
)
<DatePicker {...commonProps} showMonthYearPicker dateFormat="MM/yyyy" />
);
}
return (
<DatePicker
{...commonProps}
dateFormat="dd.MM.yyyy"
/>
)
return <DatePicker {...commonProps} dateFormat="dd.MM.yyyy" />;
}

View File

@@ -1,64 +1,69 @@
import { useState, useCallback } from 'react'
import { Outlet, Navigate, useLocation } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../../context/ThemeContext'
import { setLogoutAlert } from '../utils/api'
import useModalLock from '../hooks/useModalLock'
import Sidebar from './Sidebar'
import ShortcutsHelp from './ShortcutsHelp'
import { useState, useCallback } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useTheme } from "../../context/ThemeContext";
import { setLogoutAlert } from "../utils/api";
import useModalLock from "../hooks/useModalLock";
import Sidebar from "./Sidebar";
import ShortcutsHelp from "./ShortcutsHelp";
export default function AdminLayout() {
const { isAuthenticated, loading, user, logout } = useAuth()
const { theme, toggleTheme } = useTheme()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [loggingOut, setLoggingOut] = useState(false)
const location = useLocation()
const { isAuthenticated, loading, user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
// Session is managed by AuthProvider (initial check + proactive refresh via setTimeout).
// Do not call checkSession on route changes — concurrent refresh calls with token rotation
// would invalidate each other and kick the user out.
const handleLogout = useCallback(() => {
setLoggingOut(true)
setSidebarOpen(false)
setLogoutAlert()
setTimeout(() => logout(), 400)
}, [logout])
setLoggingOut(true);
setSidebarOpen(false);
setLogoutAlert();
setTimeout(() => logout(), 400);
}, [logout]);
useModalLock(sidebarOpen)
useModalLock(sidebarOpen);
if (loading) {
return (
<div className="admin-layout">
<div className="admin-loading" style={{ width: '100%' }}>
<div className="admin-loading" style={{ width: "100%" }}>
<div className="admin-spinner" />
</div>
</div>
)
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
return <Navigate to="/login" replace />;
}
// If 2FA is required but user hasn't enabled it, redirect to dashboard (where setup lives)
const needs2FASetup = user?.require2FA && !user?.totpEnabled
if (needs2FASetup && location.pathname !== '/') {
return <Navigate to="/" replace />
const needs2FASetup = user?.require2FA && !user?.totpEnabled;
if (needs2FASetup && location.pathname !== "/") {
return <Navigate to="/" replace />;
}
return (
<motion.div
className="admin-layout"
initial={{ opacity: 0, scale: 0.98 }}
animate={loggingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
animate={
loggingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={{ duration: loggingOut ? 0.4 : 0.25, ease: [0.4, 0, 0.2, 1] }}
>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} onLogout={handleLogout} />
<Sidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
onLogout={handleLogout}
/>
<div className="admin-main">
<header className="admin-header">
@@ -67,7 +72,14 @@ export default function AdminLayout() {
className="admin-menu-btn"
aria-label="Otevřít menu"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
@@ -79,22 +91,39 @@ export default function AdminLayout() {
<button
onClick={toggleTheme}
className="admin-header-theme-btn"
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
aria-label={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<span
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</span>
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
</button>
</header>
<main className="admin-content">
@@ -103,5 +132,5 @@ export default function AdminLayout() {
</div>
<ShortcutsHelp />
</motion.div>
)
);
}

View File

@@ -1,44 +1,72 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAlertState } from '../context/AlertContext'
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAlertState } from "../context/AlertContext";
const icons: Record<string, React.ReactNode> = {
success: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
),
error: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
),
warning: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="20"
height="20"
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>
),
info: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
),
}
};
export default function AlertContainer() {
const { alerts, removeAlert } = useAlertState()
const { alerts, removeAlert } = useAlertState();
return (
<div className="admin-alert-container" role="status" aria-live="polite">
<AnimatePresence>
{alerts.map(alert => (
{alerts.map((alert) => (
<motion.div
key={alert.id}
className={`admin-toast admin-toast-${alert.type}`}
@@ -54,7 +82,14 @@ export default function AlertContainer() {
onClick={() => removeAlert(alert.id)}
aria-label="Zavřít"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
@@ -63,5 +98,5 @@ export default function AlertContainer() {
))}
</AnimatePresence>
</div>
)
);
}

View File

@@ -1,93 +1,123 @@
import { Link } from 'react-router-dom'
import { Link } from "react-router-dom";
import {
formatDate, formatDatetime, formatTime,
calculateWorkMinutes, formatMinutes,
getLeaveTypeName, getLeaveTypeBadgeClass
} from '../utils/attendanceHelpers'
formatDate,
formatDatetime,
formatTime,
calculateWorkMinutes,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers";
interface ProjectLog {
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number
shift_date: string
user_name: string
leave_type?: string
leave_hours?: number
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
arrival_lat?: number | string | null
arrival_lng?: number | string | null
departure_lat?: number | string | null
departure_lng?: number | string | null
project_name?: string
project_logs?: ProjectLog[]
notes?: string | null
id: number;
shift_date: string;
user_name: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
arrival_lat?: number | string | null;
arrival_lng?: number | string | null;
departure_lat?: number | string | null;
departure_lng?: number | string | null;
project_name?: string;
project_logs?: ProjectLog[];
notes?: string | null;
}
interface AttendanceShiftTableProps {
records: AttendanceRecord[]
onEdit: (record: AttendanceRecord) => void
onDelete: (record: AttendanceRecord) => void
records: AttendanceRecord[];
onEdit: (record: AttendanceRecord) => void;
onDelete: (record: AttendanceRecord) => void;
}
function formatBreak(record: AttendanceRecord): string {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
return `${formatTime(record.break_start)} - ?`;
}
return '\u2014'
return "\u2014";
}
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
>
{record.project_logs.map((log, i) => {
let h: number, m: number, isActive = false
let h: number,
m: number,
isActive = false;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0
m = parseInt(String(log.minutes)) || 0
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
);
h = Math.floor(mins / 60);
m = mins % 60;
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' \u25B8' : ''})
<span
key={log.id || i}
className="admin-badge"
style={{
fontSize: "0.7rem",
display: "inline-block",
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
</span>
)
);
})}
</div>
)
);
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
return (
<span
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.75rem" }}
>
{record.project_name}
</span>
);
}
return '\u2014'
return "\u2014";
}
export default function AttendanceShiftTable({ records, onEdit, onDelete }: AttendanceShiftTableProps) {
export default function AttendanceShiftTable({
records,
onEdit,
onDelete,
}: AttendanceShiftTableProps) {
if (records.length === 0) {
return (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)
);
}
return (
@@ -110,40 +140,65 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record)
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
: calculateWorkMinutes(record);
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng);
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>{record.user_name}</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono">
{isLeave ? '\u2014' : formatBreak(record)}
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '\u2014'}</td>
<td>
{renderProjectCell(record)}
<td className="admin-mono">
{isLeave ? "\u2014" : formatBreak(record)}
</td>
<td className="admin-mono">
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? `${formatMinutes(workMinutes)} h`
: "\u2014"}
</td>
<td>{renderProjectCell(record)}</td>
<td>
{hasLocation ? (
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
{'\uD83D\uDCCD'}
<Link
to={`/attendance/location/${record.id}`}
className="attendance-gps-link"
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
>
{"\uD83D\uDCCD"}
</Link>
) : '\u2014'}
) : (
"\u2014"
)}
</td>
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
{record.notes || ''}
<td
style={{
maxWidth: "100px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={record.notes || ""}
>
{record.notes || ""}
</td>
<td>
<div className="admin-table-actions">
@@ -153,7 +208,14 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
@@ -164,7 +226,14 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="18"
height="18"
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>
@@ -172,10 +241,10 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
</div>
</td>
</tr>
)
);
})}
</tbody>
</table>
</div>
)
);
}

View File

@@ -1,31 +1,31 @@
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from './AdminDatePicker'
import useModalLock from '../hooks/useModalLock'
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
interface BulkAttendanceForm {
month: string
user_ids: string[]
arrival_time: string
departure_time: string
break_start_time: string
break_end_time: string
month: string;
user_ids: string[];
arrival_time: string;
departure_time: string;
break_start_time: string;
break_end_time: string;
}
interface BulkAttendanceUser {
id: number | string
name: string
id: number | string;
name: string;
}
interface BulkAttendanceModalProps {
show: boolean
onClose: () => void
form: BulkAttendanceForm
setForm: (form: BulkAttendanceForm) => void
users: BulkAttendanceUser[]
onSubmit: () => void
submitting: boolean
toggleUser: (userId: number | string) => void
toggleAllUsers: () => void
show: boolean;
onClose: () => void;
form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void;
users: BulkAttendanceUser[];
onSubmit: () => void;
submitting: boolean;
toggleUser: (userId: number | string) => void;
toggleAllUsers: () => void;
}
export default function BulkAttendanceModal({
@@ -39,7 +39,7 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(show)
useModalLock(show);
return (
<AnimatePresence>
@@ -51,7 +51,10 @@ export default function BulkAttendanceModal({
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => !submitting && onClose()} />
<div
className="admin-modal-backdrop"
onClick={() => !submitting && onClose()}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -61,8 +64,15 @@ export default function BulkAttendanceModal({
>
<div className="admin-modal-header">
<h2 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
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>
@@ -84,30 +94,32 @@ export default function BulkAttendanceModal({
type="button"
onClick={toggleAllUsers}
style={{
marginLeft: '0.75rem',
background: 'none',
border: 'none',
color: 'var(--accent-color)',
cursor: 'pointer',
fontSize: '0.8125rem',
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'}
{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)',
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) => (
@@ -132,7 +144,9 @@ 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">
@@ -140,7 +154,9 @@ 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>
@@ -151,7 +167,9 @@ 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">
@@ -159,7 +177,9 @@ 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>
@@ -181,12 +201,12 @@ export default function BulkAttendanceModal({
className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0}
>
{submitting ? 'Vytvářím záznamy...' : 'Vyplnit měsíc'}
{submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
);
}

View File

@@ -1,24 +1,41 @@
import type { ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import type { ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface ConfirmModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: ReactNode
confirmText?: string
cancelText?: string
type?: 'danger' | 'warning' | 'default' | 'info'
confirmVariant?: 'danger' | 'primary'
loading?: boolean
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: ReactNode;
confirmText?: string;
cancelText?: string;
type?: "danger" | "warning" | "default" | "info";
confirmVariant?: "danger" | "primary";
loading?: boolean;
}
export default function ConfirmModal({ isOpen, onClose, onConfirm, title, message, confirmText = 'Potvrdit', cancelText = 'Zrušit', type = 'default', confirmVariant, loading }: ConfirmModalProps) {
export default function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Potvrdit",
cancelText = "Zrušit",
type = "default",
confirmVariant,
loading,
}: ConfirmModalProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<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-confirm-modal"
@@ -29,7 +46,14 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="24"
height="24"
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" />
@@ -39,14 +63,26 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
<p className="admin-confirm-message">{message}</p>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={onClose} className="admin-btn admin-btn-secondary" disabled={loading}>{cancelText}</button>
<button type="button" onClick={onConfirm} className="admin-btn admin-btn-primary" disabled={loading}>
{loading ? 'Zpracování...' : confirmText}
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
className="admin-btn admin-btn-primary"
disabled={loading}
>
{loading ? "Zpracování..." : confirmText}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
);
}

View File

@@ -1,29 +1,42 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'
import { Component, type ReactNode, type ErrorInfo } from "react";
interface Props { children: ReactNode }
interface State { hasError: boolean; error: Error | null }
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info)
console.error("ErrorBoundary caught:", error, info);
}
render() {
if (this.state.hasError) {
return (
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
<div
className="admin-empty-state"
style={{ minHeight: "60vh", justifyContent: "center" }}
>
<h2>Něco se pokazilo</h2>
<p>{this.state.error?.message}</p>
<button className="admin-btn admin-btn-primary" onClick={() => window.location.reload()}>Obnovit stránku</button>
<button
className="admin-btn admin-btn-primary"
onClick={() => window.location.reload()}
>
Obnovit stránku
</button>
</div>
)
);
}
return this.props.children
return this.props.children;
}
}

View File

@@ -1,11 +1,16 @@
import { Link } from 'react-router-dom'
import { Link } from "react-router-dom";
export default function Forbidden() {
return (
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
<div
className="admin-empty-state"
style={{ minHeight: "60vh", justifyContent: "center" }}
>
<h2>403</h2>
<p>Nemáte oprávnění pro přístup k této stránce.</p>
<Link to="/" className="admin-btn admin-btn-primary">Zpět na Dashboard</Link>
<Link to="/" className="admin-btn admin-btn-primary">
Zpět na Dashboard
</Link>
</div>
)
);
}

View File

@@ -1,14 +1,20 @@
import type { CSSProperties, ReactNode } from 'react'
import type { CSSProperties, ReactNode } from "react";
interface FormFieldProps {
label: ReactNode
children: ReactNode
error?: string
required?: boolean
style?: React.CSSProperties
label: ReactNode;
children: ReactNode;
error?: string;
required?: boolean;
style?: React.CSSProperties;
}
export default function FormField({ label, children, error, required, style }: FormFieldProps) {
export default function FormField({
label,
children,
error,
required,
style,
}: FormFieldProps) {
return (
<div className="admin-form-group" style={style}>
<label className="admin-form-label">
@@ -18,5 +24,5 @@ export default function FormField({ label, children, error, required, style }: F
{children}
{error && <span className="admin-form-error">{error}</span>}
</div>
)
);
}

View File

@@ -1,77 +1,105 @@
interface PaginationProps {
pagination: {
total: number
page: number
per_page: number
total_pages: number
} | null
onPageChange: (page: number) => void
onPerPageChange?: (perPage: number) => void
total: number;
page: number;
per_page: number;
total_pages: number;
} | null;
onPageChange: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
}
export default function Pagination({ pagination, onPageChange, onPerPageChange }: PaginationProps) {
if (!pagination || pagination.total_pages <= 1) return null
export default function Pagination({
pagination,
onPageChange,
onPerPageChange,
}: PaginationProps) {
if (!pagination || pagination.total_pages <= 1) return null;
const { page, total_pages, total } = pagination
const { page, total_pages, total } = pagination;
const getPages = () => {
const pages: (number | string)[] = []
const delta = 2
const pages: (number | string)[] = [];
const delta = 2;
for (let i = 1; i <= total_pages; i++) {
if (i === 1 || i === total_pages || (i >= page - delta && i <= page + delta)) {
pages.push(i)
} else if (pages[pages.length - 1] !== '...') {
pages.push('...')
if (
i === 1 ||
i === total_pages ||
(i >= page - delta && i <= page + delta)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return pages
}
return pages;
};
return (
<div className="admin-pagination">
<div className="admin-pagination-info">
{total} záznamů
</div>
<div className="admin-pagination-info">{total} záznamů</div>
<div className="admin-pagination-controls">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="admin-pagination-page"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 18l-6-6 6-6" /></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{getPages().map((p, i) =>
typeof p === 'string' ? (
<span key={`dots-${i}`} className="admin-pagination-ellipsis">...</span>
typeof p === "string" ? (
<span key={`dots-${i}`} className="admin-pagination-ellipsis">
...
</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={`admin-pagination-page ${p === page ? 'active' : ''}`}
className={`admin-pagination-page ${p === page ? "active" : ""}`}
>
{p}
</button>
)
),
)}
<button
disabled={page >= total_pages}
onClick={() => onPageChange(page + 1)}
className="admin-pagination-page"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6" /></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
{onPerPageChange && (
<select
value={pagination.per_page}
onChange={e => onPerPageChange(Number(e.target.value))}
onChange={(e) => onPerPageChange(Number(e.target.value))}
className="admin-pagination-select"
>
{[10, 25, 50, 100].map(n => (
<option key={n} value={n}>{n} / stránka</option>
{[10, 25, 50, 100].map((n) => (
<option key={n} value={n}>
{n} / stránka
</option>
))}
</select>
)}
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +1,173 @@
import { useMemo, useRef, useCallback } from 'react'
import ReactQuill from 'react-quill-new'
import 'react-quill-new/dist/quill.snow.css'
import { useMemo, useRef, useCallback } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
const Quill = ReactQuill.Quill
const Quill = ReactQuill.Quill;
if (!(Quill as any).__bohaRegistered) {
const Font = Quill.import('attributors/class/font') as any
const Font = Quill.import("attributors/class/font") as any;
Font.whitelist = [
'arial', 'tahoma', 'verdana', 'georgia', 'times-new-roman',
'courier-new', 'trebuchet-ms', 'impact', 'comic-sans-ms',
'lucida-console', 'palatino-linotype', 'garamond'
]
Quill.register(Font, true)
"arial",
"tahoma",
"verdana",
"georgia",
"times-new-roman",
"courier-new",
"trebuchet-ms",
"impact",
"comic-sans-ms",
"lucida-console",
"palatino-linotype",
"garamond",
];
Quill.register(Font, true);
const SizeStyle = Quill.import('attributors/style/size') as any
const SizeStyle = Quill.import("attributors/style/size") as any;
SizeStyle.whitelist = [
'8px', '9px', '10px', '11px', '12px', '14px', '16px',
'18px', '20px', '24px', '28px', '32px', '36px', '48px'
]
Quill.register(SizeStyle, true)
;(Quill as any).__bohaRegistered = true
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
Quill.register(SizeStyle, true);
(Quill as any).__bohaRegistered = true;
}
const Font = Quill.import('attributors/class/font') as any
const Font = Quill.import("attributors/class/font") as any;
const SIZE_WHITELIST = [
'8px', '9px', '10px', '11px', '12px', '14px', '16px',
'18px', '20px', '24px', '28px', '32px', '36px', '48px'
]
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
const COLORS = [
'#000000', '#1a1a1a', '#333333', '#555555', '#777777', '#999999', '#bbbbbb', '#dddddd', '#ffffff',
'#de3a3a', '#e57373', '#c62828',
'#1565c0', '#42a5f5', '#0d47a1',
'#2e7d32', '#66bb6a', '#1b5e20',
'#f57f17', '#ffca28', '#e65100',
'#6a1b9a', '#ab47bc', '#4a148c',
'#00695c', '#26a69a', '#004d40',
'#37474f', '#78909c', '#263238',
]
"#000000",
"#1a1a1a",
"#333333",
"#555555",
"#777777",
"#999999",
"#bbbbbb",
"#dddddd",
"#ffffff",
"#de3a3a",
"#e57373",
"#c62828",
"#1565c0",
"#42a5f5",
"#0d47a1",
"#2e7d32",
"#66bb6a",
"#1b5e20",
"#f57f17",
"#ffca28",
"#e65100",
"#6a1b9a",
"#ab47bc",
"#4a148c",
"#00695c",
"#26a69a",
"#004d40",
"#37474f",
"#78909c",
"#263238",
];
const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
['bold', 'italic', 'underline', 'strike'],
["bold", "italic", "underline", "strike"],
[{ color: COLORS }, { background: COLORS }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ list: "ordered" }, { list: "bullet" }],
[{ indent: "-1" }, { indent: "+1" }],
[{ align: [] }],
['link'],
['clean']
]
["link"],
["clean"],
];
const FORMATS = [
'font', 'size',
'bold', 'italic', 'underline', 'strike',
'color', 'background',
'list', 'indent', 'align',
'link'
]
"font",
"size",
"bold",
"italic",
"underline",
"strike",
"color",
"background",
"list",
"indent",
"align",
"link",
];
interface RichEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
minHeight?: string
readOnly?: boolean
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: string;
readOnly?: boolean;
}
export default function RichEditor({
value,
onChange,
placeholder = 'Obsah...',
minHeight = '120px',
placeholder = "Obsah...",
minHeight = "120px",
readOnly = false,
}: RichEditorProps) {
const quillRef = useRef<ReactQuill>(null)
const lastValueRef = useRef(value)
const quillRef = useRef<ReactQuill>(null);
const lastValueRef = useRef(value);
const modules = useMemo(() => ({
toolbar: readOnly ? false : TOOLBAR,
clipboard: {
matchVisual: false,
const modules = useMemo(
() => ({
toolbar: readOnly ? false : TOOLBAR,
clipboard: {
matchVisual: false,
},
}),
[readOnly],
);
const handleChange = useCallback(
(content: string, _delta: any, source: string) => {
if (source !== "user") return;
if (content === lastValueRef.current) return;
lastValueRef.current = content;
onChange(content);
},
}), [readOnly])
const handleChange = useCallback((content: string, _delta: any, source: string) => {
if (source !== 'user') return
if (content === lastValueRef.current) return
lastValueRef.current = content
onChange(content)
}, [onChange])
[onChange],
);
return (
<div className="rich-editor" style={{ '--re-min-height': minHeight } as React.CSSProperties}>
<div
className="rich-editor"
style={{ "--re-min-height": minHeight } as React.CSSProperties}
>
<ReactQuill
ref={quillRef}
theme="snow"
value={value || ''}
value={value || ""}
onChange={handleChange}
modules={modules}
formats={FORMATS}
@@ -104,5 +175,5 @@ export default function RichEditor({
readOnly={readOnly}
/>
</div>
)
);
}

View File

@@ -1,119 +1,123 @@
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from './AdminDatePicker'
import useModalLock from '../hooks/useModalLock'
import { calcFormWorkMinutes, calcProjectMinutesTotal, formatDate } from '../utils/attendanceHelpers'
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
import {
calcFormWorkMinutes,
calcProjectMinutesTotal,
formatDate,
} from "../utils/attendanceHelpers";
let _logKeyCounter = 0
let _logKeyCounter = 0;
// ---------- Shared types ----------
export interface ShiftFormData {
user_id: string
shift_date: string
leave_type: string
leave_hours: number
arrival_date: string
arrival_time: string
break_start_date: string
break_start_time: string
break_end_date: string
break_end_time: string
departure_date: string
departure_time: string
notes: string
user_id: string;
shift_date: string;
leave_type: string;
leave_hours: number;
arrival_date: string;
arrival_time: string;
break_start_date: string;
break_start_time: string;
break_end_date: string;
break_end_time: string;
departure_date: string;
departure_time: string;
notes: string;
}
export interface ProjectLog {
_key?: string
id?: number
project_id: string | number
hours: string | number
minutes: string | number
_key?: string;
id?: number;
project_id: string | number;
hours: string | number;
minutes: string | number;
}
export interface Project {
id: number | string
project_number: string
name: string
id: number | string;
project_number: string;
name: string;
}
export interface User {
id: number | string
name: string
id: number | string;
name: string;
}
export interface EditingRecord {
user_name: string
shift_date: string
user_name: string;
shift_date: string;
}
// ---------- Sub-component props ----------
interface ProjectTimeStatusProps {
form: ShiftFormData
projectLogs: ProjectLog[]
form: ShiftFormData;
projectLogs: ProjectLog[];
}
interface ProjectLogRowProps {
log: ProjectLog
index: number
projectList: Project[]
onUpdate: (index: number, field: string, value: string) => void
onRemove: (index: number) => void
log: ProjectLog;
index: number;
projectList: Project[];
onUpdate: (index: number, field: string, value: string) => void;
onRemove: (index: number) => void;
}
export interface ShiftFormModalProps {
mode: 'create' | 'edit'
show: boolean
onClose: () => void
onSubmit: () => void
form: ShiftFormData
setForm: (form: ShiftFormData) => void
projectLogs: ProjectLog[]
setProjectLogs: (logs: ProjectLog[]) => void
projectList: Project[]
users: User[]
onShiftDateChange: (value: string) => void
editingRecord: EditingRecord | null
mode: "create" | "edit";
show: boolean;
onClose: () => void;
onSubmit: () => void;
form: ShiftFormData;
setForm: (form: ShiftFormData) => void;
projectLogs: ProjectLog[];
setProjectLogs: (logs: ProjectLog[]) => void;
projectList: Project[];
users: User[];
onShiftDateChange: (value: string) => void;
editingRecord: EditingRecord | null;
}
// ---------- ProjectTimeStatus ----------
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form)
const totalProject = calcProjectMinutesTotal(projectLogs)
const remaining = totalWork - totalProject
const hasLogs = projectLogs.some((l) => l.project_id)
const totalWork = calcFormWorkMinutes(form);
const totalProject = calcProjectMinutesTotal(projectLogs);
const remaining = totalWork - totalProject;
const hasLogs = projectLogs.some((l) => l.project_id);
if (!hasLogs || totalWork <= 0) return null
if (!hasLogs || totalWork <= 0) return null;
const isMatch = remaining === 0
const isMatch = remaining === 0;
return (
<div
style={{
padding: '0.5rem 0.75rem',
marginBottom: '0.5rem',
borderRadius: '6px',
fontSize: '0.8rem',
padding: "0.5rem 0.75rem",
marginBottom: "0.5rem",
borderRadius: "6px",
fontSize: "0.8rem",
background: isMatch
? 'var(--success-bg, rgba(34,197,94,0.1))'
: 'var(--danger-bg, rgba(239,68,68,0.1))',
? "var(--success-bg, rgba(34,197,94,0.1))"
: "var(--danger-bg, rgba(239,68,68,0.1))",
color: isMatch
? 'var(--success-color, #16a34a)'
: 'var(--danger-color, #dc2626)',
? "var(--success-color, #16a34a)"
: "var(--danger-color, #dc2626)",
border: `1px solid ${
isMatch
? 'var(--success-border, rgba(34,197,94,0.3))'
: 'var(--danger-border, rgba(239,68,68,0.3))'
? "var(--success-border, rgba(34,197,94,0.3))"
: "var(--danger-border, rgba(239,68,68,0.3))"
}`,
}}
>
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{' '}
{Math.abs(remaining) % 60}m {remaining < 0 ? '(překročeno)' : ''}
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
{remaining < 0 ? "(překročeno)" : ""}
</div>
)
);
}
// ---------- ProjectLogRow ----------
@@ -129,7 +133,7 @@ function ProjectLogRow({
<div className="flex-row gap-2 mb-2">
<select
value={log.project_id}
onChange={(e) => onUpdate(index, 'project_id', e.target.value)}
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
className="admin-form-select"
style={{ flex: 3, marginBottom: 0 }}
>
@@ -145,12 +149,12 @@ function ProjectLogRow({
min="0"
max="24"
value={log.hours}
onChange={(e) => onUpdate(index, 'hours', e.target.value)}
onChange={(e) => onUpdate(index, "hours", e.target.value)}
className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="h"
/>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
h
</span>
<input
@@ -158,19 +162,19 @@ function ProjectLogRow({
min="0"
max="59"
value={log.minutes}
onChange={(e) => onUpdate(index, 'minutes', e.target.value)}
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="m"
/>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
m
</span>
<button
type="button"
onClick={() => onRemove(index)}
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ padding: '0.375rem', flexShrink: 0 }}
style={{ padding: "0.375rem", flexShrink: 0 }}
title="Odebrat"
>
<svg
@@ -185,7 +189,7 @@ function ProjectLogRow({
</svg>
</button>
</div>
)
);
}
// ---------- ShiftFormModal ----------
@@ -204,30 +208,35 @@ export default function ShiftFormModal({
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(show)
const isCreate = mode === 'create'
const isWorkType = form.leave_type === 'work'
useModalLock(show);
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
const updateField = (field: keyof ShiftFormData, value: string | number) => {
setForm({ ...form, [field]: value })
}
setForm({ ...form, [field]: value });
};
const updateProjectLog = (index: number, field: string, value: string) => {
const updated = [...projectLogs]
updated[index] = { ...updated[index], [field]: value }
setProjectLogs(updated)
}
const updated = [...projectLogs];
updated[index] = { ...updated[index], [field]: value };
setProjectLogs(updated);
};
const removeProjectLog = (index: number) => {
setProjectLogs(projectLogs.filter((_, j) => j !== index))
}
setProjectLogs(projectLogs.filter((_, j) => j !== index));
};
const addProjectLog = () => {
setProjectLogs([
...projectLogs,
{ _key: `log-${++_logKeyCounter}`, project_id: '', hours: '', minutes: '' },
])
}
{
_key: `log-${++_logKeyCounter}`,
project_id: "",
hours: "",
minutes: "",
},
]);
};
return (
<AnimatePresence>
@@ -249,16 +258,16 @@ export default function ShiftFormModal({
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{isCreate ? 'Přidat záznam docházky' : 'Upravit docházku'}
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: 'var(--text-secondary)',
marginTop: '0.25rem',
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingRecord.user_name} {' '}
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
@@ -274,9 +283,7 @@ export default function ShiftFormModal({
</label>
<select
value={form.user_id}
onChange={(e) =>
updateField('user_id', e.target.value)
}
onChange={(e) => updateField("user_id", e.target.value)}
className="admin-form-select"
>
<option value="">Vyberte zaměstnance</option>
@@ -304,7 +311,7 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => updateField('shift_date', val)}
onChange={(val) => updateField("shift_date", val)}
/>
</div>
)}
@@ -313,9 +320,7 @@ export default function ShiftFormModal({
<label className="admin-form-label">Typ záznamu</label>
<select
value={form.leave_type}
onChange={(e) =>
updateField('leave_type', e.target.value)
}
onChange={(e) => updateField("leave_type", e.target.value)}
className="admin-form-select"
>
<option value="work">Práce</option>
@@ -334,7 +339,7 @@ export default function ShiftFormModal({
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField('leave_hours', parseFloat(e.target.value))
updateField("leave_hours", parseFloat(e.target.value))
}
min="0.5"
max="24"
@@ -359,9 +364,7 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val) =>
updateField('arrival_date', val)
}
onChange={(val) => updateField("arrival_date", val)}
/>
</div>
<div className="admin-form-group">
@@ -371,9 +374,7 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) =>
updateField('arrival_time', val)
}
onChange={(val) => updateField("arrival_time", val)}
/>
</div>
</div>
@@ -387,7 +388,7 @@ export default function ShiftFormModal({
mode="date"
value={form.break_start_date}
onChange={(val) =>
updateField('break_start_date', val)
updateField("break_start_date", val)
}
/>
</div>
@@ -399,7 +400,7 @@ export default function ShiftFormModal({
mode="time"
value={form.break_start_time}
onChange={(val) =>
updateField('break_start_time', val)
updateField("break_start_time", val)
}
/>
</div>
@@ -413,9 +414,7 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val) =>
updateField('break_end_date', val)
}
onChange={(val) => updateField("break_end_date", val)}
/>
</div>
<div className="admin-form-group">
@@ -425,9 +424,7 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) =>
updateField('break_end_time', val)
}
onChange={(val) => updateField("break_end_time", val)}
/>
</div>
</div>
@@ -440,21 +437,15 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val) =>
updateField('departure_date', val)
}
onChange={(val) => updateField("departure_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - čas
</label>
<label className="admin-form-label">Odchod - čas</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) =>
updateField('departure_time', val)
}
onChange={(val) => updateField("departure_time", val)}
/>
</div>
</div>
@@ -489,7 +480,7 @@ export default function ShiftFormModal({
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => updateField('notes', e.target.value)}
onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea"
rows={3}
/>
@@ -517,5 +508,5 @@ export default function ShiftFormModal({
</motion.div>
)}
</AnimatePresence>
)
);
}

View File

@@ -1,3 +1,3 @@
export default function ShortcutsHelp() {
return null
return null;
}

View File

@@ -1,363 +1,488 @@
import { type ReactNode } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../../context/ThemeContext'
import { type ReactNode } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useTheme } from "../../context/ThemeContext";
interface MenuItem {
path: string
label: string
end?: boolean
permission?: string | string[]
matchPrefix?: string
matchAlso?: string[]
matchExclude?: string[]
icon: ReactNode
path: string;
label: string;
end?: boolean;
permission?: string | string[];
matchPrefix?: string;
matchAlso?: string[];
matchExclude?: string[];
icon: ReactNode;
}
interface MenuSection {
label: string
items: MenuItem[]
label: string;
items: MenuItem[];
}
const menuSections: MenuSection[] = [
{
label: 'Přehled',
label: "Přehled",
items: [
{
path: '/',
label: 'Přehled',
path: "/",
label: "Přehled",
end: true,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
)
}
]
),
},
],
},
{
label: 'Docházka',
label: "Docházka",
items: [
{
path: '/attendance',
label: 'Záznam',
permission: 'attendance.record',
path: "/attendance",
label: "Záznam",
permission: "attendance.record",
end: true,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15 15" />
</svg>
)
),
},
{
path: '/attendance/history',
label: 'Moje historie',
permission: 'attendance.history',
path: "/attendance/history",
label: "Moje historie",
permission: "attendance.history",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
)
),
},
{
path: '/attendance/requests',
label: 'Žádosti',
permission: 'attendance.record',
path: "/attendance/requests",
label: "Žádosti",
permission: "attendance.record",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
)
),
},
{
path: '/attendance/approval',
label: 'Schvalování',
permission: 'attendance.approve',
path: "/attendance/approval",
label: "Schvalování",
permission: "attendance.approve",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
)
),
},
{
path: '/attendance/admin',
label: 'Správa',
permission: 'attendance.admin',
matchPrefix: '/attendance/admin',
matchAlso: ['/attendance/create', '/attendance/location'],
path: "/attendance/admin",
label: "Správa",
permission: "attendance.admin",
matchPrefix: "/attendance/admin",
matchAlso: ["/attendance/create", "/attendance/location"],
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
)
),
},
{
path: '/attendance/balances',
label: 'Správa bilancí',
permission: 'attendance.balances',
path: "/attendance/balances",
label: "Správa bilancí",
permission: "attendance.balances",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
)
}
]
),
},
],
},
{
label: 'Kniha jízd',
label: "Kniha jízd",
items: [
{
path: '/trips',
label: 'Záznam',
permission: 'trips.record',
path: "/trips",
label: "Záznam",
permission: "trips.record",
end: true,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="5" cy="18" r="3" /><circle cx="19" cy="18" r="3" />
<path d="M5 18V12L8 5h8l3 7v6" /><path d="M10 18h4" />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="5" cy="18" r="3" />
<circle cx="19" cy="18" r="3" />
<path d="M5 18V12L8 5h8l3 7v6" />
<path d="M10 18h4" />
</svg>
)
),
},
{
path: '/trips/history',
label: 'Moje historie',
permission: 'trips.history',
path: "/trips/history",
label: "Moje historie",
permission: "trips.history",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
)
),
},
{
path: '/trips/admin',
label: 'Správa',
permission: 'trips.admin',
path: "/trips/admin",
label: "Správa",
permission: "trips.admin",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
)
),
},
{
path: '/vehicles',
label: 'Vozidla',
permission: 'trips.vehicles',
path: "/vehicles",
label: "Vozidla",
permission: "trips.vehicles",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="1" y="3" width="15" height="13" rx="2" />
<path d="M16 8h4l3 3v5h-7V8z" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
)
}
]
),
},
],
},
{
label: 'Administrativa',
label: "Administrativa",
items: [
{
path: '/offers',
label: 'Nabídky',
permission: 'offers.view',
matchPrefix: '/offers',
matchExclude: ['/offers/customers', '/offers/templates'],
path: "/offers",
label: "Nabídky",
permission: "offers.view",
matchPrefix: "/offers",
matchExclude: ["/offers/customers", "/offers/templates"],
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)
),
},
{
path: '/orders',
label: 'Objednávky',
permission: 'orders.view',
matchPrefix: '/orders',
path: "/orders",
label: "Objednávky",
permission: "orders.view",
matchPrefix: "/orders",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
)
),
},
{
path: '/invoices',
label: 'Faktury',
permission: 'invoices.view',
matchPrefix: '/invoices',
path: "/invoices",
label: "Faktury",
permission: "invoices.view",
matchPrefix: "/invoices",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
)
),
},
{
path: '/projects',
label: 'Projekty',
permission: 'projects.view',
matchPrefix: '/projects',
path: "/projects",
label: "Projekty",
permission: "projects.view",
matchPrefix: "/projects",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
),
},
{
path: '/offers/customers',
label: 'Zákazníci',
permission: 'offers.view',
path: "/offers/customers",
label: "Zákazníci",
permission: "offers.view",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
),
},
{
path: '/company/settings',
label: 'Firma',
permission: 'offers.settings',
path: "/company/settings",
label: "Firma",
permission: "offers.settings",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
)
}
]
),
},
],
},
{
label: 'Systém',
label: "Systém",
items: [
{
path: '/users',
label: 'Uživatelé',
permission: 'users.view',
path: "/users",
label: "Uživatelé",
permission: "users.view",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
),
},
{
path: '/settings',
label: 'Nastavení',
permission: ['settings.roles', 'settings.security'],
path: "/settings",
label: "Nastavení",
permission: ["settings.roles", "settings.security"],
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
),
},
{
path: '/audit-log',
label: 'Audit log',
permission: 'settings.audit',
path: "/audit-log",
label: "Audit log",
permission: "settings.audit",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
}
]
}
]
),
},
],
},
];
interface SidebarProps {
isOpen: boolean
onClose: () => void
onLogout: () => void
isOpen: boolean;
onClose: () => void;
onLogout: () => void;
}
export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
const { user, hasPermission } = useAuth()
const { theme } = useTheme()
const location = useLocation()
const { user, hasPermission } = useAuth();
const { theme } = useTheme();
const location = useLocation();
const isItemActive = (item: MenuItem) => {
if (item.matchPrefix) {
let active = location.pathname.startsWith(item.matchPrefix)
let active = location.pathname.startsWith(item.matchPrefix);
if (active && item.matchExclude) {
active = !item.matchExclude.some(ex => location.pathname.startsWith(ex))
active = !item.matchExclude.some((ex) =>
location.pathname.startsWith(ex),
);
}
return active
return active;
}
if (item.end) {
return location.pathname === item.path
return location.pathname === item.path;
}
return location.pathname.startsWith(item.path)
}
return location.pathname.startsWith(item.path);
};
const hasItemPermission = (item: MenuItem) => {
if (!item.permission) {
return true
return true;
}
if (Array.isArray(item.permission)) {
return item.permission.some(p => hasPermission(p))
return item.permission.some((p) => hasPermission(p));
}
return hasPermission(item.permission)
}
return hasPermission(item.permission);
};
const visibleSections = menuSections
.map(section => ({
.map((section) => ({
...section,
items: section.items.filter(hasItemPermission)
items: section.items.filter(hasItemPermission),
}))
.filter(section => section.items.length > 0)
.filter((section) => section.items.length > 0);
return (
<>
<div
className={`admin-sidebar-overlay${isOpen ? ' open' : ''}`}
className={`admin-sidebar-overlay${isOpen ? " open" : ""}`}
onClick={onClose}
/>
<aside className={`admin-sidebar${isOpen ? ' open' : ''}`}>
<aside className={`admin-sidebar${isOpen ? " open" : ""}`}>
<div className="admin-sidebar-header">
<img
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
src={
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png"
}
alt="Logo"
className="admin-sidebar-logo"
/>
<button onClick={onClose} className="admin-sidebar-close" aria-label="Zavřít menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<button
onClick={onClose}
className="admin-sidebar-close"
aria-label="Zavřít menu"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
@@ -374,11 +499,13 @@ export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
end={item.end}
onClick={onClose}
className={() => {
let active = isItemActive(item)
let active = isItemActive(item);
if (!active && item.matchAlso) {
active = item.matchAlso.some(p => location.pathname.startsWith(p))
active = item.matchAlso.some((p) =>
location.pathname.startsWith(p),
);
}
return `admin-nav-item${active ? ' active' : ''}`
return `admin-nav-item${active ? " active" : ""}`;
}}
>
{item.icon}
@@ -392,20 +519,27 @@ export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
<div className="admin-sidebar-footer">
<div className="admin-user-chip">
<div className="admin-user-avatar">
{user?.fullName?.charAt(0) || user?.username?.charAt(0) || 'U'}
{user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"}
</div>
<div className="admin-user-details">
<div className="admin-user-name">
{user?.fullName || user?.username}
</div>
<div className="admin-user-role">
{user?.roleDisplay}
</div>
<div className="admin-user-role">{user?.roleDisplay}</div>
</div>
</div>
<button onClick={onLogout} className="admin-logout-btn" aria-label="Odhlásit se">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<button
onClick={onLogout}
className="admin-logout-btn"
aria-label="Odhlásit se"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
@@ -415,5 +549,5 @@ export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
</div>
</aside>
</>
)
);
}

View File

@@ -1,20 +1,36 @@
interface SortIconProps {
column: string
sort: string | null
order: string
column: string;
sort: string | null;
order: string;
}
export default function SortIcon({ column, sort, order }: SortIconProps) {
if (sort !== column) {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.3, marginLeft: 4 }}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ opacity: 0.3, marginLeft: 4 }}
>
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
</svg>
)
);
}
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 4 }}>
{order === 'asc' ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginLeft: 4 }}
>
{order === "asc" ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
</svg>
)
);
}

View File

@@ -1,80 +1,139 @@
import { Link } from 'react-router-dom'
import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers'
import { Link } from "react-router-dom";
import {
ENTITY_TYPE_LABELS,
getActivityIconClass,
formatActivityTime,
} from "../../utils/dashboardHelpers";
interface Activity {
id: number | string
action: string
description: string
username?: string
entity_type: string
created_at: string
id: number | string;
action: string;
description: string;
username?: string;
entity_type: string;
created_at: string;
}
interface DashActivityFeedProps {
activities: Activity[] | null
activities: Activity[] | null;
}
function getActivityIcon(action: string) {
switch (action) {
case 'create':
case "create":
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
case 'update':
);
case "update":
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
case 'delete':
);
case "delete":
return (
<svg width="15" height="15" 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
width="15"
height="15"
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>
)
case 'login':
);
case "login":
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
)
);
default:
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" />
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
)
);
}
}
export default function DashActivityFeed({ activities }: DashActivityFeedProps) {
export default function DashActivityFeed({
activities,
}: DashActivityFeedProps) {
if (!activities) {
return null
return null;
}
return (
<div className="admin-card dash-activity-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Audit log</h2>
<Link to="/audit-log" className="admin-btn admin-btn-primary admin-btn-sm">Detail &rarr;</Link>
<Link
to="/audit-log"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Detail &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{activities.map((act) => (
<div key={act.id} className="dash-activity-row">
<div className={`dash-activity-icon ${getActivityIconClass(act.action)}`}>
<div
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
>
{getActivityIcon(act.action)}
</div>
<div className="dash-activity-main">
<div className="dash-activity-text">{act.description}</div>
<div className="dash-activity-sub">{act.username || 'Systém'} · {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}</div>
<div className="dash-activity-sub">
{act.username || "Systém"} ·{" "}
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
</div>
</div>
<div className="dash-activity-time admin-mono">
{formatActivityTime(act.created_at)}
</div>
<div className="dash-activity-time admin-mono">{formatActivityTime(act.created_at)}</div>
</div>
))}
</div>
</div>
)
);
}

View File

@@ -1,50 +1,71 @@
import { Link } from 'react-router-dom'
import { LEAVE_TYPE_LABELS, STATUS_DOT_CLASS, STATUS_LABELS } from '../../utils/dashboardHelpers'
import { Link } from "react-router-dom";
import {
LEAVE_TYPE_LABELS,
STATUS_DOT_CLASS,
STATUS_LABELS,
} from "../../utils/dashboardHelpers";
interface AttendanceUser {
user_id: number | string
name: string
initials?: string
status: string
leave_type?: string
arrived_at?: string
user_id: number | string;
name: string;
initials?: string;
status: string;
leave_type?: string;
arrived_at?: string;
}
interface AttendanceData {
users: AttendanceUser[]
users: AttendanceUser[];
}
interface DashAttendanceTodayProps {
attendance: AttendanceData | null
attendance: AttendanceData | null;
}
export default function DashAttendanceToday({ attendance }: DashAttendanceTodayProps) {
export default function DashAttendanceToday({
attendance,
}: DashAttendanceTodayProps) {
if (!attendance) {
return null
return null;
}
return (
<div className="admin-card dash-attendance-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Docházka dnes</h2>
<Link to="/attendance/admin" className="admin-btn admin-btn-primary admin-btn-sm">Detail &rarr;</Link>
<Link
to="/attendance/admin"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Detail &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{attendance.users.map((u, i) => (
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
<div className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}>
{u.initials || '?'}
<div
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
>
{u.initials || "?"}
</div>
<div className="dash-presence-name">{u.name}</div>
<div className="dash-presence-end">
<span className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}>
{u.status === 'leave' ? (LEAVE_TYPE_LABELS[u.leave_type || ''] || 'Nepřítomen') : STATUS_LABELS[u.status]}
<span
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
>
{u.status === "leave"
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
: STATUS_LABELS[u.status]}
</span>
{u.arrived_at && <span className="admin-mono dash-presence-time">{u.arrived_at}</span>}
{u.arrived_at && (
<span className="admin-mono dash-presence-time">
{u.arrived_at}
</span>
)}
</div>
</div>
))}
</div>
</div>
)
);
}

View File

@@ -1,112 +1,127 @@
import { motion } from 'framer-motion'
import { formatCurrency } from '../../utils/formatters'
import { motion } from "framer-motion";
import { formatCurrency } from "../../utils/formatters";
interface KpiCard {
label: string
value: string
sub?: string
color: string
footer: string | null
label: string;
value: string;
sub?: string;
color: string;
footer: string | null;
}
interface RevenueItem {
amount: number
currency: string
amount: number;
currency: string;
}
interface InvoicesData {
revenue_this_month: RevenueItem[]
revenue_czk?: number | null
unpaid_count: number
revenue_this_month: RevenueItem[];
revenue_czk?: number | null;
unpaid_count: number;
}
interface DashData {
attendance?: {
present_today: number
total_active: number
on_leave: number
}
present_today: number;
total_active: number;
on_leave: number;
};
offers?: {
open_count: number
created_this_month: number
}
invoices?: InvoicesData
open_count: number;
created_this_month: number;
};
invoices?: InvoicesData;
leave_pending?: {
count: number
}
count: number;
};
}
interface DashKpiCardsProps {
dashData: DashData | null
dashData: DashData | null;
}
function buildKpiCards(dashData: DashData | null): KpiCard[] {
const cards: KpiCard[] = []
const cards: KpiCard[] = [];
if (dashData?.attendance) {
cards.push({
label: 'Přítomní dnes',
label: "Přítomní dnes",
value: `${dashData.attendance.present_today}`,
sub: `/ ${dashData.attendance.total_active}`,
color: 'success',
footer: dashData.attendance.on_leave > 0 ? `${dashData.attendance.on_leave} nepřítomných` : null,
})
color: "success",
footer:
dashData.attendance.on_leave > 0
? `${dashData.attendance.on_leave} nepřítomných`
: null,
});
}
if (dashData?.offers) {
cards.push({
label: 'Otevřené nabídky',
label: "Otevřené nabídky",
value: `${dashData.offers.open_count}`,
color: 'info',
footer: dashData.offers.created_this_month > 0 ? `${dashData.offers.created_this_month} tento měsíc` : null,
})
color: "info",
footer:
dashData.offers.created_this_month > 0
? `${dashData.offers.created_this_month} tento měsíc`
: null,
});
}
if (dashData?.invoices) {
cards.push(buildInvoiceKpi(dashData.invoices))
cards.push(buildInvoiceKpi(dashData.invoices));
}
if (dashData?.leave_pending) {
cards.push({
label: 'Žádosti o volno',
label: "Žádosti o volno",
value: `${dashData.leave_pending.count}`,
color: 'danger',
footer: dashData.leave_pending.count > 0 ? 'čeká na schválení' : null,
})
color: "danger",
footer: dashData.leave_pending.count > 0 ? "čeká na schválení" : null,
});
}
return cards
return cards;
}
function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
const rev = invoices.revenue_this_month || []
const hasForeign = rev.some(r => r.currency !== 'CZK')
const hasCzkTotal = hasForeign && invoices.revenue_czk !== null && invoices.revenue_czk !== undefined
const fallbackText = rev.length > 0
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
: '0 Kč'
const rev = invoices.revenue_this_month || [];
const hasForeign = rev.some((r) => r.currency !== "CZK");
const hasCzkTotal =
hasForeign &&
invoices.revenue_czk !== null &&
invoices.revenue_czk !== undefined;
const fallbackText =
rev.length > 0
? rev.map((r) => formatCurrency(r.amount, r.currency)).join(" · ")
: "0 Kč";
const revenueText = hasCzkTotal
? formatCurrency(invoices.revenue_czk!, 'CZK')
: fallbackText
const detailText = hasForeign && rev.length > 0
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
: null
const unpaidText = invoices.unpaid_count > 0
? `${invoices.unpaid_count} neuhrazených`
: null
const footerParts = [detailText, unpaidText].filter(Boolean)
? formatCurrency(invoices.revenue_czk!, "CZK")
: fallbackText;
const detailText =
hasForeign && rev.length > 0
? rev.map((r) => formatCurrency(r.amount, r.currency)).join(" · ")
: null;
const unpaidText =
invoices.unpaid_count > 0 ? `${invoices.unpaid_count} neuhrazených` : null;
const footerParts = [detailText, unpaidText].filter(Boolean);
return {
label: 'Tržby (měsíc)',
label: "Tržby (měsíc)",
value: revenueText,
color: 'warning',
footer: footerParts.length > 0 ? footerParts.join(' · ') : null,
}
color: "warning",
footer: footerParts.length > 0 ? footerParts.join(" · ") : null,
};
}
const KPI_CLASS_MAP: Record<number, string> = { 4: 'dash-kpi-4', 3: 'dash-kpi-3', 2: 'dash-kpi-2', 1: 'dash-kpi-1' }
const KPI_CLASS_MAP: Record<number, string> = {
4: "dash-kpi-4",
3: "dash-kpi-3",
2: "dash-kpi-2",
1: "dash-kpi-1",
};
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
const kpiCards = buildKpiCards(dashData)
const kpiCards = buildKpiCards(dashData);
if (kpiCards.length === 0) {
return null
return null;
}
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || 'dash-kpi-4'
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "dash-kpi-4";
return (
<motion.div
@@ -120,11 +135,22 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
<div className="admin-stat-label">{kpi.label}</div>
<div className="admin-stat-value admin-mono">
{kpi.value}
{kpi.sub && <small className="text-muted" style={{ fontSize: '0.75em', fontWeight: 500, marginLeft: '0.25rem' }}>{kpi.sub}</small>}
{kpi.sub && (
<small
className="text-muted"
style={{
fontSize: "0.75em",
fontWeight: 500,
marginLeft: "0.25rem",
}}
>
{kpi.sub}
</small>
)}
</div>
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
</div>
))}
</motion.div>
)
);
}

View File

@@ -1,107 +1,123 @@
import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../../context/AuthContext'
import { useAlert } from '../../context/AlertContext'
import useModalLock from '../../hooks/useModalLock'
import apiFetch from '../../utils/api'
import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface DashProfileProps {
totpEnabled: boolean
totpLoading: boolean
totpSubmitting: boolean
onStart2FASetup: () => void
onConfirm2FA: () => void
onDisable2FA: () => void
totpSecret: string | null
totpQrUri: string | null
totpCode: string
setTotpCode: (code: string) => void
backupCodes: string[] | null
setBackupCodes: (codes: string[] | null) => void
show2FASetup: boolean
setShow2FASetup: (show: boolean) => void
show2FADisable: boolean
setShow2FADisable: (show: boolean) => void
disableCode: string
setDisableCode: (code: string) => void
totpEnabled: boolean;
totpLoading: boolean;
totpSubmitting: boolean;
onStart2FASetup: () => void;
onConfirm2FA: () => void;
onDisable2FA: () => void;
totpSecret: string | null;
totpQrUri: string | null;
totpCode: string;
setTotpCode: (code: string) => void;
backupCodes: string[] | null;
setBackupCodes: (codes: string[] | null) => void;
show2FASetup: boolean;
setShow2FASetup: (show: boolean) => void;
show2FADisable: boolean;
setShow2FADisable: (show: boolean) => void;
disableCode: string;
setDisableCode: (code: string) => void;
}
interface ProfileFormData {
username: string
email: string
new_password: string
current_password: string
first_name: string
last_name: string
username: string;
email: string;
new_password: string;
current_password: string;
first_name: string;
last_name: string;
}
export default function DashProfile({
totpEnabled, totpLoading, totpSubmitting,
onStart2FASetup, onConfirm2FA, onDisable2FA,
totpSecret, totpQrUri, totpCode, setTotpCode,
backupCodes, setBackupCodes,
show2FASetup, setShow2FASetup,
show2FADisable, setShow2FADisable,
disableCode, setDisableCode,
totpEnabled,
totpLoading,
totpSubmitting,
onStart2FASetup,
onConfirm2FA,
onDisable2FA,
totpSecret,
totpQrUri,
totpCode,
setTotpCode,
backupCodes,
setBackupCodes,
show2FASetup,
setShow2FASetup,
show2FADisable,
setShow2FADisable,
disableCode,
setDisableCode,
}: DashProfileProps) {
const { user, updateUser } = useAuth()
const alert = useAlert()
const totpSetupRef = useRef<HTMLInputElement>(null)
const { user, updateUser } = useAuth();
const alert = useAlert();
const totpSetupRef = useRef<HTMLInputElement>(null);
const [showModal, setShowModal] = useState(false)
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({
username: '', email: '', new_password: '', current_password: '', first_name: '', last_name: ''
})
username: "",
email: "",
new_password: "",
current_password: "",
first_name: "",
last_name: "",
});
useModalLock(showModal)
useModalLock(showModal);
const openEditModal = () => {
const nameParts = (user?.fullName || '').split(' ')
const nameParts = (user?.fullName || "").split(" ");
setFormData({
username: user?.username || '',
email: user?.email || '',
new_password: '',
current_password: '',
first_name: nameParts[0] || '',
last_name: nameParts.slice(1).join(' ') || ''
})
setShowModal(true)
}
username: user?.username || "",
email: user?.email || "",
new_password: "",
current_password: "",
first_name: nameParts[0] || "",
last_name: nameParts.slice(1).join(" ") || "",
});
setShowModal(true);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
const dataToSave = { ...formData }
e?.preventDefault();
const dataToSave = { ...formData };
try {
const response = await apiFetch(`${API_BASE}/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
const data = await response.json()
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
const data = await response.json();
if (data.success) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
setShowModal(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success('Profil byl upraven')
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
setShowModal(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success("Profil byl upraven");
} else {
alert.error(data.error || 'Nepodařilo se uložit profil')
alert.error(data.error || "Nepodařilo se uložit profil");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
function getTotpStatusText(): string {
if (totpLoading) {
return 'Načítání...'
return "Načítání...";
}
return totpEnabled ? 'Aktivní' : 'Neaktivní'
return totpEnabled ? "Aktivní" : "Neaktivní";
}
return (
@@ -114,8 +130,18 @@ export default function DashProfile({
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Váš účet</h2>
<button onClick={openEditModal} 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">
<button
onClick={openEditModal}
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"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
@@ -138,42 +164,82 @@ export default function DashProfile({
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Role</span>
<span className="dash-profile-value">{user?.roleDisplay || String(user?.role || '')}</span>
<span className="dash-profile-value">
{user?.roleDisplay || String(user?.role || "")}
</span>
</div>
</div>
{/* 2FA Section */}
<div style={{ borderTop: '1px solid var(--border-color)', marginTop: '1rem', paddingTop: '1rem' }}>
<div
style={{
borderTop: "1px solid var(--border-color)",
marginTop: "1rem",
paddingTop: "1rem",
}}
>
<div className="flex-between">
<div className="flex-row-gap">
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: totpEnabled ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
color: totpEnabled ? 'var(--success)' : 'var(--text-secondary)'
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: totpEnabled
? "var(--success-light)"
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)",
color: totpEnabled
? "var(--success)"
: "var(--text-secondary)",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>Dvoufaktorové ověření (2FA)</div>
<div className={totpEnabled ? 'text-success' : 'text-secondary'} style={{ fontSize: '0.75rem' }}>
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}>
Dvoufaktorové ověření (2FA)
</div>
<div
className={totpEnabled ? "text-success" : "text-secondary"}
style={{ fontSize: "0.75rem" }}
>
{getTotpStatusText()}
</div>
</div>
</div>
{!totpLoading && (
totpEnabled ? (
<button onClick={() => { setDisableCode(''); setShow2FADisable(true) }} className="admin-btn admin-btn-primary admin-btn-sm">
{!totpLoading &&
(totpEnabled ? (
<button
onClick={() => {
setDisableCode("");
setShow2FADisable(true);
}}
className="admin-btn admin-btn-primary admin-btn-sm"
>
Deaktivovat
</button>
) : (
<button onClick={onStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary admin-btn-sm">
{totpSubmitting ? 'Generuji...' : 'Aktivovat'}
<button
onClick={onStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary admin-btn-sm"
>
{totpSubmitting ? "Generuji..." : "Aktivovat"}
</button>
)
)}
))}
</div>
</div>
</div>
@@ -182,45 +248,139 @@ export default function DashProfile({
{/* 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>
<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" />
<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" />
<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" />
<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" />
<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">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
<input type="password" value={formData.new_password} onChange={(e) => setFormData({ ...formData, new_password: e.target.value })} className="admin-form-input" />
<label className="admin-form-label">
Nové heslo (ponechte prázdné pro zachování stávajícího)
</label>
<input
type="password"
value={formData.new_password}
onChange={(e) =>
setFormData({
...formData,
new_password: e.target.value,
})
}
className="admin-form-input"
/>
</div>
{formData.new_password && (
<div className="admin-form-group">
<label className="admin-form-label required">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="Zadejte aktuální heslo pro potvrzení" />
<label className="admin-form-label required">
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="Zadejte aktuální heslo pro potvrzení"
/>
</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>
<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>
@@ -230,31 +390,105 @@ export default function DashProfile({
{/* 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) } }} />
<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 }}>
<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);
}
}}
/>
<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>
<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">
<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" />
<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={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
<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
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" />
<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>
@@ -262,48 +496,143 @@ export default function DashProfile({
</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
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' }}>
<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)' }}
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' }}>
<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" />
<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() } }} />
<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">
<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
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>
</>
)}
@@ -316,23 +645,80 @@ export default function DashProfile({
{/* 2FA Disable Modal */}
<AnimatePresence>
{show2FADisable && (
<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={() => 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>
<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={() => 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)', fontSize: '0.875rem', marginBottom: '1rem' }}>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace.
<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 />
<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>
</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
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>
@@ -340,5 +726,5 @@ export default function DashProfile({
)}
</AnimatePresence>
</>
)
);
}

View File

@@ -1,192 +1,287 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../../context/AuthContext'
import { useAlert } from '../../context/AlertContext'
import { formatKm } from '../../utils/formatters'
import AdminDatePicker from '../AdminDatePicker'
import apiFetch from '../../utils/api'
import useModalLock from '../../hooks/useModalLock'
import { useState } from "react";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import { formatKm } from "../../utils/formatters";
import AdminDatePicker from "../AdminDatePicker";
import apiFetch from "../../utils/api";
import useModalLock from "../../hooks/useModalLock";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string
spz: string
name: string
id: number | string;
spz: string;
name: string;
}
interface TripForm {
vehicle_id: string
trip_date: string
start_km: string
end_km: string
route_from: string
route_to: string
is_business: number
notes: string
vehicle_id: string;
trip_date: string;
start_km: string;
end_km: string;
route_from: string;
route_to: string;
is_business: number;
notes: string;
}
interface TripErrors {
vehicle_id?: string
trip_date?: string
start_km?: string
end_km?: string
route_from?: string
route_to?: string
vehicle_id?: string;
trip_date?: string;
start_km?: string;
end_km?: string;
route_from?: string;
route_to?: string;
}
interface DashQuickActionsProps {
dashData: {
my_shift?: {
has_ongoing: boolean
}
} | null
punching: boolean
onPunch: () => void
has_ongoing: boolean;
};
} | null;
punching: boolean;
onPunch: () => void;
}
export default function DashQuickActions({ dashData, punching, onPunch }: DashQuickActionsProps) {
const { hasPermission } = useAuth()
const alert = useAlert()
export default function DashQuickActions({
dashData,
punching,
onPunch,
}: DashQuickActionsProps) {
const { hasPermission } = useAuth();
const alert = useAlert();
const [showTripModal, setShowTripModal] = useState(false)
const [tripSubmitting, setTripSubmitting] = useState(false)
const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([])
const [showTripModal, setShowTripModal] = useState(false);
const [tripSubmitting, setTripSubmitting] = useState(false);
const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([]);
const [tripForm, setTripForm] = useState<TripForm>({
vehicle_id: '', trip_date: '', start_km: '', end_km: '',
route_from: '', route_to: '', is_business: 1, notes: ''
})
const [tripErrors, setTripErrors] = useState<TripErrors>({})
vehicle_id: "",
trip_date: "",
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: "",
});
const [tripErrors, setTripErrors] = useState<TripErrors>({});
useModalLock(showTripModal)
useModalLock(showTripModal);
const openTripModal = async () => {
setTripForm({
vehicle_id: '', trip_date: new Date().toISOString().split('T')[0],
start_km: '', end_km: '', route_from: '', route_to: '',
is_business: 1, notes: ''
})
setTripErrors({})
setShowTripModal(true)
vehicle_id: "",
trip_date: new Date().toISOString().split("T")[0],
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: "",
});
setTripErrors({});
setShowTripModal(true);
try {
const response = await apiFetch(`${API_BASE}/vehicles`)
const result = await response.json()
const response = await apiFetch(`${API_BASE}/vehicles`);
const result = await response.json();
if (result.success) {
setTripVehicles(Array.isArray(result.data) ? result.data : result.data?.vehicles || [])
setTripVehicles(
Array.isArray(result.data)
? result.data
: result.data?.vehicles || [],
);
}
} catch {
// vozidla se nenacetla
}
}
};
const handleTripVehicleChange = async (vehicleId: string) => {
setTripForm(prev => ({ ...prev, vehicle_id: vehicleId }))
setTripForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
if (!vehicleId) {
return
return;
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
const result = await response.json()
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
const result = await response.json();
if (result.success) {
setTripForm(prev => ({ ...prev, start_km: result.data.last_km }))
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
}
} catch {
// last_km se nenacetlo
}
}
};
const handleTripSubmit = async () => {
const errs: TripErrors = {}
const errs: TripErrors = {};
if (!tripForm.vehicle_id) {
errs.vehicle_id = 'Vyberte vozidlo'
errs.vehicle_id = "Vyberte vozidlo";
}
if (!tripForm.trip_date) {
errs.trip_date = 'Zadejte datum'
errs.trip_date = "Zadejte datum";
}
if (!tripForm.start_km) {
errs.start_km = 'Zadejte počáteční km'
errs.start_km = "Zadejte počáteční km";
}
if (!tripForm.end_km) {
errs.end_km = 'Zadejte konečný km'
errs.end_km = "Zadejte konečný km";
}
if (tripForm.start_km && tripForm.end_km && parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)) {
errs.end_km = 'Musí být větší než počáteční'
if (
tripForm.start_km &&
tripForm.end_km &&
parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)
) {
errs.end_km = "Musí být větší než počáteční";
}
if (!tripForm.route_from) {
errs.route_from = 'Zadejte místo odjezdu'
errs.route_from = "Zadejte místo odjezdu";
}
if (!tripForm.route_to) {
errs.route_to = 'Zadejte místo příjezdu'
errs.route_to = "Zadejte místo příjezdu";
}
setTripErrors(errs)
setTripErrors(errs);
if (Object.keys(errs).length > 0) {
return
return;
}
setTripSubmitting(true)
setTripSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/trips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tripForm)
})
const result = await response.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tripForm),
});
const result = await response.json();
if (result.success) {
setShowTripModal(false)
alert.success(result.message)
setShowTripModal(false);
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setTripSubmitting(false)
setTripSubmitting(false);
}
}
};
const tripDistance = (): number => {
const s = parseInt(tripForm.start_km) || 0
const e = parseInt(tripForm.end_km) || 0
return e > s ? e - s : 0
}
const s = parseInt(tripForm.start_km) || 0;
const e = parseInt(tripForm.end_km) || 0;
return e > s ? e - s : 0;
};
const hasOngoingShift = dashData?.my_shift?.has_ongoing
const punchLabel = hasOngoingShift ? 'Zaznamenat odchod' : 'Zaznamenat příchod'
const hasOngoingShift = dashData?.my_shift?.has_ongoing;
const punchLabel = hasOngoingShift
? "Zaznamenat odchod"
: "Zaznamenat příchod";
const quickActions: Array<{
label: string
color: string
icon: React.ReactNode
onClick?: () => void
path?: string
disabled?: boolean
}> = []
label: string;
color: string;
icon: React.ReactNode;
onClick?: () => void;
path?: string;
disabled?: boolean;
}> = [];
if (hasPermission('attendance.record')) {
if (hasPermission("attendance.record")) {
quickActions.push({
label: punching ? 'Odesílám...' : punchLabel,
color: hasOngoingShift ? 'danger' : 'success',
icon: hasOngoingShift
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 12l2 2 4-4" /><circle cx="12" cy="12" r="10" /></svg>,
label: punching ? "Odesílám..." : punchLabel,
color: hasOngoingShift ? "danger" : "success",
icon: hasOngoingShift ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
),
onClick: onPunch,
disabled: punching,
})
});
}
if (hasPermission('offers.create')) {
quickActions.push({ label: 'Nová nabídka', path: '/offers/new', color: 'info', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg> })
}
if (hasPermission('trips.record')) {
if (hasPermission("offers.create")) {
quickActions.push({
label: 'Přidat jízdu',
color: 'warning',
icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="1" y="3" width="15" height="13" rx="2" /><circle cx="8.5" cy="16" r="2.5" /><circle cx="18.5" cy="16" r="2.5" /><path d="M16 8h4l3 5v3h-7" /></svg>,
onClick: openTripModal,
})
label: "Nová nabídka",
path: "/offers/new",
color: "info",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
),
});
}
if (hasPermission('invoices.create')) {
quickActions.push({ label: 'Vystavit fakturu', path: '/invoices/new', color: 'danger', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></svg> })
if (hasPermission("trips.record")) {
quickActions.push({
label: "Přidat jízdu",
color: "warning",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="1" y="3" width="15" height="13" rx="2" />
<circle cx="8.5" cy="16" r="2.5" />
<circle cx="18.5" cy="16" r="2.5" />
<path d="M16 8h4l3 5v3h-7" />
</svg>
),
onClick: openTripModal,
});
}
if (hasPermission("invoices.create")) {
quickActions.push({
label: "Vystavit fakturu",
path: "/invoices/new",
color: "danger",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
),
});
}
return (
@@ -197,22 +292,28 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
{quickActions.map((action) => action.onClick ? (
<button
key={action.label}
onClick={action.onClick}
disabled={action.disabled}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
>
{action.icon}
<span>{action.label}</span>
</button>
) : (
<Link key={action.label} to={action.path!} className={`dash-quick-btn dash-quick-btn-${action.color}`}>
{action.icon}
<span>{action.label}</span>
</Link>
))}
{quickActions.map((action) =>
action.onClick ? (
<button
key={action.label}
onClick={action.onClick}
disabled={action.disabled}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
>
{action.icon}
<span>{action.label}</span>
</button>
) : (
<Link
key={action.label}
to={action.path!}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
>
{action.icon}
<span>{action.label}</span>
</Link>
),
)}
</motion.div>
<AnimatePresence>
@@ -224,7 +325,10 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowTripModal(false)} />
<div
className="admin-modal-backdrop"
onClick={() => setShowTripModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -238,102 +342,188 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
<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>
<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 }))
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>
<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>}
{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>
<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 }))
setTripForm((prev) => ({ ...prev, trip_date: val }));
setTripErrors((prev) => ({
...prev,
trip_date: undefined,
}));
}}
/>
{tripErrors.trip_date && <span className="admin-form-error">{tripErrors.trip_date}</span>}
{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>
<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 }))
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>}
{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>
<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 }))
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>}
{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 />
<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>
<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 }))
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>}
{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>
<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 }))
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>}
{tripErrors.route_to && (
<span className="admin-form-error">
{tripErrors.route_to}
</span>
)}
</div>
</div>
@@ -341,7 +531,12 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
<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) }))}
onChange={(e) =>
setTripForm((prev) => ({
...prev,
is_business: parseInt(e.target.value),
}))
}
className="admin-form-select"
>
<option value={1}>Služební</option>
@@ -353,7 +548,12 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
<label className="admin-form-label">Poznámky</label>
<textarea
value={tripForm.notes}
onChange={(e) => setTripForm(prev => ({ ...prev, notes: e.target.value }))}
onChange={(e) =>
setTripForm((prev) => ({
...prev,
notes: e.target.value,
}))
}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
@@ -362,11 +562,21 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowTripModal(false)} className="admin-btn admin-btn-secondary" disabled={tripSubmitting}>
<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
type="button"
onClick={handleTripSubmit}
className="admin-btn admin-btn-primary"
disabled={tripSubmitting}
>
{tripSubmitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
@@ -374,5 +584,5 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
)}
</AnimatePresence>
</>
)
);
}

View File

@@ -1,126 +1,159 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { useAlert } from '../../context/AlertContext'
import ConfirmModal from '../ConfirmModal'
import useModalLock from '../../hooks/useModalLock'
import apiFetch from '../../utils/api'
import { formatSessionDate } from '../../utils/dashboardHelpers'
import { useState, useEffect, useCallback } from "react";
import { motion } from "framer-motion";
import { useAlert } from "../../context/AlertContext";
import ConfirmModal from "../ConfirmModal";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface DeviceInfo {
icon?: string
browser?: string
os?: string
icon?: string;
browser?: string;
os?: string;
}
interface Session {
id: number | string
is_current: boolean
device_info?: DeviceInfo
ip_address: string
created_at: string
id: number | string;
is_current: boolean;
device_info?: DeviceInfo;
ip_address: string;
created_at: string;
}
interface DeleteModalState {
isOpen: boolean
session: Session | null
isOpen: boolean;
session: Session | null;
}
function getDeviceIcon(iconType?: string) {
switch (iconType) {
case 'smartphone':
case "smartphone":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
case 'tablet':
);
case "tablet":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
);
default:
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
);
}
}
export default function DashSessions() {
const alert = useAlert()
const alert = useAlert();
const [sessions, setSessions] = useState<Session[]>([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({ isOpen: false, session: null })
const [deleteAllModal, setDeleteAllModal] = useState(false)
const [deleting, setDeleting] = useState(false)
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false,
session: null,
});
const [deleteAllModal, setDeleteAllModal] = useState(false);
const [deleting, setDeleting] = useState(false);
useModalLock(deleteAllModal)
useModalLock(deleteAllModal);
const fetchSessions = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/sessions`)
const data = await response.json()
const response = await apiFetch(`${API_BASE}/sessions`);
const data = await response.json();
if (data.success) {
setSessions(Array.isArray(data.data) ? data.data : data.data?.sessions || [])
setSessions(
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
);
}
} catch {
// session fetch failed silently
} finally {
setSessionsLoading(false)
setSessionsLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchSessions()
}, [fetchSessions])
fetchSessions();
}, [fetchSessions]);
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return
return;
}
const sessionId = deleteModal.session.id
setDeleting(true)
const sessionId = deleteModal.session.id;
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, { method: 'DELETE' })
const data = await response.json()
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, {
method: "DELETE",
});
const data = await response.json();
if (data.success) {
setDeleteModal({ isOpen: false, session: null })
setSessions(prev => prev.filter(s => s.id !== sessionId))
alert.success('Relace byla ukončena')
setDeleteModal({ isOpen: false, session: null });
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
alert.success("Relace byla ukončena");
} else {
alert.error(data.error || 'Nepodařilo se ukončit relaci')
alert.error(data.error || "Nepodařilo se ukončit relaci");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
const handleDeleteAllSessions = async () => {
setDeleting(true)
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/sessions?action=all`, { method: 'DELETE' })
const data = await response.json()
const response = await apiFetch(`${API_BASE}/sessions?action=all`, {
method: "DELETE",
});
const data = await response.json();
if (data.success) {
setDeleteAllModal(false)
setSessions(prev => prev.filter(s => s.is_current))
alert.success(data.message || 'Ostatní relace byly ukončeny')
setDeleteAllModal(false);
setSessions((prev) => prev.filter((s) => s.is_current));
alert.success(data.message || "Ostatní relace byly ukončeny");
} else {
alert.error(data.error || 'Nepodařilo se ukončit relace')
alert.error(data.error || "Nepodařilo se ukončit relace");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
return (
<>
@@ -130,43 +163,81 @@ export default function DashSessions() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
<div
className="admin-card-header"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
}}
>
<h2 className="admin-card-title">Přihlášená zařízení</h2>
{sessions.filter(s => !s.is_current).length > 0 && (
<button onClick={() => setDeleteAllModal(true)} className="admin-btn admin-btn-secondary admin-btn-sm">
{sessions.filter((s) => !s.is_current).length > 0 && (
<button
onClick={() => setDeleteAllModal(true)}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Odhlásit ostatní
</button>
)}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && (
<div className="admin-skeleton" style={{ padding: '1rem', gap: '1rem' }}>
{[0, 1, 2].map(i => (
<div
className="admin-skeleton"
style={{ padding: "1rem", gap: "1rem" }}
>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
</div>
</div>
))}
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<div className="text-secondary" style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.875rem' }}>
<div
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
>
Žádné aktivní relace
</div>
)}
{!sessionsLoading && sessions.length > 0 && (
<div className="sessions-list">
{sessions.map((session) => (
<div key={session.id} className={`session-item ${session.is_current ? 'session-item-current' : ''}`}>
<div className="session-icon">{getDeviceIcon(session.device_info?.icon)}</div>
<div
key={session.id}
className={`session-item ${session.is_current ? "session-item-current" : ""}`}
>
<div className="session-icon">
{getDeviceIcon(session.device_info?.icon)}
</div>
<div className="session-info">
<div className="session-device">
{session.device_info?.browser} na {session.device_info?.os}
{session.device_info?.browser} na{" "}
{session.device_info?.os}
{session.is_current && (
<span className="admin-badge admin-badge-success" style={{ marginLeft: '0.5rem' }}>Aktuální</span>
<span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
)}
</div>
<div className="session-meta">
@@ -177,9 +248,25 @@ export default function DashSessions() {
</div>
<div className="session-actions">
{!session.is_current && (
<button onClick={() => setDeleteModal({ isOpen: true, session })} className="admin-btn-icon danger" title="Ukončit relaci" aria-label="Ukončit relaci">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
<button
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
className="admin-btn-icon danger"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
)}
@@ -214,5 +301,5 @@ export default function DashSessions() {
loading={deleting}
/>
</>
)
);
}

View File

@@ -1,53 +1,70 @@
import { createContext, useContext, useState, useCallback, useMemo, useRef, type ReactNode } from 'react'
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
interface Alert {
id: string
message: string
type: 'success' | 'error' | 'warning' | 'info'
id: string;
message: string;
type: "success" | "error" | "warning" | "info";
}
interface AlertMethods {
addAlert: (message: string, type?: string, duration?: number) => string
removeAlert: (id: string) => void
success: (message: string, duration?: number) => string
error: (message: string, duration?: number) => string
warning: (message: string, duration?: number) => string
info: (message: string, duration?: number) => string
addAlert: (message: string, type?: string, duration?: number) => string;
removeAlert: (id: string) => void;
success: (message: string, duration?: number) => string;
error: (message: string, duration?: number) => string;
warning: (message: string, duration?: number) => string;
info: (message: string, duration?: number) => string;
}
interface AlertStateValue {
alerts: Alert[]
removeAlert: (id: string) => void
alerts: Alert[];
removeAlert: (id: string) => void;
}
const AlertContext = createContext<AlertMethods | null>(null)
const AlertStateContext = createContext<AlertStateValue | null>(null)
const AlertContext = createContext<AlertMethods | null>(null);
const AlertStateContext = createContext<AlertStateValue | null>(null);
export function AlertProvider({ children }: { children: ReactNode }) {
const [alerts, setAlerts] = useState<Alert[]>([])
const [alerts, setAlerts] = useState<Alert[]>([]);
const removeAlert = useCallback((id: string) => {
setAlerts(prev => prev.filter(alert => alert.id !== id))
}, [])
setAlerts((prev) => prev.filter((alert) => alert.id !== id));
}, []);
const counterRef = useRef(0)
const addAlert = useCallback((message: string, type = 'success', duration = 4000) => {
const id = `${Date.now()}-${counterRef.current++}`
setAlerts(prev => [...prev, { id, message, type: type as Alert['type'] }])
if (duration > 0) {
setTimeout(() => removeAlert(id), duration)
}
return id
}, [removeAlert])
const counterRef = useRef(0);
const addAlert = useCallback(
(message: string, type = "success", duration = 4000) => {
const id = `${Date.now()}-${counterRef.current++}`;
setAlerts((prev) => [
...prev,
{ id, message, type: type as Alert["type"] },
]);
if (duration > 0) {
setTimeout(() => removeAlert(id), duration);
}
return id;
},
[removeAlert],
);
const methods = useMemo<AlertMethods>(() => ({
addAlert,
removeAlert,
success: (message, duration) => addAlert(message, 'success', duration),
error: (message, duration) => addAlert(message, 'error', duration),
warning: (message, duration) => addAlert(message, 'warning', duration),
info: (message, duration) => addAlert(message, 'info', duration),
}), [addAlert, removeAlert])
const methods = useMemo<AlertMethods>(
() => ({
addAlert,
removeAlert,
success: (message, duration) => addAlert(message, "success", duration),
error: (message, duration) => addAlert(message, "error", duration),
warning: (message, duration) => addAlert(message, "warning", duration),
info: (message, duration) => addAlert(message, "info", duration),
}),
[addAlert, removeAlert],
);
return (
<AlertContext.Provider value={methods}>
@@ -55,17 +72,19 @@ export function AlertProvider({ children }: { children: ReactNode }) {
{children}
</AlertStateContext.Provider>
</AlertContext.Provider>
)
);
}
export function useAlert(): AlertMethods {
const context = useContext(AlertContext)
if (!context) throw new Error('useAlert must be used within an AlertProvider')
return context
const context = useContext(AlertContext);
if (!context)
throw new Error("useAlert must be used within an AlertProvider");
return context;
}
export function useAlertState(): AlertStateValue {
const context = useContext(AlertStateContext)
if (!context) throw new Error('useAlertState must be used within an AlertProvider')
return context
const context = useContext(AlertStateContext);
if (!context)
throw new Error("useAlertState must be used within an AlertProvider");
return context;
}

View File

@@ -1,279 +1,397 @@
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
import { setSessionExpired, setTokenGetter, setRefreshFn } from '../utils/api'
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface User {
id: number
username: string
email: string
fullName: string
roleDisplay: string
isAdmin: boolean
totpEnabled: boolean
require2FA: boolean
permissions: string[]
[key: string]: unknown
id: number;
username: string;
email: string;
fullName: string;
roleDisplay: string;
isAdmin: boolean;
totpEnabled: boolean;
require2FA: boolean;
permissions: string[];
[key: string]: unknown;
}
interface AuthState {
user: User | null
loading: boolean
error: string | null
isAuthenticated: boolean
isAdmin: boolean
permissions: string[]
hasPermission: (permission: string) => boolean
user: User | null;
loading: boolean;
error: string | null;
isAuthenticated: boolean;
isAdmin: boolean;
permissions: string[];
hasPermission: (permission: string) => boolean;
}
interface AuthActions {
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; requires2FA?: boolean; loginToken?: string; error?: string; remember?: boolean }>
verify2FA: (loginToken: string, code: string, remember?: boolean, isBackup?: boolean) => Promise<{ success: boolean; error?: string }>
logout: () => Promise<void>
checkSession: () => Promise<boolean>
getAccessToken: () => string | null
apiRequest: (endpoint: string, options?: RequestInit) => Promise<Response>
silentRefresh: () => Promise<boolean>
updateUser: (updates: Partial<User>) => void
login: (
username: string,
password: string,
remember?: boolean,
) => Promise<{
success: boolean;
requires2FA?: boolean;
loginToken?: string;
error?: string;
remember?: boolean;
}>;
verify2FA: (
loginToken: string,
code: string,
remember?: boolean,
isBackup?: boolean,
) => Promise<{ success: boolean; error?: string }>;
logout: () => Promise<void>;
checkSession: () => Promise<boolean>;
getAccessToken: () => string | null;
apiRequest: (endpoint: string, options?: RequestInit) => Promise<Response>;
silentRefresh: () => Promise<boolean>;
updateUser: (updates: Partial<User>) => void;
}
const AuthStateContext = createContext<AuthState | null>(null)
const AuthActionsContext = createContext<AuthActions | null>(null)
const AuthStateContext = createContext<AuthState | null>(null);
const AuthActionsContext = createContext<AuthActions | null>(null);
function mapUser(u: Record<string, unknown> | null): User | null {
if (!u) return null
const id = (u.userId ?? u.id) as number
const firstName = (u.firstName ?? u.first_name ?? '') as string
const lastName = (u.lastName ?? u.last_name ?? '') as string
const roleName = (u.roleName ?? u.role_name ?? '') as string
if (!u) return null;
const id = (u.userId ?? u.id) as number;
const firstName = (u.firstName ?? u.first_name ?? "") as string;
const lastName = (u.lastName ?? u.last_name ?? "") as string;
const roleName = (u.roleName ?? u.role_name ?? "") as string;
return {
...u,
id,
fullName: (u.fullName ?? u.full_name ?? `${firstName} ${lastName}`.trim()) as string,
fullName: (u.fullName ??
u.full_name ??
`${firstName} ${lastName}`.trim()) as string,
roleDisplay: (u.roleDisplay ?? u.role_display ?? roleName) as string,
isAdmin: (u.isAdmin ?? u.is_admin ?? roleName === 'admin') as boolean,
isAdmin: (u.isAdmin ?? u.is_admin ?? roleName === "admin") as boolean,
totpEnabled: (u.totpEnabled ?? u.totp_enabled ?? false) as boolean,
require2FA: (u.require2FA ?? u.require_2fa ?? false) as boolean,
permissions: (u.permissions ?? []) as string[],
} as User
} as User;
}
let accessToken: string | null = null
let tokenExpiresAt: number | null = null
let cachedUser: User | null = null
let sessionFetched = false
let silentRefreshInFlight: Promise<boolean> | null = null
let accessToken: string | null = null;
let tokenExpiresAt: number | null = null;
let cachedUser: User | null = null;
let sessionFetched = false;
let silentRefreshInFlight: Promise<boolean> | null = null;
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(cachedUser)
const [loading, setLoading] = useState(!sessionFetched)
const [error, setError] = useState<string | null>(null)
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [user, setUser] = useState<User | null>(cachedUser);
const [loading, setLoading] = useState(!sessionFetched);
const [error, setError] = useState<string | null>(null);
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { cachedUser = user }, [user])
useEffect(() => {
cachedUser = user;
}, [user]);
const getAccessTokenFn = useCallback((): string | null => {
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null
return accessToken
}, [])
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null;
return accessToken;
}, []);
const setAccessTokenFn = useCallback((token: string | null, expiresIn?: number) => {
const ttl = expiresIn ?? 900 // default 15 min matching backend config
accessToken = token
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
refreshTimeoutRef.current = null
}
if (token && ttl > 60) {
refreshTimeoutRef.current = setTimeout(() => silentRefresh(), (ttl - 60) * 1000)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const setAccessTokenFn = useCallback(
(token: string | null, expiresIn?: number) => {
const ttl = expiresIn ?? 900; // default 15 min matching backend config
accessToken = token;
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null;
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
if (token && ttl > 60) {
refreshTimeoutRef.current = setTimeout(
() => silentRefresh(),
(ttl - 60) * 1000,
);
}
},
[],
); // eslint-disable-line react-hooks/exhaustive-deps
const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
if (silentRefreshInFlight) return silentRefreshInFlight
if (silentRefreshInFlight) return silentRefreshInFlight;
const promise = (async (): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/refresh`, { method: 'POST', credentials: 'include' })
const data = await response.json()
const response = await fetch(`${API_BASE}/refresh`, {
method: "POST",
credentials: "include",
});
const data = await response.json();
if (data.success && data.data?.access_token) {
setAccessTokenFn(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
return true
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
return true;
}
accessToken = null
tokenExpiresAt = null
setUser(null)
cachedUser = null
setSessionExpired()
return false
accessToken = null;
tokenExpiresAt = null;
setUser(null);
cachedUser = null;
setSessionExpired();
return false;
} catch {
// Network error — don't kick the user out, just return false
return false
return false;
} finally {
silentRefreshInFlight = null
silentRefreshInFlight = null;
}
})()
})();
silentRefreshInFlight = promise
return promise
}, [setAccessTokenFn])
silentRefreshInFlight = promise;
return promise;
}, [setAccessTokenFn]);
const checkSession = useCallback(async (): Promise<boolean> => {
try {
const token = getAccessTokenFn()
const token = getAccessTokenFn();
if (token) {
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }
const response = await fetch(`${API_BASE}/session`, { method: 'GET', credentials: 'include', headers })
if (response.status === 429 || response.status >= 500) return !!cachedUser
const data = await response.json()
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
const response = await fetch(`${API_BASE}/session`, {
method: "GET",
credentials: "include",
headers,
});
if (response.status === 429 || response.status >= 500)
return !!cachedUser;
const data = await response.json();
if (data.success && data.data?.user) {
if (data.data.access_token) setAccessTokenFn(data.data.access_token)
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
return true
if (data.data.access_token) setAccessTokenFn(data.data.access_token);
setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user);
return true;
}
}
// No token or session invalid — try silent refresh via cookie
const refreshed = await silentRefresh()
if (refreshed) return true
setUser(null)
cachedUser = null
accessToken = null
tokenExpiresAt = null
return false
const refreshed = await silentRefresh();
if (refreshed) return true;
setUser(null);
cachedUser = null;
accessToken = null;
tokenExpiresAt = null;
return false;
} catch {
return !!cachedUser
return !!cachedUser;
} finally {
setLoading(false)
sessionFetched = true
setLoading(false);
sessionFetched = true;
}
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh])
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
useEffect(() => {
setTokenGetter(getAccessTokenFn)
setRefreshFn(silentRefresh)
}, [getAccessTokenFn, silentRefresh])
setTokenGetter(getAccessTokenFn);
setRefreshFn(silentRefresh);
}, [getAccessTokenFn, silentRefresh]);
useEffect(() => {
checkSession()
return () => { if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current) }
}, [checkSession])
checkSession();
return () => {
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
};
}, [checkSession]);
const login = useCallback(async (username: string, password: string, remember = false) => {
setError(null)
try {
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password, remember_me: remember }),
})
const data = await response.json()
if (data.success) {
if (data.data?.totp_required) {
return { success: false, requires2FA: true, loginToken: data.data.login_token, remember }
const login = useCallback(
async (username: string, password: string, remember = false) => {
setError(null);
try {
const response = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password, remember_me: remember }),
});
const data = await response.json();
if (data.success) {
if (data.data?.totp_required) {
return {
success: false,
requires2FA: true,
loginToken: data.data.login_token,
remember,
};
}
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user);
sessionFetched = true;
return { success: true };
}
setAccessTokenFn(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
sessionFetched = true
return { success: true }
setError(data.error);
return { success: false, error: data.error };
} catch {
const errorMsg =
"Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu.";
setError(errorMsg);
return { success: false, error: errorMsg };
}
setError(data.error)
return { success: false, error: data.error }
} catch {
const errorMsg = 'Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}, [setAccessTokenFn])
},
[setAccessTokenFn],
);
const verify2FA = useCallback(async (loginToken: string, code: string, remember = false, isBackup = false) => {
setError(null)
try {
const response = await fetch(`${API_BASE}/login/totp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ login_token: loginToken, totp_code: code, remember_me: remember }),
})
const data = await response.json()
if (data.success) {
setAccessTokenFn(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
sessionFetched = true
return { success: true }
const verify2FA = useCallback(
async (
loginToken: string,
code: string,
remember = false,
isBackup = false,
) => {
setError(null);
try {
const response = await fetch(`${API_BASE}/login/totp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
login_token: loginToken,
totp_code: code,
remember_me: remember,
}),
});
const data = await response.json();
if (data.success) {
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user);
sessionFetched = true;
return { success: true };
}
setError(data.error);
return { success: false, error: data.error };
} catch {
const errorMsg = "Chyba pripojeni.";
setError(errorMsg);
return { success: false, error: errorMsg };
}
setError(data.error)
return { success: false, error: data.error }
} catch {
const errorMsg = 'Chyba pripojeni.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}, [setAccessTokenFn])
},
[setAccessTokenFn],
);
const logout = useCallback(async () => {
try {
const token = getAccessTokenFn()
const token = getAccessTokenFn();
await fetch(`${API_BASE}/logout`, {
method: 'POST',
method: "POST",
headers: { ...(token && { Authorization: `Bearer ${token}` }) },
credentials: 'include',
})
} catch { /* ignore */ } finally {
accessToken = null
tokenExpiresAt = null
setUser(null)
cachedUser = null
sessionFetched = false
if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null }
}
}, [getAccessTokenFn])
const apiRequest = useCallback(async (endpoint: string, options: RequestInit = {}) => {
let token = getAccessTokenFn()
if (!token && user) {
const refreshed = await silentRefresh()
if (refreshed) token = getAccessTokenFn()
}
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(options.headers as Record<string, string>) }
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: 'include' })
if (response.status === 401 && user) {
const refreshed = await silentRefresh()
if (refreshed) {
token = getAccessTokenFn()
if (token) headers['Authorization'] = `Bearer ${token}`
return fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: 'include' })
credentials: "include",
});
} catch {
/* ignore */
} finally {
accessToken = null;
tokenExpiresAt = null;
setUser(null);
cachedUser = null;
sessionFetched = false;
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
}
return response
}, [getAccessTokenFn, silentRefresh, user])
}, [getAccessTokenFn]);
const apiRequest = useCallback(
async (endpoint: string, options: RequestInit = {}) => {
let token = getAccessTokenFn();
if (!token && user) {
const refreshed = await silentRefresh();
if (refreshed) token = getAccessTokenFn();
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
credentials: "include",
});
if (response.status === 401 && user) {
const refreshed = await silentRefresh();
if (refreshed) {
token = getAccessTokenFn();
if (token) headers["Authorization"] = `Bearer ${token}`;
return fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
credentials: "include",
});
}
}
return response;
},
[getAccessTokenFn, silentRefresh, user],
);
const updateUser = useCallback((updates: Partial<User>) => {
setUser(prev => prev ? { ...prev, ...updates } : null)
}, [])
setUser((prev) => (prev ? { ...prev, ...updates } : null));
}, []);
const hasPermission = useCallback((permission: string): boolean => {
if (!user) return false
if (user.isAdmin) return true
return (user.permissions || []).includes(permission)
}, [user])
const hasPermission = useCallback(
(permission: string): boolean => {
if (!user) return false;
if (user.isAdmin) return true;
return (user.permissions || []).includes(permission);
},
[user],
);
const permissions = useMemo(() => user?.permissions || [], [user])
const permissions = useMemo(() => user?.permissions || [], [user]);
const stateValue = useMemo<AuthState>(() => ({
user, loading, error, isAuthenticated: !!user, isAdmin: user?.isAdmin || false, permissions, hasPermission,
}), [user, loading, error, permissions, hasPermission])
const stateValue = useMemo<AuthState>(
() => ({
user,
loading,
error,
isAuthenticated: !!user,
isAdmin: user?.isAdmin || false,
permissions,
hasPermission,
}),
[user, loading, error, permissions, hasPermission],
);
const actionsValue = useMemo<AuthActions>(() => ({
login, verify2FA, logout, checkSession, getAccessToken: getAccessTokenFn, apiRequest, silentRefresh, updateUser,
}), [login, verify2FA, logout, checkSession, getAccessTokenFn, apiRequest, silentRefresh, updateUser])
const actionsValue = useMemo<AuthActions>(
() => ({
login,
verify2FA,
logout,
checkSession,
getAccessToken: getAccessTokenFn,
apiRequest,
silentRefresh,
updateUser,
}),
[
login,
verify2FA,
logout,
checkSession,
getAccessTokenFn,
apiRequest,
silentRefresh,
updateUser,
],
);
return (
<AuthActionsContext.Provider value={actionsValue}>
@@ -281,26 +399,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
{children}
</AuthStateContext.Provider>
</AuthActionsContext.Provider>
)
);
}
export function useAuth(): AuthState & AuthActions {
const state = useContext(AuthStateContext)
const actions = useContext(AuthActionsContext)
if (!state || !actions) throw new Error('useAuth must be used within an AuthProvider')
return { ...state, ...actions }
const state = useContext(AuthStateContext);
const actions = useContext(AuthActionsContext);
if (!state || !actions)
throw new Error("useAuth must be used within an AuthProvider");
return { ...state, ...actions };
}
export function useAuthState(): AuthState {
const context = useContext(AuthStateContext)
if (!context) throw new Error('useAuthState must be used within an AuthProvider')
return context
const context = useContext(AuthStateContext);
if (!context)
throw new Error("useAuthState must be used within an AuthProvider");
return context;
}
export function useAuthActions(): AuthActions {
const context = useContext(AuthActionsContext)
if (!context) throw new Error('useAuthActions must be used within an AuthProvider')
return context
const context = useContext(AuthActionsContext);
if (!context)
throw new Error("useAuthActions must be used within an AuthProvider");
return context;
}
export default AuthStateContext
export default AuthStateContext;

View File

@@ -1,45 +1,48 @@
import { useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import apiFetch from '../utils/api'
import { useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
interface ApiCallResult<T> {
data: T | null
ok: boolean
response: Response | null
data: T | null;
ok: boolean;
response: Response | null;
}
export default function useApiCall() {
const alert = useAlert()
const abortRef = useRef<AbortController | null>(null)
const alert = useAlert();
const abortRef = useRef<AbortController | null>(null);
const call = useCallback(async <T = unknown>(
url: string,
options: RequestInit = {},
errorMsg = 'Chyba při načítání dat'
): Promise<ApiCallResult<T>> => {
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
const call = useCallback(
async <T = unknown>(
url: string,
options: RequestInit = {},
errorMsg = "Chyba při načítání dat",
): Promise<ApiCallResult<T>> => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await apiFetch(url, {
...options,
signal: controller.signal,
})
const data = await response.json()
if (!response.ok || !data.success) {
alert.error(data.error || errorMsg)
return { data: null, ok: false, response }
try {
const response = await apiFetch(url, {
...options,
signal: controller.signal,
});
const data = await response.json();
if (!response.ok || !data.success) {
alert.error(data.error || errorMsg);
return { data: null, ok: false, response };
}
return { data: data.data as T, ok: true, response };
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return { data: null, ok: false, response: null };
}
alert.error(errorMsg);
return { data: null, ok: false, response: null };
}
return { data: data.data as T, ok: true, response }
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
return { data: null, ok: false, response: null }
}
alert.error(errorMsg)
return { data: null, ok: false, response: null }
}
}, [alert])
},
[alert],
);
return { call }
return { call };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
import { useState, useEffect } from 'react'
import { useState, useEffect } from "react";
export default function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue
return debouncedValue;
}

View File

@@ -1,90 +1,126 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import apiFetch from '../utils/api'
import useDebounce from './useDebounce'
import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
import useDebounce from "./useDebounce";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface PaginationData {
total: number
page: number
per_page: number
total_pages: number
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface UseListDataOptions {
dataKey?: string
search?: string
sort?: string
order?: string
page?: number
perPage?: number
extraParams?: Record<string, string>
errorMsg?: string
dataKey?: string;
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
extraParams?: Record<string, string>;
errorMsg?: string;
}
export default function useListData<T = unknown>(
endpoint: string,
options: UseListDataOptions = {}
options: UseListDataOptions = {},
) {
const { dataKey, search = '', sort, order, page = 1, perPage = 25, extraParams = {}, errorMsg = 'Nepodařilo se načíst data' } = options
const alert = useAlert()
const [items, setItems] = useState<T[]>([])
const [loading, setLoading] = useState(true)
const [initialLoad, setInitialLoad] = useState(true)
const [pagination, setPagination] = useState<PaginationData | null>(null)
const abortRef = useRef<AbortController | null>(null)
const debouncedSearch = useDebounce(search, 300)
const {
dataKey,
search = "",
sort,
order,
page = 1,
perPage = 25,
extraParams = {},
errorMsg = "Nepodařilo se načíst data",
} = options;
const alert = useAlert();
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [initialLoad, setInitialLoad] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const debouncedSearch = useDebounce(search, 300);
const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
})
if (debouncedSearch) params.set('search', debouncedSearch)
if (sort) params.set('sort', sort)
if (order) params.set('order', order)
});
if (debouncedSearch) params.set("search", debouncedSearch);
if (sort) params.set("sort", sort);
if (order) params.set("order", order);
Object.entries(extraParams).forEach(([k, v]) => {
if (v) params.set(k, v)
})
if (v) params.set(k, v);
});
const url = endpoint.startsWith('/') ? `${endpoint}?${params}` : `${API_BASE}/${endpoint}?${params}`
const response = await apiFetch(url, { signal: controller.signal })
if (response.status === 401) return
const result = await response.json()
const url = endpoint.startsWith("/")
? `${endpoint}?${params}`
: `${API_BASE}/${endpoint}?${params}`;
const response = await apiFetch(url, { signal: controller.signal });
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const data = dataKey ? result.data[dataKey] : (Array.isArray(result.data) ? result.data : result.data?.items || [])
setItems(data || [])
const pag = result.pagination || (!Array.isArray(result.data) && result.data?.pagination) || null
setPagination(pag || {
total: data?.length ?? 0,
page,
per_page: perPage,
total_pages: 1,
})
const data = dataKey
? result.data[dataKey]
: Array.isArray(result.data)
? result.data
: result.data?.items || [];
setItems(data || []);
const pag =
result.pagination ||
(!Array.isArray(result.data) && result.data?.pagination) ||
null;
setPagination(
pag || {
total: data?.length ?? 0,
page,
per_page: perPage,
total_pages: 1,
},
);
} else {
alert.error(result.error || errorMsg)
alert.error(result.error || errorMsg);
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
alert.error(errorMsg)
if (err instanceof Error && err.name === "AbortError") return;
alert.error(errorMsg);
} finally {
setLoading(false)
setInitialLoad(false)
setLoading(false);
setInitialLoad(false);
}
}, [endpoint, debouncedSearch, sort, order, page, perPage, dataKey, JSON.stringify(extraParams)]) // eslint-disable-line react-hooks/exhaustive-deps
}, [
endpoint,
debouncedSearch,
sort,
order,
page,
perPage,
dataKey,
JSON.stringify(extraParams),
]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchData()
fetchData();
return () => {
if (abortRef.current) abortRef.current.abort()
}
}, [fetchData])
if (abortRef.current) abortRef.current.abort();
};
}, [fetchData]);
return { items, setItems, loading, initialLoad, pagination, refetch: fetchData }
return {
items,
setItems,
loading,
initialLoad,
pagination,
refetch: fetchData,
};
}

View File

@@ -1,14 +1,14 @@
import { useEffect } from 'react'
import { useEffect } from "react";
export default function useModalLock(isOpen: boolean): void {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = ''
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
document.body.style.overflow = "";
};
}, [isOpen]);
}

View File

@@ -1,25 +1,31 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef } from "react";
interface SortState {
sort: string
order: 'asc' | 'desc'
sort: string;
order: "asc" | "desc";
}
export default function useTableSort(defaultSort = 'id', defaultOrder: 'asc' | 'desc' = 'desc') {
const [state, setState] = useState<SortState>({ sort: defaultSort, order: defaultOrder })
const userClicked = useRef(false)
export default function useTableSort(
defaultSort = "id",
defaultOrder: "asc" | "desc" = "desc",
) {
const [state, setState] = useState<SortState>({
sort: defaultSort,
order: defaultOrder,
});
const userClicked = useRef(false);
const handleSort = useCallback((column: string) => {
userClicked.current = true
setState(prev => {
userClicked.current = true;
setState((prev) => {
if (prev.sort === column) {
return { sort: column, order: prev.order === 'asc' ? 'desc' : 'asc' }
return { sort: column, order: prev.order === "asc" ? "desc" : "asc" };
}
return { sort: column, order: 'desc' }
})
}, [])
return { sort: column, order: "desc" };
});
}, []);
const activeSort = userClicked.current ? state.sort : null
const activeSort = userClicked.current ? state.sort : null;
return { sort: state.sort, order: state.order, handleSort, activeSort }
return { sort: state.sort, order: state.order, handleSort, activeSort };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +1,169 @@
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import BulkAttendanceModal from '../components/BulkAttendanceModal'
import ShiftFormModal from '../components/ShiftFormModal'
import AttendanceShiftTable from '../components/AttendanceShiftTable'
import useModalLock from '../hooks/useModalLock'
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
import FormField from '../components/FormField'
import { formatMinutes } from '../utils/attendanceHelpers'
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import AdminDatePicker from "../components/AdminDatePicker";
import BulkAttendanceModal from "../components/BulkAttendanceModal";
import ShiftFormModal from "../components/ShiftFormModal";
import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes } from "../utils/attendanceHelpers";
interface UserTotalData {
name: string
minutes: number
working: boolean
vacation_hours: number
sick_hours: number
holiday_hours: number
unpaid_hours: number
fund: number | null
worked_hours: number
covered: number
missing: number
overtime: number
name: string;
minutes: number;
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
fund: number | null;
worked_hours: number;
covered: number;
missing: number;
overtime: number;
}
function getFundBarBackground(data: UserTotalData) {
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
if (data.covered >= (data.fund ?? 0)) return 'linear-gradient(135deg, var(--success), #059669)'
return 'var(--gradient)'
if (data.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (data.covered >= (data.fund ?? 0))
return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
}
export default function AttendanceAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const alert = useAlert();
const { hasPermission } = useAuth();
const {
loading, month, setMonth,
filterUserId, setFilterUserId,
data, hasData,
showBulkModal, setShowBulkModal,
bulkSubmitting, bulkForm, setBulkForm,
showCreateModal, setShowCreateModal,
createForm, setCreateForm,
showEditModal, setShowEditModal,
editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
loading,
month,
setMonth,
filterUserId,
setFilterUserId,
data,
hasData,
showBulkModal,
setShowBulkModal,
bulkSubmitting,
bulkForm,
setBulkForm,
showCreateModal,
setShowCreateModal,
createForm,
setCreateForm,
showEditModal,
setShowEditModal,
editingRecord,
editForm,
setEditForm,
deleteConfirm,
setDeleteConfirm,
projectList,
createProjectLogs, setCreateProjectLogs,
editProjectLogs, setEditProjectLogs,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
openEditModal, handleEditSubmit,
handleDelete, handlePrint
} = useAttendanceAdmin({ alert })
createProjectLogs,
setCreateProjectLogs,
editProjectLogs,
setEditProjectLogs,
openCreateModal,
handleCreateShiftDateChange,
handleCreateSubmit,
openBulkModal,
toggleBulkUser,
toggleAllBulkUsers,
handleBulkSubmit,
openEditModal,
handleEditSubmit,
handleDelete,
handlePrint,
} = useAttendanceAdmin({ alert });
useModalLock(showBulkModal)
useModalLock(showEditModal)
useModalLock(showCreateModal)
useModalLock(showBulkModal);
useModalLock(showEditModal);
useModalLock(showCreateModal);
if (!hasPermission('attendance.admin')) return <Forbidden />
if (!hasPermission("attendance.admin")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad = loading && data.records.length === 0 && Object.keys(data.user_totals).length === 0
const isInitialLoad =
loading &&
data.records.length === 0 &&
Object.keys(data.user_totals).length === 0;
if (isInitialLoad) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
</div>
</div>
</div>
<div className="admin-grid admin-grid-3">
{[0, 1, 2].map(i => (
{[0, 1, 2].map((i) => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
<div className="admin-skeleton-line w-1/2" />
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
<div className="admin-skeleton-line w-full" style={{ height: '4px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "4px" }}
/>
</div>
</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
@@ -113,7 +173,7 @@ export default function AttendanceAdmin() {
</div>
</div>
</div>
)
);
}
return (
@@ -134,7 +194,15 @@ export default function AttendanceAdmin() {
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
@@ -152,7 +220,14 @@ export default function AttendanceAdmin() {
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -185,7 +260,9 @@ export default function AttendanceAdmin() {
>
<option value="">Všichni</option>
{data.users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
@@ -202,68 +279,110 @@ export default function AttendanceAdmin() {
transition={{ duration: 0.25, delay: 0.09 }}
>
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData
const ut = userData as UserTotalData;
return (
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span className={`attendance-working-badge ${ut.working ? 'working' : 'finished'}`}>
{ut.working ? '\u2713' : '\u2717'}
<span
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
>
{ut.working ? "\u2713" : "\u2717"}
</span>
</div>
<div className="admin-stat-value">{formatMinutes(ut.minutes)}</div>
<div className="admin-stat-value">
{formatMinutes(ut.minutes)}
</div>
<div className="admin-stat-label">odpracováno</div>
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
<div
style={{
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
}}
>
{ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">Dov: {ut.vacation_hours}h</span>
<span className="attendance-leave-badge badge-vacation">
Dov: {ut.vacation_hours}h
</span>
)}
{ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">Nem: {ut.sick_hours}h</span>
<span className="attendance-leave-badge badge-sick">
Nem: {ut.sick_hours}h
</span>
)}
{ut.holiday_hours > 0 && (
<span className="attendance-leave-badge badge-holiday">Sv: {ut.holiday_hours}h</span>
<span className="attendance-leave-badge badge-holiday">
Sv: {ut.holiday_hours}h
</span>
)}
{ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">Nep: {ut.unpaid_hours}h</span>
<span className="attendance-leave-badge badge-unpaid">
Nep: {ut.unpaid_hours}h
</span>
)}
</div>
{ut.fund !== null && (
<div className="mt-2">
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem' }}>
<span>Fond: {ut.worked_hours}h / {ut.fund}h</span>
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {ut.worked_hours}h / {ut.fund}h
</span>
{ut.overtime > 0 && (
<span className="text-warning fw-600">+{ut.overtime}h</span>
<span className="text-warning fw-600">
+{ut.overtime}h
</span>
)}
{ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">-{ut.missing}h</span>
<span className="text-danger fw-600">
-{ut.missing}h
</span>
)}
</div>
<div style={{
marginTop: '0.375rem',
height: '4px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{data.leave_balances[uid] && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
Zbývá dovolené: {data.leave_balances[uid].vacation_remaining.toFixed(1)}h / {data.leave_balances[uid].vacation_total}h
<div
className="text-secondary"
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }}
>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h
/ {data.leave_balances[uid].vacation_total}h
</div>
)}
</div>
</div>
)
);
})}
</motion.div>
)}
@@ -337,5 +456,5 @@ export default function AttendanceAdmin() {
confirmVariant="danger"
/>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,113 @@
import { useState, useEffect } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useState, useEffect } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number | string
name: string
id: number | string;
name: string;
}
interface CreateForm {
user_id: string
shift_date: string
leave_type: string
leave_hours: number
arrival_date: string
arrival_time: string
break_start_date: string
break_start_time: string
break_end_date: string
break_end_time: string
departure_date: string
departure_time: string
notes: string
user_id: string;
shift_date: string;
leave_type: string;
leave_hours: number;
arrival_date: string;
arrival_time: string;
break_start_date: string;
break_start_time: string;
break_end_date: string;
break_end_time: string;
departure_date: string;
departure_time: string;
notes: string;
}
export default function AttendanceCreate() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [users, setUsers] = useState<User[]>([])
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const today = new Date().toISOString().split('T')[0]
const today = new Date().toISOString().split("T")[0];
const [form, setForm] = useState<CreateForm>({
user_id: '',
user_id: "",
shift_date: today,
leave_type: 'work',
leave_type: "work",
leave_hours: 8,
arrival_date: today,
arrival_time: '',
arrival_time: "",
break_start_date: today,
break_start_time: '',
break_start_time: "",
break_end_date: today,
break_end_time: '',
break_end_time: "",
departure_date: today,
departure_time: '',
notes: ''
})
departure_time: "",
notes: "",
});
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users`)
const result = await response.json()
const response = await apiFetch(`${API_BASE}/users`);
const result = await response.json();
if (result.success) {
setUsers(Array.isArray(result.data) ? result.data : result.data?.items || [])
setUsers(
Array.isArray(result.data) ? result.data : result.data?.items || [],
);
}
} catch {
alert.error('Nepodařilo se načíst uživatele')
alert.error("Nepodařilo se načíst uživatele");
} finally {
setLoading(false)
setLoading(false);
}
}
};
fetchUsers()
}, [alert])
fetchUsers();
}, [alert]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!form.user_id || !form.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
alert.error("Vyplňte zaměstnance a datum směny");
return;
}
setSubmitting(true)
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
alert.success(result.message)
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`)
alert.success(result.message);
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSubmitting(false)
setSubmitting(false);
}
}
};
const handleShiftDateChange = (newDate: string) => {
setForm({
@@ -114,33 +116,42 @@ export default function AttendanceCreate() {
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate
})
}
departure_date: newDate,
});
};
const isWorkType = form.leave_type === 'work'
const isWorkType = form.leave_type === "work";
if (!hasPermission('attendance.admin')) return <Forbidden />
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div>
<div className="admin-card" style={{ maxWidth: '600px' }}>
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-card" style={{ maxWidth: "600px" }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i}>
<div className="admin-skeleton-line w-1/4" style={{ marginBottom: '0.5rem', height: '10px' }} />
<div
className="admin-skeleton-line w-1/4"
style={{ marginBottom: "0.5rem", height: "10px" }}
/>
<div className="admin-skeleton-line w-full h-10" />
</div>
))}
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
</div>
</div>
)
);
}
return (
@@ -155,7 +166,10 @@ export default function AttendanceCreate() {
<h1 className="admin-page-title">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
@@ -163,7 +177,7 @@ export default function AttendanceCreate() {
<motion.div
className="admin-card"
style={{ maxWidth: '600px' }}
style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
@@ -174,13 +188,17 @@ export default function AttendanceCreate() {
<FormField label="Zaměstnanec" required>
<select
value={form.user_id}
onChange={(e) => setForm({ ...form, user_id: e.target.value })}
onChange={(e) =>
setForm({ ...form, user_id: e.target.value })
}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
@@ -197,7 +215,9 @@ export default function AttendanceCreate() {
<FormField label="Typ záznamu" required>
<select
value={form.leave_type}
onChange={(e) => setForm({ ...form, leave_type: e.target.value })}
onChange={(e) =>
setForm({ ...form, leave_type: e.target.value })
}
className="admin-form-select"
>
<option value="work">Práce</option>
@@ -213,13 +233,20 @@ export default function AttendanceCreate() {
<input
type="number"
value={form.leave_hours}
onChange={(e) => setForm({ ...form, leave_hours: parseFloat(e.target.value) })}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">Výchozí 8 hodin pro celý den</small>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
@@ -230,14 +257,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) => setForm({ ...form, arrival_date: val })}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) => setForm({ ...form, arrival_time: val })}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
@@ -247,14 +278,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) => setForm({ ...form, break_start_date: val })}
onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) => setForm({ ...form, break_start_time: val })}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
@@ -264,14 +299,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) => setForm({ ...form, break_end_date: val })}
onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) => setForm({ ...form, break_end_time: val })}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
@@ -281,14 +320,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) => setForm({ ...form, departure_date: val })}
onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) => setForm({ ...form, departure_time: val })}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
@@ -305,7 +348,10 @@ export default function AttendanceCreate() {
</FormField>
<div className="admin-form-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
@@ -313,12 +359,12 @@ export default function AttendanceCreate() {
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? 'Ukládám...' : 'Uložit'}
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,164 +1,210 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } from '../utils/attendanceHelpers'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import {
formatDate,
formatDatetime,
formatTime,
calculateWorkMinutes,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
calculateWorkMinutesPrint,
formatTimeOrDatetimePrint,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface ProjectLog {
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number
shift_date: string
leave_type?: string
leave_hours?: number
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
notes?: string
project_name?: string
project_logs?: ProjectLog[]
id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
project_name?: string;
project_logs?: ProjectLog[];
}
const MONTH_NAMES = [
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec'
]
"Leden",
"Únor",
"Březen",
"Duben",
"Květen",
"Červen",
"Červenec",
"Srpen",
"Září",
"Říjen",
"Listopad",
"Prosinec",
];
const formatBreakRange = (record: AttendanceRecord): string => {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
return `${formatTime(record.break_start)} - ?`;
}
return '—'
}
return "—";
};
const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
>
{record.project_logs.map((log, i) => {
let h: number, m: number, isActive = false
let h: number,
m: number,
isActive = false;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0
m = parseInt(String(log.minutes)) || 0
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
);
h = Math.floor(mins / 60);
m = mins % 60;
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
<span
key={log.id || i}
className="admin-badge"
style={{
fontSize: "0.7rem",
display: "inline-block",
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
</span>
)
);
})}
</div>
)
);
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
return (
<span
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.75rem" }}
>
{record.project_name}
</span>
);
}
return '—'
}
return "—";
};
export default function AttendanceHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const printRef = useRef<HTMLDivElement>(null)
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [records, setRecords] = useState<AttendanceRecord[]>([])
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [records, setRecords] = useState<AttendanceRecord[]>([]);
const fetchData = useCallback(async () => {
setLoading(true)
setLoading(true);
try {
const [yearStr, monthStr] = month.split('-')
const response = await apiFetch(`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ''}`)
if (response.status === 401) return
const result = await response.json()
const [yearStr, monthStr] = month.split("-");
const response = await apiFetch(
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRecords(result.data)
setRecords(result.data);
}
} catch {
alert.error('Nepodařilo se načíst data')
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false)
setLoading(false);
}
}, [month, alert, user?.id])
}, [month, alert, user?.id]);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
// Compute totals client-side from raw records
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split('-')
const monthIndex = parseInt(monthStr, 10) - 1
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`
const [yearStr, monthStr] = month.split("-");
const monthIndex = parseInt(monthStr, 10) - 1;
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`;
let totalMinutes = 0
let vacationHours = 0
let sickHours = 0
let holidayHours = 0
let unpaidHours = 0
let totalMinutes = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0;
for (const record of records) {
const leaveType = record.leave_type || 'work'
if (leaveType === 'work') {
totalMinutes += calculateWorkMinutes(record)
const leaveType = record.leave_type || "work";
if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record);
} else {
const hours = Number(record.leave_hours) || 8
if (leaveType === 'vacation') vacationHours += hours
else if (leaveType === 'sick') sickHours += hours
else if (leaveType === 'holiday') holidayHours += hours
else if (leaveType === 'unpaid') unpaidHours += hours
const hours = Number(record.leave_hours) || 8;
if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "holiday") holidayHours += hours;
else if (leaveType === "unpaid") unpaidHours += hours;
}
}
// Compute monthly fund (working days * 8h)
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10)
const mo = parseInt(monthStr, 10) - 1
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
// Count holiday records to subtract from business days
const holidayDays = records.filter(r => (r.leave_type || 'work') === 'holiday').length
let businessDays = 0
const cur = new Date(yr, mo, 1)
const holidayDays = records.filter(
(r) => (r.leave_type || "work") === "holiday",
).length;
let businessDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
const dow = cur.getDay()
if (dow !== 0 && dow !== 6) businessDays++
cur.setDate(cur.getDate() + 1)
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) businessDays++;
cur.setDate(cur.getDate() + 1);
}
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
businessDays = Math.max(0, businessDays - holidayDays)
const fund = businessDays * 8
const worked = Math.round((totalMinutes / 60) * 100) / 100
businessDays = Math.max(0, businessDays - holidayDays);
const fund = businessDays * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
const leaveHours = vacationHours + sickHours
const covered = Math.round((worked + leaveHours) * 100) / 100
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100)
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100)
const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 100) / 100;
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
const monthlyFund = {
fund,
@@ -167,18 +213,26 @@ export default function AttendanceHistory() {
covered,
remaining,
overtime,
}
};
return { monthName, totalMinutes, vacationHours, sickHours, holidayHours, unpaidHours, monthlyFund }
}, [records, month])
return {
monthName,
totalMinutes,
vacationHours,
sickHours,
holidayHours,
unpaidHours,
monthlyFund,
};
}, [records, month]);
if (!hasPermission('attendance.history')) return <Forbidden />
if (!hasPermission("attendance.history")) return <Forbidden />;
const handlePrint = () => {
if (!printRef.current) return
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
if (!printRef.current) return;
const content = printRef.current.innerHTML;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
@@ -266,12 +320,12 @@ export default function AttendanceHistory() {
${content}
</body>
</html>
`)
printWindow.document.close()
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print()
}
}
printWindow.print();
};
};
return (
<div>
@@ -292,7 +346,15 @@ export default function AttendanceHistory() {
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
@@ -332,33 +394,81 @@ export default function AttendanceHistory() {
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-row" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line" style={{ width: '48px', height: '48px', borderRadius: '12px', flexShrink: 0 }} />
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
<div className="admin-skeleton-row" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line"
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
flexShrink: 0,
}}
/>
<div className="flex-1">
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-full" style={{ height: '6px', borderRadius: '3px' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px', marginTop: '0.5rem' }} />
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "6px", borderRadius: "3px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px", marginTop: "0.5rem" }}
/>
</div>
</div>
</div>
)}
{!loading && computed.monthlyFund && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="admin-stat-icon info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: '200px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.375rem' }}>
<span style={{ fontWeight: 600, fontSize: '1rem', color: 'var(--text-primary)' }}>
Fond: {computed.monthlyFund.worked}h / {computed.monthlyFund.fund}h
<div style={{ flex: 1, minWidth: "200px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span className="text-secondary" style={{ fontSize: '0.8125rem' }}>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
</span>
</div>
@@ -367,23 +477,41 @@ export default function AttendanceHistory() {
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
background: computed.monthlyFund.covered >= computed.monthlyFund.fund
? 'linear-gradient(135deg, var(--success), #059669)'
: 'var(--gradient)'
background:
computed.monthlyFund.covered >=
computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div className="text-muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginTop: '0.375rem' }}>
<div
className="text-muted"
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.75rem",
marginTop: "0.375rem",
}}
>
<span>
{'Pokryto: '}{computed.monthlyFund.covered}h (práce {computed.monthlyFund.worked}h
{computed.vacationHours > 0 && ` + dovolená ${computed.vacationHours}h`}
{computed.sickHours > 0 && ` + nemoc ${computed.sickHours}h`}
{computed.holidayHours > 0 && ` + svátek ${computed.holidayHours}h`}
{computed.unpaidHours > 0 && ` + neplacené ${computed.unpaidHours}h`}
{"Pokryto: "}
{computed.monthlyFund.covered}h (práce{" "}
{computed.monthlyFund.worked}h
{computed.vacationHours > 0 &&
` + dovolená ${computed.vacationHours}h`}
{computed.sickHours > 0 &&
` + nemoc ${computed.sickHours}h`}
{computed.holidayHours > 0 &&
` + svátek ${computed.holidayHours}h`}
{computed.unpaidHours > 0 &&
` + neplacené ${computed.unpaidHours}h`}
)
</span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">Přesčas: +{computed.monthlyFund.overtime}h</span>
<span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
@@ -392,7 +520,14 @@ export default function AttendanceHistory() {
</div>
)}
{!loading && !computed.monthlyFund && (
<div className="text-muted" style={{ fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem 0' }}>
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici
</div>
)}
@@ -408,8 +543,8 @@ export default function AttendanceHistory() {
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
@@ -440,34 +575,53 @@ export default function AttendanceHistory() {
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record)
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono">
{isLeave ? '—' : formatBreakRange(record)}
{isLeave ? "—" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? formatMinutes(workMinutes, true) : '—'}</td>
<td>
{renderProjectCell(record)}
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{record.notes || ''}
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
</tr>
)
);
})}
</tbody>
</table>
@@ -478,109 +632,216 @@ export default function AttendanceHistory() {
{/* Hidden Print Content */}
{records.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}>
<div ref={printRef} style={{ display: "none" }}>
<table className="print-wrapper-table">
<thead>
<tr><td>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
<tr>
<td>
<div className="print-header">
<div className="print-header-left">
<img
src="/images/logo-light.png"
alt="BOHA"
className="print-logo"
/>
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{computed.monthName}</div>
<div className="filters">
Zaměstnanec: {user?.fullName || ""}
</div>
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{computed.monthName}</div>
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div>
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</td>
</tr>
</thead>
<tbody>
<tr><td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ''}</h3>
<span className="total">Odpracováno: {formatMinutes(computed.totalMinutes, true)}</span>
</div>
{(computed.vacationHours > 0 || computed.sickHours > 0 || computed.holidayHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && <><span className="leave-badge badge-vacation">Dovolená: {computed.vacationHours}h</span> </>}
{computed.sickHours > 0 && <><span className="leave-badge badge-sick">Nemoc: {computed.sickHours}h</span> </>}
{computed.holidayHours > 0 && <><span className="leave-badge badge-holiday">Svátek: {computed.holidayHours}h</span> </>}
<tr>
<td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ""}</h3>
<span className="total">
Odpracováno:{" "}
{formatMinutes(computed.totalMinutes, true)}
</span>
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{[...records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
{(computed.vacationHours > 0 ||
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && (
<>
<span className="leave-badge badge-vacation">
Dovolená: {computed.vacationHours}h
</span>{" "}
</>
)}
{computed.sickHours > 0 && (
<>
<span className="leave-badge badge-sick">
Nemoc: {computed.sickHours}h
</span>{" "}
</>
)}
{computed.holidayHours > 0 && (
<>
<span className="leave-badge badge-holiday">
Svátek: {computed.holidayHours}h
</span>{" "}
</>
)}
</div>
)}
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td className="text-center">
{isLeave || !record.break_start || !record.break_end
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
}
</td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style={{ fontSize: '8px' }}>
{(record.project_logs && record.project_logs.length > 0)
? record.project_logs.map((log, i) => {
let h: number, m: number
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0
} else if (log.started_at && log.ended_at) {
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at).getTime()) / 60000))
h = Math.floor(mins2 / 60); m = mins2 % 60
} else { h = 0; m = 0 }
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
})
: record.project_name || '—'}
</td>
<td>{record.notes || ''}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">Odpracováno:</td>
<td className="text-center">{formatMinutes(computed.totalMinutes, true)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
</td></tr>
<table>
<thead>
<tr>
<th style={{ width: "70px" }}>Datum</th>
<th style={{ width: "70px" }}>Typ</th>
<th className="text-center" style={{ width: "70px" }}>
Příchod
</th>
<th className="text-center" style={{ width: "90px" }}>
Pauza
</th>
<th className="text-center" style={{ width: "70px" }}>
Odchod
</th>
<th className="text-center" style={{ width: "80px" }}>
Hodiny
</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{[...records]
.sort((a, b) =>
a.shift_date.localeCompare(b.shift_date),
)
.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes =
calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td>
<span
className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="text-center">
{isLeave
? "—"
: formatTimeOrDatetimePrint(
record.arrival_time,
record.shift_date,
)}
</td>
<td className="text-center">
{isLeave ||
!record.break_start ||
!record.break_end
? "—"
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`}
</td>
<td className="text-center">
{isLeave
? "—"
: formatTimeOrDatetimePrint(
record.departure_time,
record.shift_date,
)}
</td>
<td className="text-center">
{workMinutes > 0
? `${hours}:${String(mins).padStart(2, "0")}`
: "—"}
</td>
<td style={{ fontSize: "8px" }}>
{record.project_logs &&
record.project_logs.length > 0
? record.project_logs.map((log, i) => {
let h: number, m: number;
if (
log.hours !== null &&
log.hours !== undefined
) {
h = parseInt(String(log.hours)) || 0;
m =
parseInt(String(log.minutes)) || 0;
} else if (
log.started_at &&
log.ended_at
) {
const mins2 = Math.max(
0,
Math.floor(
(new Date(
log.ended_at,
).getTime() -
new Date(
log.started_at,
).getTime()) /
60000,
),
);
h = Math.floor(mins2 / 60);
m = mins2 % 60;
} else {
h = 0;
m = 0;
}
return (
<div key={log.id || i}>
{log.project_name ||
`#${log.project_id}`}{" "}
({h}:{String(m).padStart(2, "0")}h)
</div>
);
})
: record.project_name || "—"}
</td>
<td>{record.notes || ""}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">
Odpracováno:
</td>
<td className="text-center">
{formatMinutes(computed.totalMinutes, true)}
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
)
);
}

View File

@@ -1,154 +1,158 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useNavigate, useParams, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { formatDate, formatTime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import { formatDate, formatTime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
declare const L: any
declare const L: any;
interface LocationRecord {
user_name: string
shift_date: string
arrival_time?: string | null
departure_time?: string | null
arrival_lat?: string | number | null
arrival_lng?: string | number | null
arrival_accuracy?: number | null
arrival_address?: string | null
departure_lat?: string | number | null
departure_lng?: string | number | null
departure_accuracy?: number | null
departure_address?: string | null
user_name: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
export default function AttendanceLocation() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [loading, setLoading] = useState(true)
const [record, setRecord] = useState<LocationRecord | null>(null)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstanceRef = useRef<unknown>(null)
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(true);
const [record, setRecord] = useState<LocationRecord | null>(null);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<unknown>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance?action=location&id=${id}`)
const result = await response.json()
const response = await apiFetch(
`${API_BASE}/attendance?action=location&id=${id}`,
);
const result = await response.json();
if (result.success) {
const raw = result.data.record || result.data
const raw = result.data.record || result.data;
// Enrich with user_name from nested users relation
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || ''
setRecord({ ...raw, user_name: userName })
: raw.user_name || "";
setRecord({ ...raw, user_name: userName });
} else {
alert.error('Záznam nebyl nalezen')
navigate('/attendance/admin')
alert.error("Záznam nebyl nalezen");
navigate("/attendance/admin");
}
} catch {
alert.error('Nepodařilo se načíst data')
navigate('/attendance/admin')
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
} finally {
setLoading(false)
setLoading(false);
}
}
};
fetchData()
}, [id, alert, navigate])
fetchData();
}, [id, alert, navigate]);
useEffect(() => {
if (!record || loading) return
if (!record || loading) return;
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
if (!hasAnyLocation || !mapRef.current) return
if (!hasAnyLocation || !mapRef.current) return;
const loadLeaflet = async () => {
if ((window as unknown as Record<string, unknown>).L) {
initMap()
return
initMap();
return;
}
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
document.head.appendChild(link)
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(link);
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.onload = initMap
document.body.appendChild(script)
}
const script = document.createElement("script");
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
script.onload = initMap;
document.body.appendChild(script);
};
const initMap = () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove()
(mapInstanceRef.current as { remove: () => void }).remove();
}
const map = L.map(mapRef.current!)
mapInstanceRef.current = map
const map = L.map(mapRef.current!);
mapInstanceRef.current = map;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
}).addTo(map);
const bounds: [number, number][] = []
const bounds: [number, number][] = [];
interface LocationPoint {
lat: number
lng: number
type: string
label: string
time: string
accuracy: number
lat: number;
lng: number;
type: string;
label: string;
time: string;
accuracy: number;
}
const locations: LocationPoint[] = []
const locations: LocationPoint[] = [];
if (hasArrivalLocation) {
locations.push({
lat: parseFloat(String(record.arrival_lat)),
lng: parseFloat(String(record.arrival_lng)),
type: 'arrival',
label: 'Příchod',
type: "arrival",
label: "Příchod",
time: formatTime(record.arrival_time),
accuracy: Number(record.arrival_accuracy) || 0
})
accuracy: Number(record.arrival_accuracy) || 0,
});
}
if (hasDepartureLocation) {
locations.push({
lat: parseFloat(String(record.departure_lat)),
lng: parseFloat(String(record.departure_lng)),
type: 'departure',
label: 'Odchod',
type: "departure",
label: "Odchod",
time: formatTime(record.departure_time),
accuracy: Number(record.departure_accuracy) || 0
})
accuracy: Number(record.departure_accuracy) || 0,
});
}
locations.forEach(loc => {
const color = loc.type === 'arrival' ? '#22c55e' : '#ef4444'
locations.forEach((loc) => {
const color = loc.type === "arrival" ? "#22c55e" : "#ef4444";
const marker = L.circleMarker([loc.lat, loc.lng], {
radius: 10,
fillColor: color,
color: '#fff',
color: "#fff",
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(map)
fillOpacity: 0.8,
}).addTo(map);
marker.bindPopup(`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`)
marker.bindPopup(
`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`,
);
if (loc.accuracy > 0) {
L.circle([loc.lat, loc.lng], {
@@ -157,55 +161,78 @@ export default function AttendanceLocation() {
color: color,
weight: 1,
opacity: 0.3,
fillOpacity: 0.1
}).addTo(map)
fillOpacity: 0.1,
}).addTo(map);
}
bounds.push([loc.lat, loc.lng])
})
bounds.push([loc.lat, loc.lng]);
});
if (bounds.length === 1) {
map.setView(bounds[0], 16)
map.setView(bounds[0], 16);
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [50, 50] })
map.fitBounds(bounds, { padding: [50, 50] });
}
}
};
loadLeaflet()
loadLeaflet();
return () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove()
mapInstanceRef.current = null
(mapInstanceRef.current as { remove: () => void }).remove();
mapInstanceRef.current = null;
}
}
}, [record, loading])
};
}, [record, loading]);
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`
}
if (!datetime) return "—";
const d = new Date(datetime);
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
};
if (!hasPermission('attendance.admin')) return <Forbidden />
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div
style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
>
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton-line" style={{ width: '100%', height: '300px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "300px", borderRadius: "8px" }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem' }}>
{[0, 1].map(i => (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.25rem",
}}
>
{[0, 1].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line h-8" style={{ width: '50%' }} />
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "50%" }}
/>
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-3/4" />
</div>
@@ -213,18 +240,20 @@ export default function AttendanceLocation() {
))}
</div>
</div>
)
);
}
if (!record) {
return null
return null;
}
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
const shiftDateStr = record.shift_date.includes('T') ? record.shift_date.split('T')[0] : record.shift_date
const month = shiftDateStr.substring(0, 7)
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
const shiftDateStr = record.shift_date.includes("T")
? record.shift_date.split("T")[0]
: record.shift_date;
const month = shiftDateStr.substring(0, 7);
return (
<div>
@@ -238,7 +267,10 @@ export default function AttendanceLocation() {
<h1 className="admin-page-title">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link to={`/attendance/admin?month=${month}`} className="admin-btn admin-btn-secondary">
<Link
to={`/attendance/admin?month=${month}`}
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
@@ -257,18 +289,19 @@ export default function AttendanceLocation() {
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div
ref={mapRef}
className="attendance-location-map"
/>
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div className={`attendance-location-card ${!hasArrivalLocation ? 'empty' : ''}`}>
<div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.arrival_time ? formatDatetimeLocal(record.arrival_time) : '—'}
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</div>
{hasArrivalLocation ? (
<>
@@ -277,7 +310,8 @@ export default function AttendanceLocation() {
</div>
<div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy && ` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
@@ -297,10 +331,14 @@ export default function AttendanceLocation() {
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div className={`attendance-location-card ${!hasDepartureLocation ? 'empty' : ''}`}>
<div
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.departure_time ? formatDatetimeLocal(record.departure_time) : '—'}
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</div>
{hasDepartureLocation ? (
<>
@@ -309,7 +347,8 @@ export default function AttendanceLocation() {
</div>
<div className="attendance-location-coords">
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy && ` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
@@ -331,5 +370,5 @@ export default function AttendanceLocation() {
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,220 +1,263 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import Forbidden from '../components/Forbidden'
import Pagination from '../components/Pagination'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import { czechPlural } from '../utils/formatters'
import apiFetch from '../utils/api'
import { useState, useEffect, useCallback } from "react";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden";
import Pagination from "../components/Pagination";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const ACTION_LABELS: Record<string, string> = {
create: 'Vytvoření',
update: 'Úprava',
delete: 'Smazání',
login: 'Přihlášení',
login_failed: 'Neúspěšné přihlášení',
logout: 'Odhlášení',
view: 'Zobrazení',
activate: 'Aktivace',
deactivate: 'Deaktivace',
password_change: 'Změna hesla',
permission_change: 'Změna oprávnění',
access_denied: 'Přístup odepřen',
}
create: "Vytvoření",
update: "Úprava",
delete: "Smazání",
login: "Přihlášení",
login_failed: "Neúspěšné přihlášení",
logout: "Odhlášení",
view: "Zobrazení",
activate: "Aktivace",
deactivate: "Deaktivace",
password_change: "Změna hesla",
permission_change: "Změna oprávnění",
access_denied: "Přístup odepřen",
};
const ACTION_BADGE_CLASS: Record<string, string> = {
create: 'admin-badge-success',
update: 'admin-badge-info',
delete: 'admin-badge-danger',
login: 'admin-badge-secondary',
login_failed: 'admin-badge-danger',
logout: 'admin-badge-secondary',
view: 'admin-badge-info',
activate: 'admin-badge-success',
deactivate: 'admin-badge-warning',
password_change: 'admin-badge-info',
permission_change: 'admin-badge-warning',
access_denied: 'admin-badge-danger',
}
create: "admin-badge-success",
update: "admin-badge-info",
delete: "admin-badge-danger",
login: "admin-badge-secondary",
login_failed: "admin-badge-danger",
logout: "admin-badge-secondary",
view: "admin-badge-info",
activate: "admin-badge-success",
deactivate: "admin-badge-warning",
password_change: "admin-badge-info",
permission_change: "admin-badge-warning",
access_denied: "admin-badge-danger",
};
const ENTITY_TYPE_LABELS: Record<string, string> = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
user: "Uživatel",
attendance: "Docházka",
leave_request: "Žádost o nepřítomnost",
offers_quotation: "Nabídka",
offers_customer: "Zákazník",
offers_item_template: "Šablona položky",
offers_scope_template: "Šablona rozsahu",
offers_settings: "Nastavení nabídek",
orders_order: "Objednávka",
invoices_invoice: "Faktura",
projects_project: "Projekt",
role: "Role",
trips: "Jízda",
vehicles: "Vozidlo",
bank_account: "Bankovní účet",
};
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
value,
label,
}));
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
([value, label]) => ({ value, label }),
);
interface AuditLogEntry {
id: number
created_at: string
username: string | null
action: string
entity_type: string | null
description: string | null
user_ip: string | null
id: number;
created_at: string;
username: string | null;
action: string;
entity_type: string | null;
description: string | null;
user_ip: string | null;
}
interface PaginationData {
total: number
page: number
per_page: number
total_pages: number
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface Filters {
search: string
action: string
entity_type: string
date_from: string
date_to: string
search: string;
action: string;
entity_type: string;
date_from: string;
date_to: string;
}
export default function AuditLog() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [logs, setLogs] = useState<AuditLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState<PaginationData | null>(null)
const { hasPermission } = useAuth();
const alert = useAlert();
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const [filters, setFilters] = useState<Filters>({
search: '',
action: '',
entity_type: '',
date_from: '',
date_to: '',
})
const [showCleanup, setShowCleanup] = useState(false)
const [cleanupDays, setCleanupDays] = useState(90)
const [cleaning, setCleaning] = useState(false)
search: "",
action: "",
entity_type: "",
date_from: "",
date_to: "",
});
const [showCleanup, setShowCleanup] = useState(false);
const [cleanupDays, setCleanupDays] = useState(90);
const [cleaning, setCleaning] = useState(false);
const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
const fetchLogs = useCallback(
async (page = 1, perPage = 50) => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
if (filters.search) params.set('search', filters.search)
if (filters.action) params.set('action', filters.action)
if (filters.entity_type) params.set('entity_type', filters.entity_type)
if (filters.date_from) params.set('date_from', filters.date_from)
if (filters.date_to) params.set('date_to', filters.date_to)
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entity_type) params.set("entity_type", filters.entity_type);
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const response = await apiFetch(`${API_BASE}/audit-log?${params.toString()}`)
const data = await response.json()
const response = await apiFetch(
`${API_BASE}/audit-log?${params.toString()}`,
);
const data = await response.json();
if (data.success) {
setLogs(Array.isArray(data.data) ? data.data : [])
setPagination({
total: data.pagination?.total ?? 0,
page: data.pagination?.page ?? 1,
per_page: data.pagination?.limit ?? 50,
total_pages: data.pagination?.total_pages ?? 1,
})
} else {
alert.error(data.error || 'Nepodařilo se načíst audit log')
if (data.success) {
setLogs(Array.isArray(data.data) ? data.data : []);
setPagination({
total: data.pagination?.total ?? 0,
page: data.pagination?.page ?? 1,
per_page: data.pagination?.limit ?? 50,
total_pages: data.pagination?.total_pages ?? 1,
});
} else {
alert.error(data.error || "Nepodařilo se načíst audit log");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [filters, alert])
},
[filters, alert],
);
useEffect(() => {
fetchLogs()
}, [fetchLogs])
fetchLogs();
}, [fetchLogs]);
if (!hasPermission('settings.audit')) {
return <Forbidden />
if (!hasPermission("settings.audit")) {
return <Forbidden />;
}
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
setFilters((prev) => ({ ...prev, [key]: value }));
};
const handlePageChange = (newPage: number) => {
fetchLogs(newPage, pagination?.per_page || 50)
}
fetchLogs(newPage, pagination?.per_page || 50);
};
const handlePerPageChange = (newPerPage: number) => {
fetchLogs(1, newPerPage)
}
fetchLogs(1, newPerPage);
};
const handleCleanup = async () => {
setCleaning(true)
setCleaning(true);
try {
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days: cleanupDays }),
})
const data = await response.json()
});
const data = await response.json();
if (data.success) {
alert.success(data.message)
setShowCleanup(false)
fetchLogs()
alert.success(data.message);
setShowCleanup(false);
fetchLogs();
} else {
alert.error(data.error)
alert.error(data.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setCleaning(false)
setCleaning(false);
}
}
};
const formatDatetime = (dateString: string | null): string => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('cs-CZ')
}
if (!dateString) return "-";
return new Date(dateString).toLocaleString("cs-CZ");
};
if (loading && logs.length === 0) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '160px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '100px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "160px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "100px" }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '4px' }} />
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "4px" }}
/>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line" style={{ width: '120px' }} />
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div className="admin-skeleton-line" style={{ width: '70px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div
className="admin-skeleton-line"
style={{ width: "120px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "70px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line flex-1" />
<div className="admin-skeleton-line" style={{ width: '90px' }} />
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
</div>
))}
</div>
</div>
</div>
)
);
}
return (
@@ -229,7 +272,8 @@ export default function AuditLog() {
<h1 className="admin-page-title">Audit log</h1>
{pagination && (
<p className="admin-page-subtitle">
{pagination.total} {czechPlural(pagination.total, 'záznam', 'záznamy', 'záznamů')}
{pagination.total}{" "}
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
</p>
)}
</div>
@@ -237,7 +281,14 @@ export default function AuditLog() {
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => setShowCleanup(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="14"
height="14"
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>
@@ -247,7 +298,10 @@ export default function AuditLog() {
{showCleanup && (
<div className="admin-modal-overlay" style={{ opacity: 1 }}>
<div className="admin-modal-backdrop" onClick={() => !cleaning && setShowCleanup(false)} />
<div
className="admin-modal-backdrop"
onClick={() => !cleaning && setShowCleanup(false)}
/>
<motion.div
className="admin-modal admin-confirm-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -256,14 +310,23 @@ export default function AuditLog() {
>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-danger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="24"
height="24"
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>
</div>
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
<div style={{ margin: '0.75rem auto', maxWidth: '200px' }}>
<p className="admin-confirm-message">
Smazat záznamy starší než:
</p>
<div style={{ margin: "0.75rem auto", maxWidth: "200px" }}>
<select
className="admin-form-select"
value={cleanupDays}
@@ -277,7 +340,12 @@ export default function AuditLog() {
<option value={0}>Vše</option>
</select>
</div>
<p className="admin-confirm-message" style={{ fontSize: '12px', opacity: 0.6 }}>Tato akce je nevratná.</p>
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</div>
<div className="admin-modal-footer">
<button
@@ -294,7 +362,7 @@ export default function AuditLog() {
className="admin-btn admin-btn-primary"
disabled={cleaning}
>
{cleaning ? 'Mažu...' : 'Smazat'}
{cleaning ? "Mažu..." : "Smazat"}
</button>
</div>
</motion.div>
@@ -315,18 +383,20 @@ export default function AuditLog() {
className="admin-form-input"
placeholder="Popis, uživatel..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
onChange={(e) => handleFilterChange("search", e.target.value)}
/>
</FormField>
<FormField label="Akce">
<select
className="admin-form-select"
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
onChange={(e) => handleFilterChange("action", e.target.value)}
>
<option value="">Všechny</option>
{ACTION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
{ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
@@ -334,11 +404,15 @@ export default function AuditLog() {
<select
className="admin-form-select"
value={filters.entity_type}
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
onChange={(e) =>
handleFilterChange("entity_type", e.target.value)
}
>
<option value="">Všechny</option>
{ENTITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
{ENTITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
@@ -346,14 +420,14 @@ export default function AuditLog() {
<AdminDatePicker
mode="date"
value={filters.date_from}
onChange={(val: string) => handleFilterChange('date_from', val)}
onChange={(val: string) => handleFilterChange("date_from", val)}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={filters.date_to}
onChange={(val: string) => handleFilterChange('date_to', val)}
onChange={(val: string) => handleFilterChange("date_to", val)}
/>
</FormField>
</div>
@@ -380,22 +454,64 @@ export default function AuditLog() {
</tr>
</thead>
<tbody>
{loading && Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td><div className="admin-skeleton-line" style={{ width: '110px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '70px', height: '22px', borderRadius: '10px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '60%', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '90px', height: '14px' }} /></td>
</tr>
))}
{loading &&
Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td>
<div
className="admin-skeleton-line"
style={{ width: "110px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "80px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{
width: "70px",
height: "22px",
borderRadius: "10px",
}}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "80px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "60%", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "90px", height: "14px" }}
/>
</td>
</tr>
))}
{!loading && logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
@@ -407,20 +523,29 @@ export default function AuditLog() {
</td>
</tr>
)}
{!loading && logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">{formatDatetime(log.created_at)}</td>
<td className="fw-500">{log.username || '-'}</td>
<td>
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>{ENTITY_TYPE_LABELS[log.entity_type || ''] || log.entity_type || '-'}</td>
<td>{log.description || '-'}</td>
<td className="admin-mono">{log.user_ip || '-'}</td>
</tr>
))}
{!loading &&
logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">
{formatDatetime(log.created_at)}
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -433,5 +558,5 @@ export default function AuditLog() {
</div>
</motion.div>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +1,203 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
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 { getCzechDate } from '../utils/dashboardHelpers'
import DashKpiCards from '../components/dashboard/DashKpiCards'
import DashQuickActions from '../components/dashboard/DashQuickActions'
import DashActivityFeed from '../components/dashboard/DashActivityFeed'
import DashAttendanceToday from '../components/dashboard/DashAttendanceToday'
import DashProfile from '../components/dashboard/DashProfile'
import DashSessions from '../components/dashboard/DashSessions'
import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
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 { getCzechDate } from "../utils/dashboardHelpers";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DashData = Record<string, any>
type DashData = Record<string, any>;
export default function Dashboard() {
const { user, updateUser, hasPermission } = useAuth()
const alert = useAlert()
const { user, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [dashData, setDashData] = useState<DashData | null>(null)
const [dashLoading, setDashLoading] = useState(true)
const [punching, setPunching] = useState(false)
const [dashData, setDashData] = useState<DashData | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [punching, setPunching] = useState(false);
// 2FA state - sdileny mezi profilem a bannerem
const [totpEnabled, setTotpEnabled] = useState(false)
const [totpLoading, setTotpLoading] = useState(true)
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
const [totpSecret, setTotpSecret] = useState<string | null>(null)
const [totpQrUri, setTotpQrUri] = useState<string | null>(null)
const [totpCode, setTotpCode] = useState('')
const [totpSubmitting, setTotpSubmitting] = useState(false)
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
const [disableCode, setDisableCode] = useState('')
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpLoading, setTotpLoading] = useState(true);
const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false);
const [totpSecret, setTotpSecret] = useState<string | null>(null);
const [totpQrUri, setTotpQrUri] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState("");
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [disableCode, setDisableCode] = useState("");
useModalLock(show2FASetup)
useModalLock(show2FADisable)
useModalLock(show2FASetup);
useModalLock(show2FADisable);
const fetchDashboard = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/dashboard`)
const data = await response.json()
const response = await apiFetch(`${API_BASE}/dashboard`);
const data = await response.json();
if (data.success !== false) {
setDashData(data.data || data)
setDashData(data.data || data);
}
} catch (err) {
if (import.meta.env.DEV) {
console.error('Dashboard fetch error:', err)
console.error("Dashboard fetch error:", err);
}
} finally {
setDashLoading(false)
setDashLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
fetchDashboard();
}, [fetchDashboard]);
// 2FA status fetch
const fetch2FAStatus = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/totp/setup`)
const data = await response.json()
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpEnabled(!!user?.totpEnabled)
setTotpEnabled(!!user?.totpEnabled);
}
} catch {
// 2FA status fetch failed silently
setTotpEnabled(!!user?.totpEnabled)
setTotpEnabled(!!user?.totpEnabled);
} finally {
setTotpLoading(false)
setTotpLoading(false);
}
}, [user?.totpEnabled])
}, [user?.totpEnabled]);
useEffect(() => {
fetch2FAStatus()
}, [fetch2FAStatus])
fetch2FAStatus();
}, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = () => {
const action = dashData?.my_shift?.has_ongoing ? 'departure' : 'arrival'
setPunching(true)
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
setPunching(true);
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ punch_action: action, ...gpsData })
})
const result = await response.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
});
const result = await response.json();
if (result.success) {
alert.success(result.data?.message || 'Docházka zaznamenána')
fetchDashboard()
alert.success(result.data?.message || "Docházka zaznamenána");
fetchDashboard();
} else {
alert.error(result.error || 'Chyba při záznamu docházky')
alert.error(result.error || "Chyba při záznamu docházky");
}
} catch {
alert.error('Chyba pripojeni')
alert.error("Chyba pripojeni");
} finally {
setPunching(false)
setPunching(false);
}
}
};
if (!navigator.geolocation) {
submitPunch({})
return
submitPunch({});
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude, accuracy } = pos.coords
submitPunch({ latitude, longitude, accuracy, address: '' })
const { latitude, longitude, accuracy } = pos.coords;
submitPunch({ latitude, longitude, accuracy, address: "" });
},
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
)
}
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
};
// 2FA handlery
const handleStart2FASetup = async () => {
setTotpSubmitting(true)
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/setup`)
const data = await response.json()
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpSecret(data.data.secret)
setTotpQrUri(data.data.uri || data.data.qr_uri)
setTotpCode('')
setBackupCodes(null)
setShow2FASetup(true)
setTotpSecret(data.data.secret);
setTotpQrUri(data.data.uri || data.data.qr_uri);
setTotpCode("");
setBackupCodes(null);
setShow2FASetup(true);
} else {
alert.error(data.error || 'Nepodařilo se vygenerovat 2FA klíč')
alert.error(data.error || "Nepodařilo se vygenerovat 2FA klíč");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false)
setTotpSubmitting(false);
}
}
};
const handleConfirm2FA = async () => {
if (!totpCode.trim()) return
setTotpSubmitting(true)
if (!totpCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() })
})
const data = await response.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() }),
});
const data = await response.json();
if (data.success) {
setTotpEnabled(true)
setBackupCodes(data.data?.backup_codes || null)
setTotpSecret(null)
setTotpQrUri(null)
updateUser({ totpEnabled: true })
alert.success('2FA bylo aktivováno')
setTotpEnabled(true);
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
updateUser({ totpEnabled: true });
alert.success("2FA bylo aktivováno");
} else {
alert.error(data.error || 'Neplatný kód')
setTotpCode('')
alert.error(data.error || "Neplatný kód");
setTotpCode("");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false)
setTotpSubmitting(false);
}
}
};
const handleDisable2FA = async () => {
if (!disableCode.trim()) return
setTotpSubmitting(true)
if (!disableCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/disable`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: disableCode.trim() })
})
const data = await response.json()
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: disableCode.trim() }),
});
const data = await response.json();
if (data.success) {
setTotpEnabled(false)
setShow2FADisable(false)
setDisableCode('')
updateUser({ totpEnabled: false })
alert.success('2FA bylo deaktivováno')
setTotpEnabled(false);
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
alert.success("2FA bylo deaktivováno");
} else {
alert.error(data.error || 'Neplatný kód')
setDisableCode('')
alert.error(data.error || "Neplatný kód");
setDisableCode("");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false)
setTotpSubmitting(false);
}
}
};
return (
<div className="dash">
@@ -223,29 +223,66 @@ export default function Dashboard() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ border: '2px solid var(--danger)', background: 'var(--danger-light)' }}
style={{
border: "2px solid var(--danger)",
background: "var(--danger-light)",
}}
>
<div className="admin-card-body" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div
className="admin-card-body"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="flex-row-gap">
<div style={{
width: 40, height: 40, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--danger-light)', color: 'var(--danger)', flexShrink: 0
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--danger-light)",
color: "var(--danger)",
flexShrink: 0,
}}
>
<svg
width="20"
height="20"
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" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<div>
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
<div className="text-secondary" style={{ fontSize: '0.875rem' }}>
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete, nemáte přístup k ostatním sekcím systému.
<div
className="text-secondary"
style={{ fontSize: "0.875rem" }}
>
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</div>
</div>
</div>
<button onClick={handleStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary" style={{ flexShrink: 0 }}>
{totpSubmitting ? 'Generuji...' : 'Aktivovat 2FA nyní'}
<button
onClick={handleStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary"
style={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
</button>
</div>
</motion.div>
@@ -253,36 +290,70 @@ export default function Dashboard() {
{/* Skeleton loading */}
{dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.25rem' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}>
<div className="dash-kpi-grid dash-kpi-4">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-line h-24" style={{ borderRadius: '10px' }} />
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line h-24"
style={{ borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-quick-actions">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '10px' }} />
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-main-grid">
<div className="admin-skeleton-line" style={{ height: '320px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ height: '320px', borderRadius: '10px' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div className="admin-skeleton-line" style={{ height: '150px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ height: '150px', borderRadius: '10px' }} />
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
</div>
</div>
<div className="dash-bottom">
<div className="admin-skeleton-line" style={{ height: '200px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ height: '200px', borderRadius: '10px' }} />
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
</div>
</div>
)}
{/* KPI cards — only show if user has any admin-level permissions */}
{!dashLoading && (hasPermission('offers.view') || hasPermission('invoices.view') || hasPermission('projects.view') || hasPermission('orders.view')) && (
<DashKpiCards dashData={dashData} />
)}
{!dashLoading &&
(hasPermission("offers.view") ||
hasPermission("invoices.view") ||
hasPermission("projects.view") ||
hasPermission("orders.view")) && <DashKpiCards dashData={dashData} />}
{/* Quick actions */}
{!dashLoading && (
@@ -294,87 +365,125 @@ export default function Dashboard() {
)}
{/* Main content grid */}
{!dashLoading && <motion.div
className="dash-main-grid"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
{hasPermission('settings.audit') && <DashActivityFeed activities={dashData?.recent_activity} />}
{!dashLoading && (
<motion.div
className="dash-main-grid"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
{hasPermission("settings.audit") && (
<DashActivityFeed activities={dashData?.recent_activity} />
)}
{hasPermission('attendance.admin') && <DashAttendanceToday attendance={dashData?.attendance} />}
{hasPermission("attendance.admin") && (
<DashAttendanceToday attendance={dashData?.attendance} />
)}
{/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col">
{dashData?.projects && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Aktivní projekty</h2>
<Link to="/projects" className="admin-btn admin-btn-primary admin-btn-sm">Vše &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{dashData.projects.active_projects.length === 0 && (
<div className="dash-empty-row">Žádné aktivní projekty</div>
)}
{dashData.projects.active_projects.map((p: { id: number; name: string; customer_name: string | null }) => (
<Link key={p.id} to={`/projects/${p.id}`} className="dash-project-row">
<div className="dash-project-name">{p.name}</div>
{p.customer_name && <div className="dash-project-customer">{p.customer_name}</div>}
{/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col">
{dashData?.projects && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Aktivní projekty</h2>
<Link
to="/projects"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Vše &rarr;
</Link>
))}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{dashData.projects.active_projects.length === 0 && (
<div className="dash-empty-row">Žádné aktivní projekty</div>
)}
{dashData.projects.active_projects.map(
(p: {
id: number;
name: string;
customer_name: string | null;
}) => (
<Link
key={p.id}
to={`/projects/${p.id}`}
className="dash-project-row"
>
<div className="dash-project-name">{p.name}</div>
{p.customer_name && (
<div className="dash-project-customer">
{p.customer_name}
</div>
)}
</Link>
),
)}
</div>
</div>
</div>
)}
)}
{dashData?.offers && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Nabídky</h2>
<Link to="/offers" className="admin-btn admin-btn-primary admin-btn-sm">Zobrazit &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-stat-row">
<span>Otevřené</span>
<span className="admin-badge admin-badge-info">{dashData.offers.open_count}</span>
{dashData?.offers && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Nabídky</h2>
<Link
to="/offers"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Zobrazit &rarr;
</Link>
</div>
<div className="dash-stat-row">
<span>Převedené na objednávku</span>
<span className="admin-badge admin-badge-success">{dashData.offers.converted_count}</span>
</div>
<div className="dash-stat-row">
<span>Prošlé</span>
<span className="admin-badge admin-badge-warning">{dashData.offers.expired_count}</span>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-stat-row">
<span>Otevřené</span>
<span className="admin-badge admin-badge-info">
{dashData.offers.open_count}
</span>
</div>
<div className="dash-stat-row">
<span>Převedené na objednávku</span>
<span className="admin-badge admin-badge-success">
{dashData.offers.converted_count}
</span>
</div>
<div className="dash-stat-row">
<span>Prošlé</span>
<span className="admin-badge admin-badge-warning">
{dashData.offers.expired_count}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</motion.div>}
)}
</div>
</motion.div>
)}
{/* Profile + Sessions */}
{!dashLoading && <div className="dash-bottom">
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
totpSubmitting={totpSubmitting}
onStart2FASetup={handleStart2FASetup}
onConfirm2FA={handleConfirm2FA}
onDisable2FA={handleDisable2FA}
totpSecret={totpSecret}
totpQrUri={totpQrUri}
totpCode={totpCode}
setTotpCode={setTotpCode}
backupCodes={backupCodes}
setBackupCodes={setBackupCodes}
show2FASetup={show2FASetup}
setShow2FASetup={setShow2FASetup}
show2FADisable={show2FADisable}
setShow2FADisable={setShow2FADisable}
disableCode={disableCode}
setDisableCode={setDisableCode}
/>
<DashSessions />
</div>}
{!dashLoading && (
<div className="dash-bottom">
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
totpSubmitting={totpSubmitting}
onStart2FASetup={handleStart2FASetup}
onConfirm2FA={handleConfirm2FA}
onDisable2FA={handleDisable2FA}
totpSecret={totpSecret}
totpQrUri={totpQrUri}
totpCode={totpCode}
setTotpCode={setTotpCode}
backupCodes={backupCodes}
setBackupCodes={setBackupCodes}
show2FASetup={show2FASetup}
setShow2FASetup={setShow2FASetup}
show2FADisable={show2FADisable}
setShow2FADisable={setShow2FADisable}
disableCode={disableCode}
setDisableCode={setDisableCode}
/>
<DashSessions />
</div>
)}
</div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,87 @@
import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { motion, AnimatePresence } from 'framer-motion'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import { czechPlural } from '../utils/formatters'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import FormField from '../components/FormField'
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { motion, AnimatePresence } from "framer-motion";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import { czechPlural } from "../utils/formatters";
import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
vacation: "Dovolená",
sick: "Nemoc",
unpaid: "Neplacené volno",
};
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
pending: "Čeká na schválení",
approved: "Schváleno",
rejected: "Zamítnuto",
cancelled: "Zrušeno",
};
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
pending: "badge-pending",
approved: "badge-approved",
rejected: "badge-rejected",
cancelled: "badge-cancelled",
};
interface RawLeaveRequest {
id: number
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_note?: string
created_at: string
reviewed_at?: string
users_leave_requests_user_idTousers?: { first_name: string; last_name: string }
users_leave_requests_reviewer_idTousers?: { first_name: string; last_name: string } | null
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
reviewed_at?: string;
users_leave_requests_user_idTousers?: {
first_name: string;
last_name: string;
};
users_leave_requests_reviewer_idTousers?: {
first_name: string;
last_name: string;
} | null;
}
interface LeaveRequest {
id: number
employee_name: string
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_name?: string
reviewer_note?: string
created_at: string
reviewed_at?: string
id: number;
employee_name: string;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_name?: string;
reviewer_note?: string;
created_at: string;
reviewed_at?: string;
}
function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
const user = raw.users_leave_requests_user_idTousers
const reviewer = raw.users_leave_requests_reviewer_idTousers
const user = raw.users_leave_requests_user_idTousers;
const reviewer = raw.users_leave_requests_reviewer_idTousers;
return {
id: raw.id,
employee_name: user ? `${user.first_name} ${user.last_name}` : 'Neznámý',
employee_name: user ? `${user.first_name} ${user.last_name}` : "Neznámý",
leave_type: raw.leave_type,
date_from: raw.date_from,
date_to: raw.date_to,
@@ -83,152 +89,196 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
total_hours: raw.total_hours,
status: raw.status,
notes: raw.notes,
reviewer_name: reviewer ? `${reviewer.first_name} ${reviewer.last_name}` : undefined,
reviewer_name: reviewer
? `${reviewer.first_name} ${reviewer.last_name}`
: undefined,
reviewer_note: raw.reviewer_note,
created_at: raw.created_at,
reviewed_at: raw.reviewed_at,
}
};
}
export default function LeaveApproval() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'pending' | 'processed'>('pending')
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>([])
const [approveModal, setApproveModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectModal, setRejectModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectNote, setRejectNote] = useState('')
const [processing, setProcessing] = useState(false)
const { hasPermission } = useAuth();
const alert = useAlert();
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"pending" | "processed">(
"pending",
);
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]);
const [pendingCount, setPendingCount] = useState(0);
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>(
[],
);
const [approveModal, setApproveModal] = useState<{
open: boolean;
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectModal, setRejectModal] = useState<{
open: boolean;
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectNote, setRejectNote] = useState("");
const [processing, setProcessing] = useState(false);
useModalLock(rejectModal.open)
useModalLock(rejectModal.open);
const fetchPending = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=pending`)
if (response.status === 401) return
const result = await response.json()
const response = await apiFetch(
`${API_BASE}/leave-requests?status=pending`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest)
setPendingRequests(mapped)
setPendingCount(result.pagination?.total ?? mapped.length)
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest);
setPendingRequests(mapped);
setPendingCount(result.pagination?.total ?? mapped.length);
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
alert.error("Nepodařilo se načíst žádosti");
}
}, [alert])
}, [alert]);
const fetchProcessed = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=approved`)
if (response.status === 401) return
const resultApproved = await response.json()
const response = await apiFetch(
`${API_BASE}/leave-requests?status=approved`,
);
if (response.status === 401) return;
const resultApproved = await response.json();
const response2 = await apiFetch(`${API_BASE}/leave-requests?status=rejected`)
if (response2.status === 401) return
const resultRejected = await response2.json()
const response2 = await apiFetch(
`${API_BASE}/leave-requests?status=rejected`,
);
if (response2.status === 401) return;
const resultRejected = await response2.json();
const all = [
...(resultApproved.success ? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest) : []),
...(resultRejected.success ? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest) : [])
].sort((a: LeaveRequest, b: LeaveRequest) => new Date(b.reviewed_at!).getTime() - new Date(a.reviewed_at!).getTime())
...(resultApproved.success
? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
...(resultRejected.success
? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
].sort(
(a: LeaveRequest, b: LeaveRequest) =>
new Date(b.reviewed_at!).getTime() -
new Date(a.reviewed_at!).getTime(),
);
setProcessedRequests(all)
setProcessedRequests(all);
} catch {
alert.error('Nepodařilo se načíst vyřízené žádosti')
alert.error("Nepodařilo se načíst vyřízené žádosti");
}
}, [alert])
}, [alert]);
useEffect(() => {
setLoading(true)
fetchPending().finally(() => setLoading(false))
}, [fetchPending])
setLoading(true);
fetchPending().finally(() => setLoading(false));
}, [fetchPending]);
useEffect(() => {
if (activeTab === 'processed' && processedRequests.length === 0) {
fetchProcessed()
if (activeTab === "processed" && processedRequests.length === 0) {
fetchProcessed();
}
}, [activeTab, processedRequests.length, fetchProcessed])
}, [activeTab, processedRequests.length, fetchProcessed]);
if (!hasPermission('attendance.approve')) return <Forbidden />
if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => {
setProcessing(true)
setProcessing(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${approveModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' })
})
if (response.status === 401) return
const response = await apiFetch(
`${API_BASE}/leave-requests/${approveModal.request!.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "approved" }),
},
);
if (response.status === 401) return;
const result = await response.json()
const result = await response.json();
if (result.success) {
setApproveModal({ open: false, request: null })
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla schválena')
setApproveModal({ open: false, request: null });
await fetchPending();
setProcessedRequests([]);
alert.success("Žádost byla schválena");
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setProcessing(false)
setProcessing(false);
}
}
};
const handleReject = async () => {
if (!rejectNote.trim()) {
alert.error('Důvod zamítnutí je povinný')
return
alert.error("Důvod zamítnutí je povinný");
return;
}
setProcessing(true)
setProcessing(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${rejectModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'rejected', reviewer_note: rejectNote })
})
if (response.status === 401) return
const response = await apiFetch(
`${API_BASE}/leave-requests/${rejectModal.request!.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "rejected",
reviewer_note: rejectNote,
}),
},
);
if (response.status === 401) return;
const result = await response.json()
const result = await response.json();
if (result.success) {
setRejectModal({ open: false, request: null })
setRejectNote('')
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla zamítnuta')
setRejectModal({ open: false, request: null });
setRejectNote("");
await fetchPending();
setProcessedRequests([]);
alert.success("Žádost byla zamítnuta");
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setProcessing(false)
setProcessing(false);
}
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -236,7 +286,7 @@ export default function LeaveApproval() {
</div>
</div>
</div>
)
);
}
return (
@@ -251,9 +301,8 @@ export default function LeaveApproval() {
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, 'žádost čeká', 'žádosti čekají', 'žádostí čeká')} na schválení`
: 'Žádné čekající žádosti'
}
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"}
</p>
</div>
</motion.div>
@@ -266,19 +315,26 @@ export default function LeaveApproval() {
>
<div className="offers-tabs mb-6">
<button
className={`offers-tab ${activeTab === 'pending' ? 'active' : ''}`}
onClick={() => setActiveTab('pending')}
className={`offers-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab("pending")}
>
Ke schválení
{pendingCount > 0 && (
<span className="admin-badge badge-pending" style={{ marginLeft: '0.5rem', fontSize: '0.7rem', padding: '0.15rem 0.5rem' }}>
<span
className="admin-badge badge-pending"
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingCount}
</span>
)}
</button>
<button
className={`offers-tab ${activeTab === 'processed' ? 'active' : ''}`}
onClick={() => setActiveTab('processed')}
className={`offers-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")}
>
Vyřízené
</button>
@@ -286,7 +342,7 @@ export default function LeaveApproval() {
</motion.div>
{/* Pending Tab */}
{activeTab === 'pending' && (
{activeTab === "pending" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
@@ -296,7 +352,15 @@ export default function LeaveApproval() {
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted mb-4">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-muted mb-4"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
@@ -305,43 +369,100 @@ export default function LeaveApproval() {
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
<div className="admin-card-body" style={{ padding: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<div
className="admin-card-body"
style={{ padding: "1.25rem" }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexWrap: "wrap",
gap: "1rem",
}}
>
<div className="flex-1">
<div className="flex-row-gap mb-2">
<strong style={{ fontSize: '1rem' }}>{req.employee_name}</strong>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
<strong style={{ fontSize: "1rem" }}>
{req.employee_name}
</strong>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</div>
<div className="text-secondary" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', fontSize: '0.875rem' }}>
<div
className="text-secondary"
style={{
display: "flex",
gap: "1.5rem",
flexWrap: "wrap",
fontSize: "0.875rem",
}}
>
<span>
<strong>{formatDate(req.date_from)}</strong> <strong>{formatDate(req.date_to)}</strong>
<strong>{formatDate(req.date_from)}</strong> {" "}
<strong>{formatDate(req.date_to)}</strong>
</span>
<span>
{req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")} (
{req.total_hours}h)
</span>
<span className="text-muted">
Podáno: {formatDatetime(req.created_at)}
</span>
<span>{req.total_days} {czechPlural(req.total_days, 'den', 'dny', 'dnů')} ({req.total_hours}h)</span>
<span className="text-muted">Podáno: {formatDatetime(req.created_at)}</span>
</div>
{req.notes && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.875rem', fontStyle: 'italic' }}>
<div
className="text-secondary"
style={{
marginTop: "0.5rem",
fontSize: "0.875rem",
fontStyle: "italic",
}}
>
{req.notes}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<div
style={{
display: "flex",
gap: "0.5rem",
flexShrink: 0,
}}
>
<button
onClick={() => setApproveModal({ open: true, request: req })}
onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--success-light)', color: 'var(--success)', border: 'none' }}
style={{
background: "var(--success-light)",
color: "var(--success)",
border: "none",
}}
>
Schválit
</button>
<button
onClick={() => setRejectModal({ open: true, request: req })}
onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--danger-light)', color: 'var(--danger)', border: 'none' }}
style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
>
Zamítnout
</button>
@@ -356,7 +477,7 @@ export default function LeaveApproval() {
)}
{/* Processed Tab */}
{activeTab === 'processed' && (
{activeTab === "processed" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -387,29 +508,46 @@ export default function LeaveApproval() {
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td><strong>{req.employee_name}</strong></td>
<td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
<strong>{req.employee_name}</strong>
</td>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">{formatDate(req.date_from)}</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || '—'}</td>
<td style={{ maxWidth: '200px' }}>
<td>{req.reviewer_name || "—"}</td>
<td style={{ maxWidth: "200px" }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40 ? `${req.reviewer_note.substring(0, 40)}...` : req.reviewer_note}
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span>
) : '—'}
) : (
"—"
)}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
@@ -428,9 +566,10 @@ export default function LeaveApproval() {
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, 'den', 'dny', 'dnů')} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ''} pro ${approveModal.request.employee_name}?`
: ''
message={
approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, "den", "dny", "dnů")} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ""} pro ${approveModal.request.employee_name}?`
: ""
}
confirmText="Schválit"
type="info"
@@ -447,7 +586,13 @@ export default function LeaveApproval() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }} />
<div
className="admin-modal-backdrop"
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -461,8 +606,11 @@ export default function LeaveApproval() {
<div className="admin-modal-body">
{rejectModal.request && (
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {leaveTypeLabels[rejectModal.request.leave_type]},{' '}
{formatDate(rejectModal.request.date_from)} {formatDate(rejectModal.request.date_to)} ({rejectModal.request.total_days} dnů)
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
)}
<FormField label="Důvod zamítnutí" required>
@@ -479,7 +627,10 @@ export default function LeaveApproval() {
<div className="admin-modal-footer">
<button
type="button"
onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }}
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
@@ -491,7 +642,7 @@ export default function LeaveApproval() {
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? 'Zpracování...' : 'Zamítnout'}
{processing ? "Zpracování..." : "Zamítnout"}
</button>
</div>
</motion.div>
@@ -499,5 +650,5 @@ export default function LeaveApproval() {
)}
</AnimatePresence>
</div>
)
);
}

View File

@@ -1,124 +1,142 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import ConfirmModal from '../components/ConfirmModal'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
vacation: "Dovolená",
sick: "Nemoc",
unpaid: "Neplacené volno",
};
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
pending: "Čeká na schválení",
approved: "Schváleno",
rejected: "Zamítnuto",
cancelled: "Zrušeno",
};
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
pending: "badge-pending",
approved: "badge-approved",
rejected: "badge-rejected",
cancelled: "badge-cancelled",
};
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
interface LeaveRequest {
id: number
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_note?: string
created_at: string
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
}
export default function LeaveRequests() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [requests, setRequests] = useState<LeaveRequest[]>([])
const [cancelModal, setCancelModal] = useState<{ open: boolean; id: number | null }>({ open: false, id: null })
const [cancelling, setCancelling] = useState(false)
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<LeaveRequest[]>([]);
const [cancelModal, setCancelModal] = useState<{
open: boolean;
id: number | null;
}>({ open: false, id: null });
const [cancelling, setCancelling] = useState(false);
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests`)
if (response.status === 401) return
const result = await response.json()
const response = await apiFetch(`${API_BASE}/leave-requests`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRequests(result.data)
setRequests(result.data);
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
alert.error("Nepodařilo se načíst žádosti");
} finally {
setLoading(false)
setLoading(false);
}
}, [alert])
}, [alert]);
useEffect(() => {
fetchRequests()
}, [fetchRequests])
fetchRequests();
}, [fetchRequests]);
if (!hasPermission('attendance.record')) return <Forbidden />
if (!hasPermission("attendance.record")) return <Forbidden />;
const handleCancel = async () => {
setCancelling(true)
setCancelling(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${cancelModal.id}`, {
method: 'DELETE',
})
if (response.status === 401) return
const response = await apiFetch(
`${API_BASE}/leave-requests/${cancelModal.id}`,
{
method: "DELETE",
},
);
if (response.status === 401) return;
const result = await response.json()
const result = await response.json();
if (result.success) {
setCancelModal({ open: false, id: null })
await fetchRequests()
alert.success(result.message)
setCancelModal({ open: false, id: null });
await fetchRequests();
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setCancelling(false)
setCancelling(false);
}
}
};
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -127,26 +145,34 @@ export default function LeaveRequests() {
</div>
</div>
</div>
)
);
}
function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) => text.length > 40 ? `${text.substring(0, 40)}...` : text
if (req.status === 'rejected' && req.reviewer_note) {
const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text;
if (req.status === "rejected" && req.reviewer_note) {
return (
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }} title={req.reviewer_note}>
<span
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
title={req.reviewer_note}
>
{truncate(req.reviewer_note)}
</span>
)
);
}
if (req.notes) {
return (
<span className="text-secondary" style={{ fontSize: '0.875rem' }} title={req.notes}>
<span
className="text-secondary"
style={{ fontSize: "0.875rem" }}
title={req.notes}
>
{truncate(req.notes)}
</span>
)
);
}
return <span className="text-muted"></span>
return <span className="text-muted"></span>;
}
return (
@@ -173,7 +199,16 @@ export default function LeaveRequests() {
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
@@ -181,7 +216,7 @@ export default function LeaveRequests() {
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
@@ -205,29 +240,40 @@ export default function LeaveRequests() {
{requests.map((req) => (
<tr key={req.id}>
<td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">{formatDate(req.date_from)}</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: '200px' }}>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)}
</td>
<td>
{req.status === 'pending' && (
{req.status === "pending" && (
<button
onClick={() => setCancelModal({ open: true, id: req.id })}
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
@@ -254,5 +300,5 @@ export default function LeaveRequests() {
loading={cancelling}
/>
</div>
)
);
}

View File

@@ -1,44 +1,47 @@
import { useState, useEffect, useRef } from 'react'
import { Navigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { useTheme } from '../../context/ThemeContext'
import { shouldShowSessionExpiredAlert, shouldShowLogoutAlert } from '../utils/api'
import FormField from '../components/FormField'
import { useState, useEffect, useRef } from "react";
import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { useTheme } from "../../context/ThemeContext";
import {
shouldShowSessionExpiredAlert,
shouldShowLogoutAlert,
} from "../utils/api";
import FormField from "../components/FormField";
export default function Login() {
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth()
const alert = useAlert()
const { theme, toggleTheme } = useTheme()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false)
const [shake, setShake] = useState(false)
const [animatingOut, setAnimatingOut] = useState(false)
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
const alert = useAlert();
const { theme, toggleTheme } = useTheme();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const [loading, setLoading] = useState(false);
const [shake, setShake] = useState(false);
const [animatingOut, setAnimatingOut] = useState(false);
// 2FA state
const [show2FA, setShow2FA] = useState(false)
const [loginToken, setLoginToken] = useState<string | null>(null)
const [totpCode, setTotpCode] = useState('')
const [useBackupCode, setUseBackupCode] = useState(false)
const totpInputRef = useRef<HTMLInputElement>(null)
const [show2FA, setShow2FA] = useState(false);
const [loginToken, setLoginToken] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState("");
const [useBackupCode, setUseBackupCode] = useState(false);
const totpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (shouldShowSessionExpiredAlert()) {
alert.warning('Vaše relace vypršela. Přihlaste se prosím znovu.')
alert.warning("Vaše relace vypršela. Přihlaste se prosím znovu.");
} else if (shouldShowLogoutAlert()) {
alert.success('Byli jste úspěšně odhlášeni.')
alert.success("Byli jste úspěšně odhlášeni.");
}
}, [alert])
}, [alert]);
// Auto-focus TOTP input
useEffect(() => {
if (show2FA && totpInputRef.current) {
totpInputRef.current.focus()
totpInputRef.current.focus();
}
}, [show2FA, useBackupCode])
}, [show2FA, useBackupCode]);
if (authLoading) {
return (
@@ -47,76 +50,83 @@ export default function Login() {
<div className="admin-spinner" />
</div>
</div>
)
);
}
if (isAuthenticated && !animatingOut) {
return <Navigate to="/" replace />
return <Navigate to="/" replace />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
e.preventDefault();
setLoading(true);
const result = await login(username, password, remember)
const result = await login(username, password, remember);
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null)
setShow2FA(true)
setTotpCode('')
setLoading(false)
setLoginToken(result.loginToken ?? null);
setShow2FA(true);
setTotpCode("");
setLoading(false);
} else if (!result.success) {
alert.error(result.error ?? 'Chyba přihlášení')
setShake(true)
setTimeout(() => setShake(false), 500)
setLoading(false)
alert.error(result.error ?? "Chyba přihlášení");
setShake(true);
setTimeout(() => setShake(false), 500);
setLoading(false);
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
}
};
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!totpCode.trim()) return
e.preventDefault();
if (!totpCode.trim()) return;
setLoading(true)
setLoading(true);
const result = await verify2FA(loginToken!, totpCode.trim(), remember, useBackupCode)
const result = await verify2FA(
loginToken!,
totpCode.trim(),
remember,
useBackupCode,
);
if (!result.success) {
alert.error(result.error ?? 'Chyba ověření')
setShake(true)
setTimeout(() => setShake(false), 500)
setTotpCode('')
if (totpInputRef.current) totpInputRef.current.focus()
setLoading(false)
alert.error(result.error ?? "Chyba ověření");
setShake(true);
setTimeout(() => setShake(false), 500);
setTotpCode("");
if (totpInputRef.current) totpInputRef.current.focus();
setLoading(false);
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
}
};
const handleBack = () => {
setShow2FA(false)
setLoginToken(null)
setTotpCode('')
setUseBackupCode(false)
}
setShow2FA(false);
setLoginToken(null);
setTotpCode("");
setUseBackupCode(false);
};
return (
<motion.div
className="admin-login"
initial={{ opacity: 0, scale: 0.98 }}
animate={animatingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
animate={
animatingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
transition={
animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
}
>
<div className="bg-orb bg-orb-1" />
@@ -125,16 +135,34 @@ export default function Login() {
<button
onClick={toggleTheme}
className="admin-login-theme-btn"
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<span
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</span>
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
@@ -146,19 +174,25 @@ export default function Login() {
key="login"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
animate={
shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
transition={
shake
? { x: { duration: 0.5, ease: "easeOut" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<img
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
src={
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png"
}
alt="Logo"
className="admin-login-logo"
/>
@@ -206,15 +240,18 @@ export default function Login() {
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
/>
Přihlašování...
</>
) : (
'Přihlásit se'
"Přihlásit se"
)}
</button>
</form>
@@ -224,19 +261,28 @@ export default function Login() {
key="2fa"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
animate={
shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
transition={
shake
? { x: { duration: 0.5, ease: "easeOut" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<div className="admin-login-2fa-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
@@ -244,36 +290,43 @@ export default function Login() {
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
<p className="admin-login-subtitle">
{useBackupCode
? 'Zadejte jeden ze záložních kódů'
: 'Zadejte 6místný kód z autentizační aplikace'
}
? "Zadejte jeden ze záložních kódů"
: "Zadejte 6místný kód z autentizační aplikace"}
</p>
</div>
<form onSubmit={handle2FASubmit} className="admin-form">
<FormField label={useBackupCode ? 'Záložní kód' : 'Ověřovací kód'}>
<FormField
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
>
<input
ref={totpInputRef}
id="totp-code"
type="text"
inputMode={useBackupCode ? 'text' : 'numeric'}
pattern={useBackupCode ? undefined : '[0-9]*'}
inputMode={useBackupCode ? "text" : "numeric"}
pattern={useBackupCode ? undefined : "[0-9]*"}
maxLength={useBackupCode ? 8 : 6}
value={totpCode}
onChange={(e) => {
const val = useBackupCode ? e.target.value : e.target.value.replace(/\D/g, '')
setTotpCode(val)
const val = useBackupCode
? e.target.value
: e.target.value.replace(/\D/g, "");
setTotpCode(val);
}}
required
autoComplete="one-time-code"
className="admin-form-input"
placeholder={useBackupCode ? 'XXXXXXXX' : '000000'}
style={useBackupCode ? {} : {
textAlign: 'center',
fontSize: '1.5rem',
letterSpacing: '0.5rem',
fontFamily: 'monospace'
}}
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
style={
useBackupCode
? {}
: {
textAlign: "center",
fontSize: "1.5rem",
letterSpacing: "0.5rem",
fontFamily: "monospace",
}
}
/>
</FormField>
@@ -281,34 +334,54 @@ export default function Login() {
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
/>
Ověřování...
</>
) : (
'Ověřit'
"Ověřit"
)}
</button>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.5rem",
}}
>
<button
onClick={() => {
setUseBackupCode(!useBackupCode)
setTotpCode('')
setUseBackupCode(!useBackupCode);
setTotpCode("");
}}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
style={{
border: "none",
background: "none",
cursor: "pointer",
}}
>
{useBackupCode ? 'Použít autentizační aplikaci' : 'Použít záložní kód'}
{useBackupCode
? "Použít autentizační aplikaci"
: "Použít záložní kód"}
</button>
<button
onClick={handleBack}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
style={{
border: "none",
background: "none",
cursor: "pointer",
}}
>
&larr; Zpět na přihlášení
</button>
@@ -317,5 +390,5 @@ export default function Login() {
)}
</AnimatePresence>
</motion.div>
)
);
}

View File

@@ -1,30 +1,53 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
export default function NotFound() {
return (
<motion.div
className="admin-empty-state"
style={{ minHeight: '60vh', justifyContent: 'center' }}
style={{ minHeight: "60vh", justifyContent: "center" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<div
className="admin-empty-icon"
style={{ width: 80, height: 80, marginBottom: "1.5rem" }}
>
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>
<h2
style={{
fontSize: "1.5rem",
fontWeight: 600,
marginBottom: "0.5rem",
color: "var(--text-primary)",
}}
>
404
</h2>
<p>Stránka nebyla nalezena.</p>
<Link to="/" className="admin-btn admin-btn-primary" style={{ marginTop: '0.5rem' }}>
<Link
to="/"
className="admin-btn admin-btn-primary"
style={{ marginTop: "0.5rem" }}
>
Zpět na Dashboard
</Link>
</motion.div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,110 +1,140 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import Pagination from "../components/Pagination";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
prijata: "Přijatá",
v_realizaci: "V realizaci",
dokoncena: "Dokončená",
stornovana: "Stornována",
};
const STATUS_CLASSES: Record<string, string> = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
prijata: "admin-badge-order-prijata",
v_realizaci: "admin-badge-order-realizace",
dokoncena: "admin-badge-order-dokoncena",
stornovana: "admin-badge-order-stornovana",
};
interface Order {
id: number
order_number: string
quotation_id: number
quotation_number: string
customer_name: string
status: string
created_at: string
total: number
currency: string
invoice_id?: number
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
customer_name: string;
status: string;
created_at: string;
total: number;
currency: string;
invoice_id?: number;
}
export default function Orders() {
const alert = useAlert()
const { hasPermission } = useAuth()
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } = useTableSort('order_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const { sort, order, handleSort, activeSort } = useTableSort("order_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; order: Order | null }>({ show: false, order: null })
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
order: Order | null;
}>({ show: false, order: null });
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
const { items: orders, loading, initialLoad, pagination, refetch: fetchData } = useListData('orders', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst objednávky'
})
const {
items: orders,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData("orders", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst objednávky",
});
if (!hasPermission('orders.view')) return <Forbidden />
if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return
setDeleting(true)
if (!deleteConfirm.order) return;
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/orders/${deleteConfirm.order.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
const response = await apiFetch(
`${API_BASE}/orders/${deleteConfirm.order.id}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
alert.success(result.message || 'Objednávka byla smazána')
fetchData()
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(result.message || "Objednávka byla smazána");
fetchData();
} else {
alert.error(result.error || 'Nepodařilo se smazat objednávku')
alert.error(result.error || "Nepodařilo se smazat objednávku");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -113,7 +143,7 @@ export default function Orders() {
</div>
</div>
</div>
)
);
}
return (
@@ -127,7 +157,13 @@ export default function Orders() {
<div>
<h1 className="admin-page-title">Objednávky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? orders.length} {czechPlural(pagination?.total ?? orders.length, 'objednávka', 'objednávky', 'objednávek')}
{pagination?.total ?? orders.length}{" "}
{czechPlural(
pagination?.total ?? orders.length,
"objednávka",
"objednávky",
"objednávek",
)}
</p>
</div>
</motion.div>
@@ -137,14 +173,17 @@ export default function Orders() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s' }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
/>
@@ -153,14 +192,23 @@ export default function Orders() {
{orders.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
</div>
<p>Zatím nejsou žádné objednávky.</p>
<p className="text-tertiary" style={{ fontSize: '0.875rem' }}>
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
Objednávky se vytvářejí z nabídek.
</p>
</div>
@@ -169,16 +217,40 @@ export default function Orders() {
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('order_number')}>
Číslo <SortIcon column="order_number" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("order_number")}
>
Číslo{" "}
<SortIcon
column="order_number"
sort={activeSort}
order={order}
/>
</th>
<th>Nabídka</th>
<th>Zákazník</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('created_at')}>
Datum <SortIcon column="created_at" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("created_at")}
>
Datum{" "}
<SortIcon
column="created_at"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
@@ -193,55 +265,116 @@ export default function Orders() {
</Link>
</td>
<td>
<Link to={`/offers/${o.quotation_id}`} className="text-secondary" style={{ textDecoration: 'none' }}>
<Link
to={`/offers/${o.quotation_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{o.quotation_number}
</Link>
</td>
<td>{o.customer_name || '—'}</td>
<td>{o.customer_name || "—"}</td>
<td>
<span className={`admin-badge ${STATUS_CLASSES[o.status] || ''}`}>
<span
className={`admin-badge ${STATUS_CLASSES[o.status] || ""}`}
>
{STATUS_LABELS[o.status] || o.status}
</span>
</td>
<td className="admin-mono">
{formatDate(o.created_at)}
</td>
<td className="admin-mono">{formatDate(o.created_at)}</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(o.total, o.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/orders/${o.id}`} className="admin-btn-icon" title="Detail" aria-label="Detail">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<Link
to={`/orders/${o.id}`}
className="admin-btn-icon"
title="Detail"
aria-label="Detail"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</Link>
{o.invoice_id ? (
<Link to={`/invoices/${o.invoice_id}`} className="admin-btn-icon accent" title="Zobrazit fakturu" aria-label="Zobrazit fakturu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<Link
to={`/invoices/${o.invoice_id}`}
className="admin-btn-icon accent"
title="Zobrazit fakturu"
aria-label="Zobrazit fakturu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text x="12" y="16.5" textAnchor="middle" fill="currentColor" stroke="none" fontSize="9" fontWeight="700">F</text>
</svg>
</Link>
) : hasPermission('invoices.create') && (
<Link to={`/invoices/new?fromOrder=${o.id}`} className="admin-btn-icon" title="Vytvořit fakturu" aria-label="Vytvořit fakturu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
</svg>
</Link>
) : (
hasPermission("invoices.create") && (
<Link
to={`/invoices/new?fromOrder=${o.id}`}
className="admin-btn-icon"
title="Vytvořit fakturu"
aria-label="Vytvořit fakturu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
</Link>
)
)}
{hasPermission('orders.delete') && (
{hasPermission("orders.delete") && (
<button
onClick={() => setDeleteConfirm({ show: true, order: o })}
onClick={() =>
setDeleteConfirm({ show: true, order: o })
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="18"
height="18"
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>
@@ -262,15 +395,20 @@ export default function Orders() {
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
Opravdu chcete smazat objednávku &quot;
{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený
projekt. Tato akce je nevratná.
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
@@ -286,5 +424,5 @@ export default function Orders() {
loading={deleting}
/>
</div>
)
);
}

View File

@@ -1,58 +1,58 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
import { useState, useEffect, useMemo } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface Customer {
id: number
name: string
company_id?: string
city?: string
id: number;
name: string;
company_id?: string;
city?: string;
}
interface User {
id: number
name: string
id: number;
name: string;
}
interface ProjectForm {
project_number: string
name: string
customer_id: number | null
customer_name: string
start_date: string
responsible_user_id: string
project_number: string;
name: string;
customer_id: number | null;
customer_name: string;
start_date: string;
responsible_user_id: string;
}
export default function ProjectCreate() {
const navigate = useNavigate()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate();
const alert = useAlert();
const { hasPermission } = useAuth();
const [form, setForm] = useState<ProjectForm>({
project_number: '',
name: '',
project_number: "",
name: "",
customer_id: null,
customer_name: '',
start_date: new Date().toISOString().split('T')[0],
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
const [loadingNumber, setLoadingNumber] = useState(true)
customer_name: "",
start_date: new Date().toISOString().split("T")[0],
responsible_user_id: "",
});
const [users, setUsers] = useState<User[]>([]);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [loadingNumber, setLoadingNumber] = useState(true);
// Customer selector state
const [customers, setCustomers] = useState<Customer[]>([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
// Load initial data
useEffect(() => {
@@ -61,115 +61,142 @@ export default function ProjectCreate() {
const [numRes, custRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/projects/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/users`)
])
apiFetch(`${API_BASE}/users`),
]);
const numData = await numRes.json()
const numData = await numRes.json();
if (numData.success) {
setForm(prev => ({ ...prev, project_number: numData.data?.next_number || numData.data?.number || '' }))
setForm((prev) => ({
...prev,
project_number:
numData.data?.next_number || numData.data?.number || "",
}));
}
const custData = await custRes.json()
const custData = await custRes.json();
if (custData.success) {
setCustomers(Array.isArray(custData.data) ? custData.data : custData.data?.items || [])
setCustomers(
Array.isArray(custData.data)
? custData.data
: custData.data?.items || [],
);
}
const usersData = await usersRes.json()
const usersData = await usersRes.json();
if (usersData.success) {
const rawUsers = Array.isArray(usersData.data) ? usersData.data : usersData.data?.items || []
setUsers(rawUsers.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
const rawUsers = Array.isArray(usersData.data)
? usersData.data
: usersData.data?.items || [];
setUsers(
rawUsers.map((u: any) => ({
id: u.id,
name:
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
u.username,
})),
);
}
} catch {
alert.error('Chyba při načítání dat')
alert.error("Chyba při načítání dat");
} finally {
setLoadingNumber(false)
setLoadingNumber(false);
}
}
load()
}, [alert])
};
load();
}, [alert]);
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
if (!customerSearch) return customers;
const q = customerSearch.toLowerCase();
return customers.filter(
(c) =>
(c.name || "").toLowerCase().includes(q) ||
(c.company_id || "").includes(customerSearch) ||
(c.city || "").toLowerCase().includes(q),
);
}, [customers, customerSearch]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
const handleClickOutside = () => setShowCustomerDropdown(false);
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [showCustomerDropdown])
}, [showCustomerDropdown]);
if (!hasPermission('projects.create')) return <Forbidden />
if (!hasPermission("projects.create")) return <Forbidden />;
const selectCustomer = (customer: Customer) => {
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
setErrors(prev => ({ ...prev, customer_id: undefined }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
setForm((prev) => ({
...prev,
customer_id: customer.id,
customer_name: customer.name,
}));
setErrors((prev) => ({ ...prev, customer_id: undefined }));
setCustomerSearch("");
setShowCustomerDropdown(false);
};
const clearCustomer = () => {
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
}
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
};
const updateForm = (field: keyof ProjectForm, value: unknown) => {
setForm(prev => ({ ...prev, [field]: value }))
setErrors(prev => ({ ...prev, [field]: undefined }))
}
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
};
const handleSave = async () => {
const newErrors: Record<string, string> = {}
if (!form.name.trim()) newErrors.name = 'Název projektu je povinný'
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
const newErrors: Record<string, string> = {};
if (!form.name.trim()) newErrors.name = "Název projektu je povinný";
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true)
setSaving(true);
try {
const body = {
name: form.name.trim(),
customer_id: form.customer_id,
start_date: form.start_date,
project_number: form.project_number.trim(),
responsible_user_id: form.responsible_user_id || null
}
responsible_user_id: form.responsible_user_id || null,
};
const res = await apiFetch(`${API_BASE}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
navigate(`/projects/${data.data.project_id}`, { state: { created: true } })
navigate(`/projects/${data.data.project_id}`, {
state: { created: true },
});
} else {
alert.error(data.error || 'Nepodařilo se vytvořit projekt')
alert.error(data.error || "Nepodařilo se vytvořit projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSaving(false)
setSaving(false);
}
}
};
if (loadingNumber) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
@@ -178,7 +205,7 @@ export default function ProjectCreate() {
</div>
</div>
</div>
)
);
}
return (
@@ -190,8 +217,20 @@ export default function ProjectCreate() {
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<Link
to="/projects"
className="admin-btn-icon"
title="Zpět"
aria-label="Zpět"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
@@ -206,7 +245,7 @@ export default function ProjectCreate() {
disabled={saving}
className="admin-btn admin-btn-primary"
>
{saving ? 'Ukládám...' : 'Uložit'}
{saving ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
@@ -216,7 +255,7 @@ export default function ProjectCreate() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ overflow: 'visible' }}
style={{ overflow: "visible" }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
@@ -226,7 +265,7 @@ export default function ProjectCreate() {
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm('project_number', e.target.value)}
onChange={(e) => updateForm("project_number", e.target.value)}
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
/>
@@ -235,7 +274,7 @@ export default function ProjectCreate() {
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
onChange={(e) => updateForm("name", e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
/>
@@ -247,18 +286,38 @@ export default function ProjectCreate() {
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
<button
type="button"
onClick={clearCustomer}
className="admin-btn-icon"
title="Odebrat zákazníka"
aria-label="Odebrat zákazníka"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
) : (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<div
className="offers-customer-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
}}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
@@ -270,7 +329,7 @@ export default function ProjectCreate() {
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map(c => (
filteredCustomers.slice(0, 20).map((c) => (
<div
key={c.id}
className="offers-customer-dropdown-item"
@@ -290,7 +349,7 @@ export default function ProjectCreate() {
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
onChange={(val: string) => updateForm("start_date", val)}
/>
</FormField>
</div>
@@ -299,12 +358,16 @@ export default function ProjectCreate() {
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
onChange={(e) =>
updateForm("responsible_user_id", e.target.value)
}
className="admin-form-select"
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FormField>
@@ -312,7 +375,6 @@ export default function ProjectCreate() {
</div>
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,292 +1,323 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
import { motion } from "framer-motion";
import Forbidden from '../components/Forbidden'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import ProjectFileManager from '../components/ProjectFileManager'
import apiFetch from '../utils/api'
import Forbidden from "../components/Forbidden";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import ProjectFileManager from "../components/ProjectFileManager";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
function formatNoteDate(dateStr: string) {
if (!dateStr) return ''
const d = new Date(dateStr)
const day = d.getDate()
const month = d.getMonth() + 1
const year = d.getFullYear()
const hours = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
return `${day}. ${month}. ${year} ${hours}:${mins}`
if (!dateStr) return "";
const d = new Date(dateStr);
const day = d.getDate();
const month = d.getMonth() + 1;
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, "0");
const mins = String(d.getMinutes()).padStart(2, "0");
return `${day}. ${month}. ${year} ${hours}:${mins}`;
}
interface Note {
id: number
content: string
user_name: string
created_at: string
id: number;
content: string;
user_name: string;
created_at: string;
}
interface User {
id: number
name: string
id: number;
name: string;
}
interface ProjectData {
id: number
project_number: string
name: string
status: string
start_date: string
end_date: string
customer_name: string
responsible_user_id: string
notes?: string
order_id?: number
order_number?: string
order_status?: string
quotation_id?: number
quotation_number?: string
has_nas_folder?: boolean
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
}
interface ProjectForm {
name: string
status: string
start_date: string
end_date: string
responsible_user_id: string
name: string;
status: string;
start_date: string;
end_date: string;
responsible_user_id: string;
}
export default function ProjectDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const { id } = useParams();
const alert = useAlert();
const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [project, setProject] = useState<ProjectData | null>(null)
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [project, setProject] = useState<ProjectData | null>(null);
const [form, setForm] = useState<ProjectForm>({
name: '',
status: 'aktivni',
start_date: '',
end_date: '',
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
name: "",
status: "aktivni",
start_date: "",
end_date: "",
responsible_user_id: "",
});
const [users, setUsers] = useState<User[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
// Dynamic notes
const [notes, setNotes] = useState<Note[]>([])
const [notesLoading, setNotesLoading] = useState(true)
const [newNote, setNewNote] = useState('')
const [addingNote, setAddingNote] = useState(false)
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null)
const [notes, setNotes] = useState<Note[]>([]);
const [notesLoading, setNotesLoading] = useState(true);
const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const createdShown = useRef(false)
const createdShown = useRef(false);
useEffect(() => {
if ((location.state as { created?: boolean })?.created && !createdShown.current) {
createdShown.current = true
alert.success('Projekt byl vytvořen')
navigate(location.pathname, { replace: true, state: {} })
if (
(location.state as { created?: boolean })?.created &&
!createdShown.current
) {
createdShown.current = true;
alert.success("Projekt byl vytvořen");
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, location.pathname, alert, navigate])
}, [location.state, location.pathname, alert, navigate]);
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setNotes(result.data.project_notes || [])
setNotes(result.data.project_notes || []);
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false)
setNotesLoading(false);
}
}
};
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const p = result.data
setProject(p)
const p = result.data;
setProject(p);
setForm({
name: p.name || '',
status: p.status || 'aktivni',
start_date: (p.start_date || '').substring(0, 10),
end_date: (p.end_date || '').substring(0, 10),
responsible_user_id: p.responsible_user_id || ''
})
name: p.name || "",
status: p.status || "aktivni",
start_date: (p.start_date || "").substring(0, 10),
end_date: (p.end_date || "").substring(0, 10),
responsible_user_id: p.responsible_user_id || "",
});
} else {
alert.error(result.error || 'Nepodařilo se načíst projekt')
navigate('/projects')
alert.error(result.error || "Nepodařilo se načíst projekt");
navigate("/projects");
}
} catch {
alert.error('Chyba připojení')
navigate('/projects')
alert.error("Chyba připojení");
navigate("/projects");
} finally {
setLoading(false)
setLoading(false);
}
}
};
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/users`)
if (res.status === 401) return
const data = await res.json()
const res = await apiFetch(`${API_BASE}/users`);
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
const raw = Array.isArray(data.data) ? data.data : data.data?.items || []
setUsers(raw.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
const raw = Array.isArray(data.data)
? data.data
: data.data?.items || [];
setUsers(
raw.map((u: any) => ({
id: u.id,
name:
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
u.username,
})),
);
}
} catch {
// silent
}
}
};
fetchDetail()
fetchNotes()
fetchUsers()
}, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps
fetchDetail();
fetchNotes();
fetchUsers();
}, [id, alert, navigate]); // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission('projects.view')) return <Forbidden />
if (!hasPermission("projects.view")) return <Forbidden />;
const updateForm = (field: keyof ProjectForm, value: string) => setForm(prev => ({ ...prev, [field]: value }))
const updateForm = (field: keyof ProjectForm, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }));
const handleSave = async () => {
if (!form.name.trim()) {
alert.error('Název projektu je povinný')
return
alert.error("Název projektu je povinný");
return;
}
setSaving(true)
setSaving(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null
})
})
const result = await response.json()
responsible_user_id: form.responsible_user_id || null,
}),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || 'Projekt byl aktualizován')
alert.success(result.message || "Projekt byl aktualizován");
} else {
alert.error(result.error || 'Nepodařilo se uložit projekt')
alert.error(result.error || "Nepodařilo se uložit projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSaving(false)
setSaving(false);
}
}
};
const handleDelete = async () => {
setDeleting(true)
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
});
const result = await response.json();
if (result.success) {
navigate('/projects')
setTimeout(() => alert.success('Projekt byl smazán'), 300)
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} else {
alert.error(result.error || 'Nepodařilo se smazat projekt')
alert.error(result.error || "Nepodařilo se smazat projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
const handleAddNote = async () => {
if (!newNote.trim()) return
if (!newNote.trim()) return;
setAddingNote(true)
setAddingNote(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newNote.trim() })
})
const result = await response.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: newNote.trim() }),
});
const result = await response.json();
if (result.success) {
setNotes(prev => [result.data.note, ...prev])
setNewNote('')
alert.success('Poznámka byla přidána')
setNotes((prev) => [result.data.note, ...prev]);
setNewNote("");
alert.success("Poznámka byla přidána");
} else {
alert.error(result.error || 'Nepodařilo se přidat poznámku')
alert.error(result.error || "Nepodařilo se přidat poznámku");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setAddingNote(false)
setAddingNote(false);
}
}
};
const handleDeleteNote = async (noteId: number) => {
setDeletingNoteId(noteId)
setDeletingNoteId(noteId);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
method: 'DELETE'
})
const result = await response.json()
const response = await apiFetch(
`${API_BASE}/projects/${id}/notes/${noteId}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setNotes(prev => prev.filter(n => n.id !== noteId))
alert.success('Poznámka byla smazána')
setNotes((prev) => prev.filter((n) => n.id !== noteId));
alert.success("Poznámka byla smazána");
} else {
alert.error(result.error || 'Nepodařilo se smazat poznámku')
alert.error(result.error || "Nepodařilo se smazat poznámku");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeletingNoteId(null)
setDeletingNoteId(null);
}
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
@@ -295,12 +326,12 @@ export default function ProjectDetail() {
</div>
</div>
</div>
)
);
}
if (!project) return null
if (!project) return null;
const canEdit = hasPermission('projects.edit')
const canEdit = hasPermission("projects.edit");
return (
<div>
@@ -311,9 +342,21 @@ export default function ProjectDetail() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
to="/projects"
className="admin-btn-icon"
title="Zpět"
aria-label="Zpět"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
@@ -325,13 +368,19 @@ export default function ProjectDetail() {
</div>
{canEdit && (
<div className="admin-page-actions">
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : 'Uložit'}
) : (
"Uložit"
)}
</button>
{!project.order_id && (
<button
@@ -362,14 +411,17 @@ export default function ProjectDetail() {
value={project.project_number}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Název">
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
onChange={(e) => updateForm("name", e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
disabled={!canEdit}
@@ -381,22 +433,29 @@ export default function ProjectDetail() {
<FormField label="Zákazník">
<input
type="text"
value={project.customer_name || '—'}
value={project.customer_name || "—"}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
onChange={(e) =>
updateForm("responsible_user_id", e.target.value)
}
className="admin-form-select"
disabled={!canEdit}
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FormField>
@@ -406,7 +465,7 @@ export default function ProjectDetail() {
<FormField label="Stav">
<select
value={form.status}
onChange={(e) => updateForm('status', e.target.value)}
onChange={(e) => updateForm("status", e.target.value)}
className="admin-form-select"
disabled={!canEdit}
>
@@ -419,7 +478,7 @@ export default function ProjectDetail() {
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
onChange={(val: string) => updateForm("start_date", val)}
disabled={!canEdit}
/>
</FormField>
@@ -427,12 +486,11 @@ export default function ProjectDetail() {
<AdminDatePicker
mode="date"
value={form.end_date}
onChange={(val: string) => updateForm('end_date', val)}
onChange={(val: string) => updateForm("end_date", val)}
disabled={!canEdit}
/>
</FormField>
</div>
</div>
</div>
</motion.div>
@@ -455,10 +513,10 @@ export default function ProjectDetail() {
className="admin-form-input"
rows={2}
placeholder="Napište poznámku..."
style={{ resize: 'vertical', width: '100%' }}
style={{ resize: "vertical", width: "100%" }}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey && newNote.trim()) {
handleAddNote()
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote();
}
}}
/>
@@ -471,7 +529,7 @@ export default function ProjectDetail() {
{addingNote ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
'Přidat poznámku'
"Přidat poznámku"
)}
</button>
</div>
@@ -479,57 +537,107 @@ export default function ProjectDetail() {
{/* Legacy notes (read-only) */}
{project.notes && (
<div style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
marginBottom: '0.5rem',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.25rem' }}>
<div
style={{
padding: "0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
color: "var(--text-secondary)",
}}
>
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Starší poznámka (před zavedením systému)
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{project.notes}</div>
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
</div>
)}
{/* Notes list */}
{notesLoading && (
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '8px' }} />
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
{[0, 1, 2].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "8px" }}
/>
))}
</div>
)}
{!notesLoading && notes.length === 0 && !project.notes && (
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem', textAlign: 'center', padding: '1rem 0' }}>
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1rem 0",
}}
>
Zatím žádné poznámky
</div>
)}
{!notesLoading && (notes.length > 0 || project.notes) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{notes.map(note => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
{notes.map((note) => (
<div
key={note.id}
style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
position: 'relative'
padding: "0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
position: "relative",
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "0.5rem",
}}
>
<div className="flex-1">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.25rem",
}}
>
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
{note.user_name}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
<span
style={{
color: "var(--text-tertiary)",
fontSize: "0.75rem",
}}
>
{formatNoteDate(note.created_at)}
</span>
</div>
<div style={{ whiteSpace: 'pre-wrap', fontSize: '0.875rem', lineHeight: 1.5 }}>
<div
style={{
whiteSpace: "pre-wrap",
fontSize: "0.875rem",
lineHeight: 1.5,
}}
>
{note.content}
</div>
</div>
@@ -539,12 +647,25 @@ export default function ProjectDetail() {
className="admin-btn-icon"
title="Smazat poznámku"
disabled={deletingNoteId === note.id}
style={{ flexShrink: 0, opacity: deletingNoteId === note.id ? 0.5 : 1 }}
style={{
flexShrink: 0,
opacity: deletingNoteId === note.id ? 0.5 : 1,
}}
>
{deletingNoteId === note.id ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
<div
className="admin-spinner"
style={{ width: 14, height: 14, borderWidth: 2 }}
/>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
@@ -562,6 +683,7 @@ export default function ProjectDetail() {
{/* Project File Manager */}
<motion.div
style={{ marginBottom: "1rem" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
@@ -587,24 +709,40 @@ export default function ProjectDetail() {
<FormField label="Objednávka">
<div>
{project.order_id ? (
<Link to={`/orders/${project.order_id}`} className="link-accent">
<Link
to={`/orders/${project.order_id}`}
className="link-accent"
>
{project.order_number}
{project.order_status && (
<span className="text-tertiary" style={{ fontWeight: 400, marginLeft: '0.5rem' }}>
({STATUS_LABELS[project.order_status] || project.order_status})
<span
className="text-tertiary"
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
>
(
{STATUS_LABELS[project.order_status] ||
project.order_status}
)
</span>
)}
</Link>
) : '—'}
) : (
"—"
)}
</div>
</FormField>
<FormField label="Nabídka">
<div>
{project.quotation_id ? (
<Link to={`/offers/${project.quotation_id}`} className="link-accent">
<Link
to={`/offers/${project.quotation_id}`}
className="link-accent"
>
{project.quotation_number}
</Link>
) : '—'}
) : (
"—"
)}
</div>
</FormField>
</div>
@@ -614,16 +752,20 @@ export default function ProjectDetail() {
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
setDeleteConfirm(false);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {project.name}&quot;? Tato akce je nevratná.
Opravdu chcete smazat projekt &quot;{project.project_number} {" "}
{project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
@@ -640,5 +782,5 @@ export default function ProjectDetail() {
loading={deleting}
/>
</div>
)
);
}

View File

@@ -1,107 +1,134 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import apiFetch from '../utils/api'
import { formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
import apiFetch from "../utils/api";
import { formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import Pagination from "../components/Pagination";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
const STATUS_CLASSES: Record<string, string> = {
aktivni: 'admin-badge-project-aktivni',
dokonceny: 'admin-badge-project-dokonceny',
zruseny: 'admin-badge-project-zruseny'
}
aktivni: "admin-badge-project-aktivni",
dokonceny: "admin-badge-project-dokonceny",
zruseny: "admin-badge-project-zruseny",
};
interface Project {
id: number
project_number: string
name: string
customer_name: string
responsible_user_name: string
status: string
start_date: string
end_date: string
order_id?: number
order_number?: string
id: number;
project_number: string;
name: string;
customer_name: string;
responsible_user_name: string;
status: string;
start_date: string;
end_date: string;
order_id?: number;
order_number?: string;
}
export default function Projects() {
const alert = useAlert()
const { hasPermission } = useAuth()
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } = useTableSort('project_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
const [deleteFiles, setDeleteFiles] = useState(false)
const { sort, order, handleSort, activeSort } =
useTableSort("project_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false);
const { items: projects, setItems: setProjects, loading, initialLoad, pagination } = useListData<Project>('projects', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst projekty'
})
const {
items: projects,
setItems: setProjects,
loading,
initialLoad,
pagination,
} = useListData<Project>("projects", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst projekty",
});
if (!hasPermission('projects.view')) return <Forbidden />
if (!hasPermission("projects.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteTarget) return
setDeletingId(deleteTarget.id)
if (!deleteTarget) return;
setDeletingId(deleteTarget.id);
try {
const res = await apiFetch(`${API_BASE}/projects/${deleteTarget.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const data = await res.json()
});
const data = await res.json();
if (data.success) {
alert.success(data.message || 'Projekt byl smazán')
setProjects((prev: Project[]) => prev.filter(p => p.id !== deleteTarget.id))
alert.success(data.message || "Projekt byl smazán");
setProjects((prev: Project[]) =>
prev.filter((p) => p.id !== deleteTarget.id),
);
} else {
alert.error(data.error || 'Nepodařilo se smazat projekt')
alert.error(data.error || "Nepodařilo se smazat projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeletingId(null)
setDeleteTarget(null)
setDeleteFiles(false)
setDeletingId(null);
setDeleteTarget(null);
setDeleteFiles(false);
}
}
};
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -110,7 +137,7 @@ export default function Projects() {
</div>
</div>
</div>
)
);
}
return (
@@ -124,12 +151,25 @@ export default function Projects() {
<div>
<h1 className="admin-page-title">Projekty</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? projects.length} {czechPlural(pagination?.total ?? projects.length, 'projekt', 'projekty', 'projektů')}
{pagination?.total ?? projects.length}{" "}
{czechPlural(
pagination?.total ?? projects.length,
"projekt",
"projekty",
"projektů",
)}
</p>
</div>
{hasPermission('projects.create') && (
{hasPermission("projects.create") && (
<Link to="/projects/new" className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -143,14 +183,17 @@ export default function Projects() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s' }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
/>
@@ -159,13 +202,25 @@ export default function Projects() {
{projects.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<p>Zatím nejsou žádné projekty.</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
Vytvořte první projekt tlačítkem výše nebo automaticky při vytvoření objednávky.
<p
style={{ color: "var(--text-tertiary)", fontSize: "0.875rem" }}
>
Vytvořte první projekt tlačítkem výše nebo automaticky při
vytvoření objednávky.
</p>
</div>
) : (
@@ -173,22 +228,58 @@ export default function Projects() {
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('project_number')}>
Číslo <SortIcon column="project_number" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("project_number")}
>
Číslo{" "}
<SortIcon
column="project_number"
sort={activeSort}
order={order}
/>
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('name')}>
Název <SortIcon column="name" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
Název{" "}
<SortIcon column="name" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th>Zodpovědná osoba</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('start_date')}>
Začátek <SortIcon column="start_date" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("start_date")}
>
Začátek{" "}
<SortIcon
column="start_date"
sort={activeSort}
order={order}
/>
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('end_date')}>
Konec <SortIcon column="end_date" sort={activeSort} order={order} />
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("end_date")}
>
Konec{" "}
<SortIcon
column="end_date"
sort={activeSort}
order={order}
/>
</th>
<th>Objednávka</th>
<th>Akce</th>
@@ -202,11 +293,13 @@ export default function Projects() {
{p.project_number}
</Link>
</td>
<td className="fw-500">{p.name || '—'}</td>
<td>{p.customer_name || '—'}</td>
<td>{p.responsible_user_name || '—'}</td>
<td className="fw-500">{p.name || "—"}</td>
<td>{p.customer_name || "—"}</td>
<td>{p.responsible_user_name || "—"}</td>
<td>
<span className={`admin-badge ${STATUS_CLASSES[p.status] || ''}`}>
<span
className={`admin-badge ${STATUS_CLASSES[p.status] || ""}`}
>
{STATUS_LABELS[p.status] || p.status}
</span>
</td>
@@ -214,20 +307,38 @@ export default function Projects() {
<td className="admin-mono">{formatDate(p.end_date)}</td>
<td>
{p.order_id ? (
<Link to={`/orders/${p.order_id}`} className="text-secondary" style={{ textDecoration: 'none' }}>
<Link
to={`/orders/${p.order_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{p.order_number}
</Link>
) : '—'}
) : (
"—"
)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/projects/${p.id}`} className="admin-btn-icon" title="Upravit" aria-label="Upravit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<Link
to={`/projects/${p.id}`}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
{!p.order_id && hasPermission('projects.create') && (
{!p.order_id && hasPermission("projects.create") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"
@@ -237,7 +348,14 @@ export default function Projects() {
{deletingId === p.id ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
@@ -260,15 +378,18 @@ export default function Projects() {
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null)
setDeleteFiles(false)
setDeleteTarget(null);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
@@ -283,5 +404,5 @@ export default function Projects() {
loading={!!deletingId}
/>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,144 +1,154 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string
spz: string
name: string
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number
vehicle_id: number | string
trip_date: string
start_km: number
end_km: number
distance?: number | null
route_from: string
route_to: string
is_business: boolean
notes?: string | null
users?: { id: number; first_name: string; last_name: string }
vehicles?: { id: number; name: string; spz: string }
id: number;
vehicle_id: number | string;
trip_date: string;
start_km: number;
end_km: number;
distance?: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes?: string | null;
users?: { id: number; first_name: string; last_name: string };
vehicles?: { id: number; name: string; spz: string };
}
interface TripForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
vehicle_id: string;
trip_date: string;
start_km: string | number;
end_km: string | number;
route_from: string;
route_to: string;
is_business: number;
notes: string;
}
export default function Trips() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [showModal, setShowModal] = useState(false)
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; tripId: number | null }>({ show: false, tripId: null })
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
tripId: number | null;
}>({ show: false, tripId: null });
const [form, setForm] = useState<TripForm>({
vehicle_id: '',
trip_date: new Date().toISOString().split('T')[0],
start_km: '',
end_km: '',
route_from: '',
route_to: '',
vehicle_id: "",
trip_date: new Date().toISOString().split("T")[0],
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: ''
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [, setLastKm] = useState(0)
notes: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [, setLastKm] = useState(0);
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`),
])
const tripsResult = await tripsRes.json()
const vehiclesResult = await vehiclesRes.json()
if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : [])
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`),
]);
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : []);
}
if (vehiclesResult.success) {
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
},
[alert],
);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
useModalLock(showModal)
useModalLock(showModal);
if (!hasPermission('trips.record')) return <Forbidden />
if (!hasPermission("trips.record")) return <Forbidden />;
const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) {
setLastKm(0)
return
setLastKm(0);
return;
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
const result = await response.json()
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
const result = await response.json();
if (result.success) {
const km = result.data?.last_km || 0
setLastKm(km)
const km = result.data?.last_km || 0;
setLastKm(km);
if (!editingTrip) {
setForm(prev => ({ ...prev, start_km: km }))
setForm((prev) => ({ ...prev, start_km: km }));
}
return
return;
}
} catch { /* fallback below */ }
setLastKm(0)
}
} catch {
/* fallback below */
}
setLastKm(0);
};
const openCreateModal = () => {
setEditingTrip(null)
const today = new Date().toISOString().split('T')[0]
setEditingTrip(null);
const today = new Date().toISOString().split("T")[0];
setForm({
vehicle_id: '',
vehicle_id: "",
trip_date: today,
start_km: '',
end_km: '',
route_from: '',
route_to: '',
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: ''
})
setLastKm(0)
setErrors({})
setShowModal(true)
}
notes: "",
});
setLastKm(0);
setErrors({});
setShowModal(true);
};
const openEditModal = (trip: Trip) => {
setEditingTrip(trip)
setEditingTrip(trip);
setForm({
vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date,
@@ -147,116 +157,146 @@ export default function Trips() {
route_from: trip.route_from,
route_to: trip.route_to,
is_business: Number(trip.is_business),
notes: trip.notes || ''
})
setLastKm(trip.start_km)
setErrors({})
setShowModal(true)
}
notes: trip.notes || "",
});
setLastKm(trip.start_km);
setErrors({});
setShowModal(true);
};
const handleVehicleChange = (vehicleId: string) => {
setForm(prev => ({ ...prev, vehicle_id: vehicleId }))
fetchLastKm(vehicleId)
}
setForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
fetchLastKm(vehicleId);
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!form.vehicle_id) newErrors.vehicle_id = 'Vyberte vozidlo'
if (!form.trip_date) newErrors.trip_date = 'Zadejte datum'
if (!form.start_km) newErrors.start_km = 'Zadejte počáteční km'
if (!form.end_km) newErrors.end_km = 'Zadejte konečný km'
if (form.start_km && form.end_km && parseInt(String(form.end_km)) <= parseInt(String(form.start_km))) {
newErrors.end_km = 'Musí být větší než počáteční'
const newErrors: Record<string, string> = {};
if (!form.vehicle_id) newErrors.vehicle_id = "Vyberte vozidlo";
if (!form.trip_date) newErrors.trip_date = "Zadejte datum";
if (!form.start_km) newErrors.start_km = "Zadejte počáteční km";
if (!form.end_km) newErrors.end_km = "Zadejte konečný km";
if (
form.start_km &&
form.end_km &&
parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
) {
newErrors.end_km = "Musí být větší než počáteční";
}
if (!form.route_from) newErrors.route_from = 'Zadejte místo odjezdu'
if (!form.route_to) newErrors.route_to = 'Zadejte místo příjezdu'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
if (!form.route_from) newErrors.route_from = "Zadejte místo odjezdu";
if (!form.route_to) newErrors.route_to = "Zadejte místo příjezdu";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) return
if (!validateForm()) return;
setSubmitting(true)
setSubmitting(true);
try {
const url = editingTrip
? `${API_BASE}/trips/${editingTrip.id}`
: `${API_BASE}/trips`
: `${API_BASE}/trips`;
const response = await apiFetch(url, {
method: editingTrip ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
method: editingTrip ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
setShowModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
setShowModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSubmitting(false)
setSubmitting(false);
}
}
};
const handleDelete = async (tripId: number) => {
try {
const response = await apiFetch(`${API_BASE}/trips/${tripId}`, {
method: 'DELETE',
})
method: "DELETE",
});
const result = await response.json()
const result = await response.json();
if (result.success) {
await fetchData(false)
alert.success(result.message)
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleteConfirm({ show: false, tripId: null })
setDeleteConfirm({ show: false, tripId: null });
}
}
};
const calculateDistance = (): number => {
const start = parseInt(String(form.start_km)) || 0
const end = parseInt(String(form.end_km)) || 0
return end > start ? end - start : 0
}
const start = parseInt(String(form.start_km)) || 0;
const end = parseInt(String(form.end_km)) || 0;
return end > start ? end - start : 0;
};
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-grid admin-grid-4">
{[0, 1, 2, 3].map(i => (
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
@@ -267,20 +307,20 @@ export default function Trips() {
</div>
</div>
</div>
)
);
}
const totals = trips.reduce(
(acc, t) => {
const dist = t.distance ?? (t.end_km - t.start_km)
acc.count++
acc.total += dist
if (t.is_business) acc.business += dist
else acc.private += dist
return acc
const dist = t.distance ?? t.end_km - t.start_km;
acc.count++;
acc.total += dist;
if (t.is_business) acc.business += dist;
else acc.private += dist;
return acc;
},
{ total: 0, business: 0, private: 0, count: 0 }
)
{ total: 0, business: 0, private: 0, count: 0 },
);
return (
<div>
@@ -293,12 +333,25 @@ export default function Trips() {
<div>
<h1 className="admin-page-title">Kniha jízd</h1>
<p className="admin-page-subtitle">
{new Date().toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })}
{new Date().toLocaleDateString("cs-CZ", {
month: "long",
year: "numeric",
})}
</p>
</div>
<div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -316,7 +369,16 @@ export default function Trips() {
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
@@ -330,19 +392,39 @@ export default function Trips() {
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
@@ -351,20 +433,33 @@ export default function Trips() {
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební</span>
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-icon warning">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.private)} km</span>
<span className="admin-stat-value">
{formatKm(totals.private)} km
</span>
<span className="admin-stat-label">Soukromé</span>
</div>
</div>
@@ -379,7 +474,10 @@ export default function Trips() {
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední jízdy</h2>
<Link to="/trips/history" className="admin-btn admin-btn-secondary admin-btn-sm">
<Link
to="/trips/history"
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zobrazit historii
</Link>
</div>
@@ -387,7 +485,16 @@ export default function Trips() {
{trips.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
@@ -395,7 +502,10 @@ export default function Trips() {
</svg>
</div>
<p>Zatím nemáte žádné záznamy jízd.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první jízdu
</button>
</div>
@@ -416,20 +526,37 @@ export default function Trips() {
<tbody>
{trips.slice(0, 10).map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>
<span className="admin-badge">{trip.vehicles?.spz ?? ''}</span>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : ''}</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
<span className="admin-badge">
{trip.vehicles?.spz ?? ""}
</span>
</td>
<td>
{trip.users
? `${trip.users.first_name} ${trip.users.last_name}`
: ""}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance ?? (trip.end_km - trip.start_km))} km</strong></td>
<td className="admin-mono">
<strong>
{formatKm(
trip.distance ?? trip.end_km - trip.start_km,
)}{" "}
km
</strong>
</td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
@@ -440,18 +567,38 @@ export default function Trips() {
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
onClick={() =>
setDeleteConfirm({ show: true, tripId: trip.id })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
@@ -477,7 +624,10 @@ export default function Trips() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -487,19 +637,23 @@ export default function Trips() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingTrip ? 'Upravit jízdu' : 'Přidat jízdu'}
{editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo" error={errors.vehicle_id} required>
<FormField
label="Vozidlo"
error={errors.vehicle_id}
required
>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value)
setErrors(prev => ({ ...prev, vehicle_id: '' }))
handleVehicleChange(e.target.value);
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}}
className="admin-form-select"
>
@@ -512,41 +666,53 @@ export default function Trips() {
</select>
</FormField>
<FormField label="Datum jízdy" error={errors.trip_date} required>
<FormField
label="Datum jízdy"
error={errors.trip_date}
required
>
<AdminDatePicker
mode="date"
value={form.trip_date}
onChange={(val: string) => {
setForm({ ...form, trip_date: val })
setErrors(prev => ({ ...prev, trip_date: '' }))
setForm({ ...form, trip_date: val });
setErrors((prev) => ({ ...prev, trip_date: "" }));
}}
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Počáteční stav km" error={errors.start_km} required>
<FormField
label="Počáteční stav km"
error={errors.start_km}
required
>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value })
setErrors(prev => ({ ...prev, start_km: '' }))
setForm({ ...form, start_km: e.target.value });
setErrors((prev) => ({ ...prev, start_km: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km" error={errors.end_km} required>
<FormField
label="Konečný stav km"
error={errors.end_km}
required
>
<input
type="number"
inputMode="numeric"
value={form.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value })
setErrors(prev => ({ ...prev, end_km: '' }))
setForm({ ...form, end_km: e.target.value });
setErrors((prev) => ({ ...prev, end_km: "" }));
}}
className="admin-form-input"
min="0"
@@ -565,26 +731,34 @@ export default function Trips() {
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu" error={errors.route_from} required>
<FormField
label="Místo odjezdu"
error={errors.route_from}
required
>
<input
type="text"
value={form.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value })
setErrors(prev => ({ ...prev, route_from: '' }))
setForm({ ...form, route_from: e.target.value });
setErrors((prev) => ({ ...prev, route_from: "" }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<FormField label="Místo příjezdu" error={errors.route_to} required>
<FormField
label="Místo příjezdu"
error={errors.route_to}
required
>
<input
type="text"
value={form.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value })
setErrors(prev => ({ ...prev, route_to: '' }))
setForm({ ...form, route_to: e.target.value });
setErrors((prev) => ({ ...prev, route_to: "" }));
}}
className="admin-form-input"
placeholder="Např. Brno"
@@ -595,7 +769,12 @@ export default function Trips() {
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) => setForm({ ...form, is_business: parseInt(e.target.value) })}
onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
@@ -606,7 +785,9 @@ export default function Trips() {
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
onChange={(e) =>
setForm({ ...form, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
@@ -630,7 +811,7 @@ export default function Trips() {
className="admin-btn admin-btn-primary"
disabled={submitting}
>
{submitting ? 'Ukládám...' : 'Uložit'}
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
@@ -649,5 +830,5 @@ export default function Trips() {
type="danger"
/>
</div>
)
);
}

View File

@@ -1,74 +1,74 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string
spz: string
name: string
id: number | string;
spz: string;
name: string;
}
interface UserShort {
id: number | string
name: string
id: number | string;
name: string;
}
interface Trip {
id: number
vehicle_id: number | string
trip_date: string
start_km: number
end_km: number
distance: number
route_from: string
route_to: string
is_business: number | boolean
notes?: string
spz: string
driver_name: string
id: number;
vehicle_id: number | string;
trip_date: string;
start_km: number;
end_km: number;
distance: number;
route_from: string;
route_to: string;
is_business: number | boolean;
notes?: string;
spz: string;
driver_name: string;
}
interface BackendTrip {
id: number
vehicle_id: number
user_id: number
trip_date: string
start_km: number
end_km: number
distance: number | null
route_from: string
route_to: string
is_business: boolean
notes: string | null
users: { id: number; first_name: string; last_name: string }
vehicles: { id: number; name: string; spz: string }
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
interface EditForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
vehicle_id: string;
trip_date: string;
start_km: string | number;
end_km: string | number;
route_from: string;
route_to: string;
is_business: number;
notes: string;
}
function mapTrip(bt: BackendTrip): Trip {
const distance = bt.distance ?? (bt.end_km - bt.start_km)
const distance = bt.distance ?? bt.end_km - bt.start_km;
return {
id: bt.id,
vehicle_id: bt.vehicle_id,
@@ -80,38 +80,45 @@ function mapTrip(bt: BackendTrip): Trip {
route_to: bt.route_to,
is_business: bt.is_business ? 1 : 0,
notes: bt.notes || undefined,
spz: bt.vehicles?.spz ?? '',
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : '',
}
spz: bt.vehicles?.spz ?? "",
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : "",
};
}
export default function TripsAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [filterMonth, setFilterMonth] = useState(() => String(new Date().getMonth() + 1))
const [filterYear, setFilterYear] = useState(() => String(new Date().getFullYear()))
const [filterVehicleId, setFilterVehicleId] = useState('')
const [filterUserId, setFilterUserId] = useState('')
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [users, setUsers] = useState<UserShort[]>([])
const printRef = useRef<HTMLDivElement>(null)
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1),
);
const [filterYear, setFilterYear] = useState(() =>
String(new Date().getFullYear()),
);
const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [users, setUsers] = useState<UserShort[]>([]);
const printRef = useRef<HTMLDivElement>(null);
const [showEditModal, setShowEditModal] = useState(false)
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
const [showEditModal, setShowEditModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [editForm, setEditForm] = useState<EditForm>({
vehicle_id: '',
trip_date: '',
start_km: '',
end_km: '',
route_from: '',
route_to: '',
vehicle_id: "",
trip_date: "",
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: ''
})
notes: "",
});
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; trip: Trip | null }>({ show: false, trip: null })
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
trip: Trip | null;
}>({ show: false, trip: null });
// Fetch vehicles and users once on mount
useEffect(() => {
@@ -120,53 +127,60 @@ export default function TripsAdmin() {
const [vRes, uRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/users?limit=1000`),
])
const vJson = await vRes.json()
const uJson = await uRes.json()
if (vJson.success) setVehicles(vJson.data)
]);
const vJson = await vRes.json();
const uJson = await uRes.json();
if (vJson.success) setVehicles(vJson.data);
if (uJson.success) {
setUsers(uJson.data.map((u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
})))
setUsers(
uJson.data.map(
(u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
}),
),
);
}
} catch {
// silently fail, filters will just be empty
}
}
fetchLookups()
}, [])
};
fetchLookups();
}, []);
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`
if (filterUserId) url += `&user_id=${filterUserId}`
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`;
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`;
if (filterUserId) url += `&user_id=${filterUserId}`;
const response = await apiFetch(url)
const result = await response.json()
if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip)
setTrips(mapped)
const response = await apiFetch(url);
const result = await response.json();
if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip);
setTrips(mapped);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [filterMonth, filterYear, filterVehicleId, filterUserId, alert])
},
[filterMonth, filterYear, filterVehicleId, filterUserId, alert],
);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
useModalLock(showEditModal)
useModalLock(showEditModal);
if (!hasPermission('trips.admin')) return <Forbidden />
if (!hasPermission("trips.admin")) return <Forbidden />;
const openEditModal = (trip: Trip) => {
setEditingTrip(trip)
setEditingTrip(trip);
setEditForm({
vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date,
@@ -175,82 +189,91 @@ export default function TripsAdmin() {
route_from: trip.route_from,
route_to: trip.route_to,
is_business: Number(trip.is_business),
notes: trip.notes || ''
})
setShowEditModal(true)
}
notes: trip.notes || "",
});
setShowEditModal(true);
};
const handleEditSubmit = async () => {
if (!editingTrip) return
if (parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))) {
alert.error('Konečný stav km musí být větší než počáteční')
return
if (!editingTrip) return;
if (
parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))
) {
alert.error("Konečný stav km musí být větší než počáteční");
return;
}
try {
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
})
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
setShowEditModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const handleDelete = async () => {
if (!deleteConfirm.trip) return
if (!deleteConfirm.trip) return;
try {
const response = await apiFetch(`${API_BASE}/trips/${deleteConfirm.trip.id}`, {
method: 'DELETE',
})
const response = await apiFetch(
`${API_BASE}/trips/${deleteConfirm.trip.id}`,
{
method: "DELETE",
},
);
const result = await response.json()
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, trip: null })
await fetchData(false)
alert.success(result.message)
setDeleteConfirm({ show: false, trip: null });
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const getPeriodName = () => new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString('cs-CZ', { month: 'long', year: 'numeric' })
const getPeriodName = () =>
new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString(
"cs-CZ",
{ month: "long", year: "numeric" },
);
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null
const v = vehicles.find(v => String(v.id) === filterVehicleId)
return v ? `${v.spz} - ${v.name}` : null
}
if (!filterVehicleId) return null;
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
return v ? `${v.spz} - ${v.name}` : null;
};
const getSelectedUserName = () => {
if (!filterUserId) return null
const u = users.find(u => String(u.id) === filterUserId)
return u?.name || null
}
if (!filterUserId) return null;
const u = users.find((u) => String(u.id) === filterUserId);
return u?.name || null;
};
const handlePrint = () => {
const periodName = getPeriodName()
const periodName = getPeriodName();
setTimeout(() => {
if (printRef.current) {
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
const content = printRef.current.innerHTML;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
@@ -324,26 +347,28 @@ export default function TripsAdmin() {
${content}
</body>
</html>
`)
printWindow.document.close()
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print()
}
printWindow.print();
};
}
}, 100)
}
}, 100);
};
const calculateDistance = (): number => {
const start = parseInt(String(editForm.start_km)) || 0
const end = parseInt(String(editForm.end_km)) || 0
return end > start ? end - start : 0
}
const start = parseInt(String(editForm.start_km)) || 0;
const end = parseInt(String(editForm.end_km)) || 0;
return end > start ? end - start : 0;
};
const totals = {
count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips.filter(t => Number(t.is_business)).reduce((sum, t) => sum + t.distance, 0),
}
business: trips
.filter((t) => Number(t.is_business))
.reduce((sum, t) => sum + t.distance, 0),
};
return (
<div>
@@ -363,7 +388,15 @@ export default function TripsAdmin() {
className="admin-btn admin-btn-secondary"
title="Tisk knihy jízd"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
@@ -394,7 +427,9 @@ export default function TripsAdmin() {
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(2000, i).toLocaleString('cs-CZ', { month: 'long' })}
{new Date(2000, i).toLocaleString("cs-CZ", {
month: "long",
})}
</option>
))}
</select>
@@ -406,8 +441,12 @@ export default function TripsAdmin() {
className="admin-form-select"
>
{Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i
return <option key={y} value={y}>{y}</option>
const y = new Date().getFullYear() - 2 + i;
return (
<option key={y} value={y}>
{y}
</option>
);
})}
</select>
</FormField>
@@ -451,7 +490,16 @@ export default function TripsAdmin() {
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
@@ -464,18 +512,38 @@ export default function TripsAdmin() {
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
@@ -484,7 +552,9 @@ export default function TripsAdmin() {
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
@@ -499,8 +569,8 @@ export default function TripsAdmin() {
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
@@ -532,25 +602,31 @@ export default function TripsAdmin() {
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: 'nowrap' }}>
<span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
@@ -561,18 +637,38 @@ export default function TripsAdmin() {
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, trip })}
onClick={() =>
setDeleteConfirm({ show: true, trip })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
@@ -598,7 +694,10 @@ export default function TripsAdmin() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowEditModal(false)} />
<div
className="admin-modal-backdrop"
onClick={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -608,7 +707,12 @@ export default function TripsAdmin() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit jízdu</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingTrip.driver_name}
</p>
</div>
@@ -619,7 +723,12 @@ export default function TripsAdmin() {
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) => setEditForm({ ...editForm, vehicle_id: e.target.value })}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{vehicles.map((v) => (
@@ -634,7 +743,9 @@ export default function TripsAdmin() {
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) => setEditForm({ ...editForm, trip_date: val })}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
@@ -645,7 +756,9 @@ export default function TripsAdmin() {
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) => setEditForm({ ...editForm, start_km: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
@@ -656,7 +769,9 @@ export default function TripsAdmin() {
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) => setEditForm({ ...editForm, end_km: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
@@ -678,7 +793,12 @@ export default function TripsAdmin() {
<input
type="text"
value={editForm.route_from}
onChange={(e) => setEditForm({ ...editForm, route_from: e.target.value })}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
@@ -687,7 +807,9 @@ export default function TripsAdmin() {
<input
type="text"
value={editForm.route_to}
onChange={(e) => setEditForm({ ...editForm, route_to: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
@@ -696,7 +818,12 @@ export default function TripsAdmin() {
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) => setEditForm({ ...editForm, is_business: parseInt(e.target.value) })}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
@@ -707,7 +834,9 @@ export default function TripsAdmin() {
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
@@ -742,17 +871,25 @@ export default function TripsAdmin() {
onClose={() => setDeleteConfirm({ show: false, trip: null })}
onConfirm={handleDelete}
title="Smazat záznam"
message={deleteConfirm.trip ? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?` : ''}
message={
deleteConfirm.trip
? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
{/* Hidden Print Content */}
{trips.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}>
<div ref={printRef} style={{ display: "none" }}>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<img
src="/images/logo-light.png"
alt="BOHA"
className="print-logo"
/>
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">BOHA Automation s.r.o.</div>
@@ -760,9 +897,17 @@ export default function TripsAdmin() {
</div>
<div className="print-header-right">
<div className="period">{getPeriodName()}</div>
{getSelectedVehicleName() && <div className="filters">Vozidlo: {getSelectedVehicleName()}</div>}
{getSelectedUserName() && <div className="filters">Řidič: {getSelectedUserName()}</div>}
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
{getSelectedVehicleName() && (
<div className="filters">
Vozidlo: {getSelectedVehicleName()}
</div>
)}
{getSelectedUserName() && (
<div className="filters">Řidič: {getSelectedUserName()}</div>
)}
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div>
</div>
@@ -776,11 +921,15 @@ export default function TripsAdmin() {
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.business)} km</div>
<div className="summary-value">
{formatKm(totals.business)} km
</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total - totals.business)} km</div>
<div className="summary-value">
{formatKm(totals.total - totals.business)} km
</div>
<div className="summary-label">Soukromé</div>
</div>
</div>
@@ -788,13 +937,19 @@ export default function TripsAdmin() {
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '80px' }}>Řidič</th>
<th style={{ width: '70px' }}>Vozidlo</th>
<th style={{ width: "70px" }}>Datum</th>
<th style={{ width: "80px" }}>Řidič</th>
<th style={{ width: "70px" }}>Vozidlo</th>
<th>Trasa</th>
<th style={{ width: '70px' }} className="text-right">Stav km</th>
<th style={{ width: '60px' }} className="text-right">Vzdálenost</th>
<th style={{ width: '55px' }} className="text-center">Typ</th>
<th style={{ width: "70px" }} className="text-right">
Stav km
</th>
<th style={{ width: "60px" }} className="text-right">
Vzdálenost
</th>
<th style={{ width: "55px" }} className="text-center">
Typ
</th>
<th>Poznámka</th>
</tr>
</thead>
@@ -804,22 +959,34 @@ export default function TripsAdmin() {
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>{trip.spz}</td>
<td>{trip.route_from} &rarr; {trip.route_to}</td>
<td className="text-right">{formatKm(trip.start_km)} - {formatKm(trip.end_km)}</td>
<td className="text-right"><strong>{formatKm(trip.distance)} km</strong></td>
<td>
{trip.route_from} &rarr; {trip.route_to}
</td>
<td className="text-right">
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</td>
<td className="text-right">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td className="text-center">
<span className={`badge ${trip.is_business ? 'badge-success' : 'badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
<span
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>{trip.notes || ''}</td>
<td>{trip.notes || ""}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">Celkem:</td>
<td className="text-right"><strong>{formatKm(totals.total)} km</strong></td>
<td colSpan={5} className="text-right">
Celkem:
</td>
<td className="text-right">
<strong>{formatKm(totals.total)} km</strong>
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
@@ -827,5 +994,5 @@ export default function TripsAdmin() {
</div>
)}
</div>
)
);
}

View File

@@ -1,103 +1,110 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string
spz: string
name: string
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number
trip_date: string
spz: string
driver_name: string
route_from: string
route_to: string
start_km: number
end_km: number
distance: number
is_business: number | boolean
notes?: string
id: number;
trip_date: string;
spz: string;
driver_name: string;
route_from: string;
route_to: string;
start_km: number;
end_km: number;
distance: number;
is_business: number | boolean;
notes?: string;
}
export default function TripsHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [vehicleId, setVehicleId] = useState('')
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [vehicleId, setVehicleId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const totals = trips.reduce(
(acc, t) => ({
total: acc.total + (t.distance || 0),
business: acc.business + (t.is_business ? (t.distance || 0) : 0),
business: acc.business + (t.is_business ? t.distance || 0 : 0),
count: acc.count + 1,
}),
{ total: 0, business: 0, count: 0 }
)
{ total: 0, business: 0, count: 0 },
);
const fetchData = useCallback(async () => {
setLoading(true)
setLoading(true);
try {
const params = new URLSearchParams({ month })
if (user?.id) params.set('user_id', String(user.id))
if (vehicleId) params.set('vehicle_id', vehicleId)
const params = new URLSearchParams({ month });
if (user?.id) params.set("user_id", String(user.id));
if (vehicleId) params.set("vehicle_id", vehicleId);
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips?${params}`),
apiFetch(`${API_BASE}/vehicles`),
])
if (tripsRes.status === 401) return
const tripsResult = await tripsRes.json()
const vehiclesResult = await vehiclesRes.json()
]);
if (tripsRes.status === 401) return;
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
const raw = Array.isArray(tripsResult.data) ? tripsResult.data : tripsResult.data?.items || []
setTrips(raw.map((t: Record<string, unknown>) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || '',
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ''} ${(t.users as Record<string, string>).last_name || ''}`.trim()
: '',
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})))
const raw = Array.isArray(tripsResult.data)
? tripsResult.data
: tripsResult.data?.items || [];
setTrips(
raw.map((t: Record<string, unknown>) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
: "",
distance:
((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})),
);
}
if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error('Nepodařilo se načíst data')
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false)
setLoading(false);
}
}, [month, vehicleId, alert, user?.id])
}, [month, vehicleId, alert, user?.id]);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
if (!hasPermission('trips.history')) return <Forbidden />
if (!hasPermission("trips.history")) return <Forbidden />;
const getMonthName = (monthStr: string): string => {
const [yearStr, monthNum] = monthStr.split('-')
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1)
return date.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
}
const [yearStr, monthNum] = monthStr.split("-");
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1);
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
};
return (
<div>
@@ -155,7 +162,16 @@ export default function TripsHistory() {
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
@@ -168,18 +184,38 @@ export default function TripsHistory() {
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
@@ -188,7 +224,9 @@ export default function TripsHistory() {
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
@@ -204,7 +242,7 @@ export default function TripsHistory() {
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton gap-5">
{[0, 1, 2, 3, 4].map(i => (
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
@@ -236,29 +274,47 @@ export default function TripsHistory() {
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: 'var(--text-secondary)' }}>{trip.driver_name}</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td style={{ color: 'var(--text-secondary)', maxWidth: '200px' }}>
{trip.notes || '—'}
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
@@ -269,5 +325,5 @@ export default function TripsHistory() {
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,252 +1,284 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number
username: string
email: string
first_name: string
last_name: string
role_id: number
roles?: { id: number; name: string; display_name: string } | null
is_active: boolean
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
interface Role {
id: number
name: string
display_name: string
id: number;
name: string;
display_name: string;
}
interface FormData {
username: string
email: string
password: string
first_name: string
last_name: string
role_id: number | string
is_active: boolean
username: string;
email: string;
password: string;
first_name: string;
last_name: string;
role_id: number | string;
is_active: boolean;
}
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth()
const alert = useAlert()
const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user: User | null }>({ isOpen: false, user: null })
const [deleting, setDeleting] = useState(false)
const { user: currentUser, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user: User | null;
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: '',
is_active: true
})
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role_id: "",
is_active: true,
});
useModalLock(showModal)
useModalLock(showModal);
const fetchUsers = useCallback(async () => {
try {
const usersRes = await apiFetch(`${API_BASE}/users`)
const usersData = await usersRes.json()
const usersRes = await apiFetch(`${API_BASE}/users`);
const usersData = await usersRes.json();
if (usersData.success) {
setUsers(Array.isArray(usersData.data) ? usersData.data : [])
setUsers(Array.isArray(usersData.data) ? usersData.data : []);
} else {
alert.error(usersData.error || 'Nepodařilo se načíst uživatele')
alert.error(usersData.error || "Nepodařilo se načíst uživatele");
}
// Roles fetch — gracefully handle 403 if user lacks settings.roles permission
try {
const rolesRes = await apiFetch(`${API_BASE}/roles`)
const rolesData = await rolesRes.json()
const rolesRes = await apiFetch(`${API_BASE}/roles`);
const rolesData = await rolesRes.json();
if (rolesData.success) {
setRoles(Array.isArray(rolesData.data) ? rolesData.data : [])
setRoles(Array.isArray(rolesData.data) ? rolesData.data : []);
}
} catch { /* roles not accessible */ }
} catch {
/* roles not accessible */
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setLoading(false)
setLoading(false);
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers()
}, [fetchUsers])
fetchUsers();
}, [fetchUsers]);
if (!hasPermission('users.view')) return <Forbidden />
if (!hasPermission("users.view")) return <Forbidden />;
const openCreateModal = () => {
setEditingUser(null)
setEditingUser(null);
setFormData({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: roles[0]?.id || '',
is_active: true
})
setShowModal(true)
}
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role_id: roles[0]?.id || "",
is_active: true,
});
setShowModal(true);
};
const openEditModal = (user: User) => {
setEditingUser(user)
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
password: "",
first_name: user.first_name,
last_name: user.last_name,
role_id: user.role_id,
is_active: user.is_active
})
setShowModal(true)
}
is_active: user.is_active,
});
setShowModal(true);
};
const closeModal = () => {
setShowModal(false)
setEditingUser(null)
}
setShowModal(false);
setEditingUser(null);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
e?.preventDefault();
const dataToSave = { ...formData }
const wasEditing = editingUser
const editingId = editingUser?.id
const dataToSave = { ...formData };
const wasEditing = editingUser;
const editingId = editingUser?.id;
try {
const url = wasEditing
? `${API_BASE}/users/${editingId}`
: `${API_BASE}/users`
: `${API_BASE}/users`;
const method = wasEditing ? 'PUT' : 'POST'
const method = wasEditing ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
const data = await response.json()
const data = await response.json();
if (data.success) {
if (wasEditing && currentUser && Number(editingId) === Number(currentUser.id)) {
if (
wasEditing &&
currentUser &&
Number(editingId) === Number(currentUser.id)
) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
}
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(wasEditing ? 'Uživatel byl upraven' : 'Uživatel byl vytvořen')
fetchUsers()
closeModal();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
fetchUsers();
} else {
alert.error(data.error || 'Nepodařilo se uložit uživatele')
alert.error(data.error || "Nepodařilo se uložit uživatele");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const openDeleteModal = (user: User) => {
setDeleteModal({ isOpen: true, user })
}
setDeleteModal({ isOpen: true, user });
};
const closeDeleteModal = () => {
setDeleteModal({ isOpen: false, user: null })
}
setDeleteModal({ isOpen: false, user: null });
};
const handleDelete = async () => {
if (!deleteModal.user) return
if (!deleteModal.user) return;
setDeleting(true)
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/users/${deleteModal.user.id}`, {
method: 'DELETE',
})
const response = await apiFetch(
`${API_BASE}/users/${deleteModal.user.id}`,
{
method: "DELETE",
},
);
const data = await response.json()
const data = await response.json();
if (data.success) {
closeDeleteModal()
fetchUsers()
alert.success('Uživatel byl smazán')
closeDeleteModal();
fetchUsers();
alert.success("Uživatel byl smazán");
} else {
alert.error(data.error || 'Nepodařilo se smazat uživatele')
alert.error(data.error || "Nepodařilo se smazat uživatele");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
const toggleActive = async (user: User) => {
try {
const response = await apiFetch(`${API_BASE}/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
is_active: !user.is_active
})
})
is_active: !user.is_active,
}),
});
const data = await response.json()
const data = await response.json();
if (data.success) {
fetchUsers()
alert.success(user.is_active ? 'Uživatel byl deaktivován' : 'Uživatel byl aktivován')
fetchUsers();
alert.success(
user.is_active
? "Uživatel byl deaktivován"
: "Uživatel byl aktivován",
);
} else {
alert.error(data.error || 'Nepodařilo se změnit stav uživatele')
alert.error(data.error || "Nepodařilo se změnit stav uživatele");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const getRoleBadgeClass = (roleName: string): string => {
switch (roleName) {
case 'admin': return 'admin-badge admin-badge-admin'
default: return 'admin-badge admin-badge-viewer'
case "admin":
return "admin-badge admin-badge-admin";
default:
return "admin-badge admin-badge-viewer";
}
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '160px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ width: "160px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -254,7 +286,7 @@ export default function Users() {
</div>
</div>
</div>
)
);
}
return (
@@ -267,10 +299,22 @@ export default function Users() {
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">Správa uživatelských úč a oprávnění</p>
<p className="admin-page-subtitle">
Správa uživatelských úč a oprávnění
</p>
</div>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -302,30 +346,43 @@ export default function Users() {
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username).charAt(0).toUpperCase()}
{(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
</div>
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
</div>
<div className="admin-table-username">@{user.username}</div>
<div className="admin-table-username">
@{user.username}
</div>
</div>
</div>
</td>
<td>{user.email}</td>
<td>
<span className={getRoleBadgeClass(user.roles?.name ?? '')}>
{user.roles?.display_name || user.roles?.name || '—'}
<span
className={getRoleBadgeClass(user.roles?.name ?? "")}
>
{user.roles?.display_name || user.roles?.name || "—"}
</span>
</td>
<td>
<button
onClick={() => user.id !== currentUser?.id && toggleActive(user)}
onClick={() =>
user.id !== currentUser?.id && toggleActive(user)
}
disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
style={{ cursor: user.id === currentUser?.id ? 'not-allowed' : 'pointer' }}
className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
style={{
cursor:
user.id === currentUser?.id
? "not-allowed"
: "pointer",
}}
>
{user.is_active ? 'Aktivní' : 'Neaktivní'}
{user.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
@@ -336,7 +393,14 @@ export default function Users() {
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
@@ -348,7 +412,14 @@ export default function Users() {
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="18"
height="18"
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>
@@ -383,7 +454,9 @@ export default function Users() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingUser ? 'Upravit uživatele' : 'Přidat nového uživatele'}
{editingUser
? "Upravit uživatele"
: "Přidat nového uživatele"}
</h2>
</div>
@@ -394,7 +467,12 @@ export default function Users() {
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
@@ -403,7 +481,12 @@ export default function Users() {
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
@@ -414,7 +497,9 @@ export default function Users() {
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
@@ -424,17 +509,23 @@ export default function Users() {
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField label={`Heslo ${editingUser ? '(ponechte prázdné pro zachování stávajícího)' : ''}`}>
<FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
@@ -443,7 +534,9 @@ export default function Users() {
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) => setFormData({ ...formData, role_id: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
@@ -459,7 +552,12 @@ export default function Users() {
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
@@ -467,11 +565,19 @@ export default function Users() {
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">
{editingUser ? 'Uložit změny' : 'Vytvořit uživatele'}
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
</button>
</div>
</motion.div>
@@ -491,5 +597,5 @@ export default function Users() {
loading={deleting}
/>
</div>
)
);
}

View File

@@ -1,209 +1,234 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
const API_BASE = "/api/admin";
interface Vehicle {
id: number
spz: string
name: string
brand?: string
model?: string
initial_km: number
current_km: number
trip_count: number
is_active: boolean | number
id: number;
spz: string;
name: string;
brand?: string;
model?: string;
initial_km: number;
current_km: number;
trip_count: number;
is_active: boolean | number;
}
interface VehicleForm {
spz: string
name: string
brand: string
model: string
initial_km: number
is_active: boolean
spz: string;
name: string;
brand: string;
model: string;
initial_km: number;
is_active: boolean;
}
export default function Vehicles() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [showModal, setShowModal] = useState(false)
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
const [showModal, setShowModal] = useState(false);
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
const [form, setForm] = useState<VehicleForm>({
spz: '',
name: '',
brand: '',
model: '',
spz: "",
name: "",
brand: "",
model: "",
initial_km: 0,
is_active: true
})
is_active: true,
});
const [errors, setErrors] = useState<Record<string, string>>({})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; vehicle: Vehicle | null }>({ show: false, vehicle: null })
const [errors, setErrors] = useState<Record<string, string>>({});
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
vehicle: Vehicle | null;
}>({ show: false, vehicle: null });
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/vehicles`)
const result = await response.json()
if (result.success) {
setVehicles(Array.isArray(result.data) ? result.data : [])
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const response = await apiFetch(`${API_BASE}/vehicles`);
const result = await response.json();
if (result.success) {
setVehicles(Array.isArray(result.data) ? result.data : []);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
},
[alert],
);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
useModalLock(showModal)
useModalLock(showModal);
if (!hasPermission('trips.vehicles')) return <Forbidden />
if (!hasPermission("trips.vehicles")) return <Forbidden />;
const openCreateModal = () => {
setEditingVehicle(null)
setEditingVehicle(null);
setForm({
spz: '',
name: '',
brand: '',
model: '',
spz: "",
name: "",
brand: "",
model: "",
initial_km: 0,
is_active: true
})
setErrors({})
setShowModal(true)
}
is_active: true,
});
setErrors({});
setShowModal(true);
};
const openEditModal = (vehicle: Vehicle) => {
setEditingVehicle(vehicle)
setEditingVehicle(vehicle);
setForm({
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
brand: vehicle.brand || "",
model: vehicle.model || "",
initial_km: vehicle.initial_km,
is_active: Boolean(vehicle.is_active)
})
setErrors({})
setShowModal(true)
}
is_active: Boolean(vehicle.is_active),
});
setErrors({});
setShowModal(true);
};
const handleSubmit = async () => {
const newErrors: Record<string, string> = {}
if (!form.spz) newErrors.spz = 'Zadejte SPZ'
if (!form.name) newErrors.name = 'Zadejte název'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
const newErrors: Record<string, string> = {};
if (!form.spz) newErrors.spz = "Zadejte SPZ";
if (!form.name) newErrors.name = "Zadejte název";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
try {
const url = editingVehicle
? `${API_BASE}/vehicles/${editingVehicle.id}`
: `${API_BASE}/vehicles`
const method = editingVehicle ? 'PUT' : 'POST'
: `${API_BASE}/vehicles`;
const method = editingVehicle ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
setShowModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
setShowModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const handleDelete = async () => {
if (!deleteConfirm.vehicle) return
if (!deleteConfirm.vehicle) return;
try {
const response = await apiFetch(`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`, {
method: 'DELETE',
})
const response = await apiFetch(
`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`,
{
method: "DELETE",
},
);
const result = await response.json()
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null })
await fetchData(false)
alert.success(result.message)
setDeleteConfirm({ show: false, vehicle: null });
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const toggleActive = async (vehicle: Vehicle) => {
try {
const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
brand: vehicle.brand || "",
model: vehicle.model || "",
initial_km: vehicle.initial_km,
is_active: !vehicle.is_active
})
})
is_active: !vehicle.is_active,
}),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
fetchData(false)
alert.success(vehicle.is_active ? 'Vozidlo bylo deaktivováno' : 'Vozidlo bylo aktivováno')
fetchData(false);
alert.success(
vehicle.is_active
? "Vozidlo bylo deaktivováno"
: "Vozidlo bylo aktivováno",
);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-line h-10" style={{ width: '150px', borderRadius: '8px' }} />
<div
className="admin-skeleton-line h-10"
style={{ width: "150px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -211,7 +236,7 @@ export default function Vehicles() {
</div>
</div>
</div>
)
);
}
return (
@@ -226,8 +251,18 @@ export default function Vehicles() {
<h1 className="admin-page-title">Správa vozidel</h1>
</div>
<div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -246,7 +281,16 @@ export default function Vehicles() {
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
@@ -254,7 +298,10 @@ export default function Vehicles() {
</svg>
</div>
<p>Zatím nejsou žádná vozidla.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první vozidlo
</button>
</div>
@@ -276,23 +323,32 @@ export default function Vehicles() {
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr key={vehicle.id} className={!vehicle.is_active ? 'admin-table-row-inactive' : ''}>
<tr
key={vehicle.id}
className={
!vehicle.is_active ? "admin-table-row-inactive" : ""
}
>
<td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ''} ${vehicle.model || ''}`.trim()
: '—'}
? `${vehicle.brand || ""} ${vehicle.model || ""}`.trim()
: "—"}
</td>
<td className="admin-mono">
{formatKm(vehicle.initial_km)} km
</td>
<td className="admin-mono fw-500">
{formatKm(vehicle.current_km)} km
</td>
<td className="admin-mono">{formatKm(vehicle.initial_km)} km</td>
<td className="admin-mono fw-500">{formatKm(vehicle.current_km)} km</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
{vehicle.is_active ? 'Aktivní' : 'Neaktivní'}
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
@@ -303,18 +359,38 @@ export default function Vehicles() {
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, vehicle })}
onClick={() =>
setDeleteConfirm({ show: true, vehicle })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
@@ -340,7 +416,10 @@ export default function Vehicles() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -350,7 +429,7 @@ export default function Vehicles() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingVehicle ? 'Upravit vozidlo' : 'Přidat vozidlo'}
{editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
</h2>
</div>
@@ -362,8 +441,11 @@ export default function Vehicles() {
type="text"
value={form.spz}
onChange={(e) => {
setForm({ ...form, spz: e.target.value.toUpperCase() })
setErrors(prev => ({ ...prev, spz: '' }))
setForm({
...form,
spz: e.target.value.toUpperCase(),
});
setErrors((prev) => ({ ...prev, spz: "" }));
}}
className="admin-form-input"
placeholder="1AB 2345"
@@ -376,8 +458,8 @@ export default function Vehicles() {
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value })
setErrors(prev => ({ ...prev, name: '' }))
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Služební #1"
@@ -391,7 +473,9 @@ export default function Vehicles() {
<input
type="text"
value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })}
onChange={(e) =>
setForm({ ...form, brand: e.target.value })
}
className="admin-form-input"
placeholder="Škoda"
/>
@@ -401,7 +485,9 @@ export default function Vehicles() {
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
onChange={(e) =>
setForm({ ...form, model: e.target.value })
}
className="admin-form-input"
placeholder="Octavia Combi"
/>
@@ -409,12 +495,19 @@ export default function Vehicles() {
</div>
<div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label>
<label className="admin-form-label">
Počáteční stav km
</label>
<input
type="number"
inputMode="numeric"
value={form.initial_km}
onChange={(e) => setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })}
onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
@@ -427,7 +520,9 @@ export default function Vehicles() {
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
/>
<span>Vozidlo je aktivní</span>
</label>
@@ -461,10 +556,14 @@ export default function Vehicles() {
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={deleteConfirm.vehicle ? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?` : ''}
message={
deleteConfirm.vehicle
? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
)
);
}

View File

@@ -1,102 +1,109 @@
let showSessionExpiredAlert = false
let showLogoutAlert = false
let getTokenFn: (() => string | null) | null = null
let refreshFn: (() => Promise<boolean>) | null = null
let refreshPromise: Promise<boolean> | null = null
let showSessionExpiredAlert = false;
let showLogoutAlert = false;
let getTokenFn: (() => string | null) | null = null;
let refreshFn: (() => Promise<boolean>) | null = null;
let refreshPromise: Promise<boolean> | null = null;
export const shouldShowSessionExpiredAlert = (): boolean => {
if (showSessionExpiredAlert) {
showSessionExpiredAlert = false
return true
showSessionExpiredAlert = false;
return true;
}
return false
}
return false;
};
export const setSessionExpired = (): void => {
showSessionExpiredAlert = true
}
showSessionExpiredAlert = true;
};
export const shouldShowLogoutAlert = (): boolean => {
if (showLogoutAlert) {
showLogoutAlert = false
return true
showLogoutAlert = false;
return true;
}
return false
}
return false;
};
export const setLogoutAlert = (): void => {
showLogoutAlert = true
}
showLogoutAlert = true;
};
export const setTokenGetter = (fn: () => string | null): void => {
getTokenFn = fn
}
getTokenFn = fn;
};
export const setRefreshFn = (fn: () => Promise<boolean>): void => {
refreshFn = fn
}
refreshFn = fn;
};
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
let token: string | null = null
export const apiFetch = async (
url: string,
options: RequestInit = {},
): Promise<Response> => {
let token: string | null = null;
try {
token = getTokenFn ? getTokenFn() : null
token = getTokenFn ? getTokenFn() : null;
} catch {
// token retrieval failed
}
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
}
};
if (!headers['Content-Type'] && options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
if (
!headers["Content-Type"] &&
options.body &&
!(options.body instanceof FormData)
) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
headers["Authorization"] = `Bearer ${token}`;
}
let response = await fetch(url, {
...options,
headers,
credentials: 'include',
})
credentials: "include",
});
if (response.status === 401 && refreshFn) {
try {
if (!refreshPromise) {
refreshPromise = refreshFn().finally(() => {
refreshPromise = null
})
refreshPromise = null;
});
}
const refreshed = await refreshPromise
const refreshed = await refreshPromise;
if (refreshed) {
token = getTokenFn ? getTokenFn() : null
token = getTokenFn ? getTokenFn() : null;
if (token) {
headers['Authorization'] = `Bearer ${token}`
headers["Authorization"] = `Bearer ${token}`;
}
response = await fetch(url, {
...options,
headers,
credentials: 'include',
})
credentials: "include",
});
} else {
setSessionExpired()
setSessionExpired();
}
} catch {
setSessionExpired()
setSessionExpired();
}
}
return response
}
return response;
};
export const getAccessToken = (): string | null => {
try {
return getTokenFn ? getTokenFn() : null
return getTokenFn ? getTokenFn() : null;
} catch {
return null
return null;
}
}
};
export default apiFetch
export default apiFetch;

View File

@@ -1,169 +1,189 @@
interface AttendanceRecord {
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
leave_type?: string
leave_hours?: number
shift_date?: string
notes?: string
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
leave_type?: string;
leave_hours?: number;
shift_date?: string;
notes?: string;
project_logs?: Array<{
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
}>
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}>;
}
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('cs-CZ')
}
if (!dateStr) return "—";
const d = new Date(dateStr);
return d.toLocaleDateString("cs-CZ");
};
/** Extract time as HH:MM from a datetime string without timezone conversion */
const extractTime = (datetime: string): string => {
// Try ISO format: "2026-03-23T08:00:00.000Z" or "2026-03-23T08:00:00"
const tMatch = datetime.match(/T(\d{2}):(\d{2})/)
if (tMatch) return `${tMatch[1]}:${tMatch[2]}`
const tMatch = datetime.match(/T(\d{2}):(\d{2})/);
if (tMatch) return `${tMatch[1]}:${tMatch[2]}`;
// Try space format: "2026-03-23 08:00:00"
const sMatch = datetime.match(/\s(\d{2}):(\d{2})/)
if (sMatch) return `${sMatch[1]}:${sMatch[2]}`
const sMatch = datetime.match(/\s(\d{2}):(\d{2})/);
if (sMatch) return `${sMatch[1]}:${sMatch[2]}`;
// Fallback: try parsing time-only "08:00"
const hMatch = datetime.match(/^(\d{2}):(\d{2})/)
if (hMatch) return `${hMatch[1]}:${hMatch[2]}`
return datetime
}
const hMatch = datetime.match(/^(\d{2}):(\d{2})/);
if (hMatch) return `${hMatch[1]}:${hMatch[2]}`;
return datetime;
};
export const formatDatetime = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
if (!datetime) return "—";
// Extract date part without timezone conversion
const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/)
const datePart = dMatch ? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.` : ''
return `${datePart} ${extractTime(datetime)}`
}
const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const datePart = dMatch
? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.`
: "";
return `${datePart} ${extractTime(datetime)}`;
};
export const formatTime = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
return extractTime(datetime)
}
if (!datetime) return "—";
return extractTime(datetime);
};
export const calculateWorkMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime()
const breakEnd = new Date(record.break_end).getTime()
minutes -= (breakEnd - breakStart) / 60000
const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000;
}
return Math.max(0, Math.floor(minutes))
}
return Math.max(0, Math.floor(minutes));
};
export const formatMinutes = (minutes: number, withUnit = false): string => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${h}:${String(m).padStart(2, '0')}${withUnit ? ' h' : ''}`
}
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}:${String(m).padStart(2, "0")}${withUnit ? " h" : ""}`;
};
export const getLeaveTypeName = (type: string): string => {
const types: Record<string, string> = {
work: 'Práce',
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno',
}
return types[type] || 'Práce'
}
work: "Práce",
vacation: "Dovolená",
sick: "Nemoc",
holiday: "Svátek",
unpaid: "Neplacené volno",
};
return types[type] || "Práce";
};
export const getLeaveTypeBadgeClass = (type: string): string => {
const classes: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
holiday: 'badge-holiday',
unpaid: 'badge-unpaid',
}
return classes[type] || ''
}
vacation: "badge-vacation",
sick: "badge-sick",
holiday: "badge-holiday",
unpaid: "badge-unpaid",
};
return classes[type] || "";
};
export const getDatePart = (datetime: string | null | undefined): string => {
if (!datetime) return ''
if (datetime.includes('T')) {
return datetime.split('T')[0]
if (!datetime) return "";
if (datetime.includes("T")) {
return datetime.split("T")[0];
}
return datetime.split(' ')[0]
}
return datetime.split(" ")[0];
};
export const getTimePart = (datetime: string | null | undefined): string => {
if (!datetime) return ''
const d = new Date(datetime)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
if (!datetime) return "";
const d = new Date(datetime);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
export const calcProjectMinutesTotal = (logs: Array<{ project_id?: number; hours?: string | number; minutes?: string | number }>): number => {
return logs.filter(l => l.project_id).reduce((sum, l) => {
return sum + (parseInt(String(l.hours)) || 0) * 60 + (parseInt(String(l.minutes)) || 0)
}, 0)
}
export const calcProjectMinutesTotal = (
logs: Array<{
project_id?: number;
hours?: string | number;
minutes?: string | number;
}>,
): number => {
return logs
.filter((l) => l.project_id)
.reduce((sum, l) => {
return (
sum +
(parseInt(String(l.hours)) || 0) * 60 +
(parseInt(String(l.minutes)) || 0)
);
}, 0);
};
interface ShiftForm {
arrival_time?: string
departure_time?: string
arrival_date?: string
departure_date?: string
break_start_time?: string
break_end_time?: string
break_start_date?: string
break_end_date?: string
arrival_time?: string;
departure_time?: string;
arrival_date?: string;
departure_date?: string;
break_start_time?: string;
break_end_time?: string;
break_start_date?: string;
break_end_date?: string;
}
export const calcFormWorkMinutes = (form: ShiftForm): number => {
if (!form.arrival_time || !form.departure_time) return 0
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`
const departureStr = `${form.departure_date}T${form.departure_time}`
let mins = (new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000
if (!form.arrival_time || !form.departure_time) return 0;
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`;
const departureStr = `${form.departure_date}T${form.departure_time}`;
let mins =
(new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000;
if (form.break_start_time && form.break_end_time) {
const bsStr = `${form.break_start_date}T${form.break_start_time}`
const beStr = `${form.break_end_date}T${form.break_end_time}`
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000
const bsStr = `${form.break_start_date}T${form.break_start_time}`;
const beStr = `${form.break_end_date}T${form.break_end_time}`;
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000;
}
return Math.max(0, Math.floor(mins))
}
return Math.max(0, Math.floor(mins));
};
export const formatTimeOrDatetimePrint = (datetime: string | null | undefined, shiftDate: string): string => {
if (!datetime) return '—'
export const formatTimeOrDatetimePrint = (
datetime: string | null | undefined,
shiftDate: string,
): string => {
if (!datetime) return "—";
// Extract date from the datetime string directly (no timezone conversion)
const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/)
const timeDate = dateMatch ? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}` : ''
const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const timeDate = dateMatch
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
: "";
if (timeDate && timeDate !== shiftDate) {
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`
return `${datePart} ${extractTime(datetime)}`
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
return `${datePart} ${extractTime(datetime)}`;
}
return extractTime(datetime)
}
return extractTime(datetime);
};
export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
const leaveType = record.leave_type || 'work'
if (leaveType !== 'work') {
return (Number(record.leave_hours) || 8) * 60
const leaveType = record.leave_type || "work";
if (leaveType !== "work") {
return (Number(record.leave_hours) || 8) * 60;
}
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime()
const breakEnd = new Date(record.break_end).getTime()
minutes -= (breakEnd - breakStart) / 60000
const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000;
}
return Math.max(0, Math.floor(minutes))
}
return Math.max(0, Math.floor(minutes));
};

View File

@@ -1,79 +1,114 @@
export const LEAVE_TYPE_LABELS: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno',
}
vacation: "Dovolená",
sick: "Nemoc",
holiday: "Svátek",
unpaid: "Neplacené volno",
};
export const STATUS_DOT_CLASS: Record<string, string> = {
in: 'dash-status-in',
away: 'dash-status-away',
out: 'dash-status-out',
leave: 'dash-status-leave',
}
in: "dash-status-in",
away: "dash-status-away",
out: "dash-status-out",
leave: "dash-status-leave",
};
export const STATUS_LABELS: Record<string, string> = {
in: 'Přítomen',
away: 'Přestávka',
out: 'Nepřihlášen',
leave: 'Nepřítomen',
}
in: "Přítomen",
away: "Přestávka",
out: "Nepřihlášen",
leave: "Nepřítomen",
};
export const ENTITY_TYPE_LABELS: Record<string, string> = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
user: "Uživatel",
attendance: "Docházka",
leave_request: "Žádost o nepřítomnost",
offers_quotation: "Nabídka",
offers_customer: "Zákazník",
offers_item_template: "Šablona položky",
offers_scope_template: "Šablona rozsahu",
offers_settings: "Nastavení nabídek",
orders_order: "Objednávka",
invoices_invoice: "Faktura",
projects_project: "Projekt",
role: "Role",
trips: "Jízda",
vehicles: "Vozidlo",
bank_account: "Bankovní účet",
};
export const ACTION_LABELS: Record<string, string> = {
create: 'Vytvořil',
update: 'Upravil',
delete: 'Smazal',
login: 'Přihlášení',
}
create: "Vytvořil",
update: "Upravil",
delete: "Smazal",
login: "Přihlášení",
};
export function getCzechDate(): string {
const now = new Date()
const days = ['Neděle', 'Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota']
const months = ['ledna', 'února', 'března', 'dubna', 'května', 'června', 'července', 'srpna', 'září', 'října', 'listopadu', 'prosince']
const day = days[now.getDay()]
const oneJan = new Date(now.getFullYear(), 0, 1)
const week = Math.ceil(((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7)
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`
const now = new Date();
const days = [
"Neděle",
"Pondělí",
"Úterý",
"Středa",
"Čtvrtek",
"Pátek",
"Sobota",
];
const months = [
"ledna",
"února",
"března",
"dubna",
"května",
"června",
"července",
"srpna",
"září",
"října",
"listopadu",
"prosince",
];
const day = days[now.getDay()];
const oneJan = new Date(now.getFullYear(), 0, 1);
const week = Math.ceil(
((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7,
);
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`;
}
export function getActivityIconClass(action: string): string {
const map: Record<string, string> = { create: 'success', update: 'info', delete: 'danger', login: 'accent' }
return map[action] || 'muted'
const map: Record<string, string> = {
create: "success",
update: "info",
delete: "danger",
login: "accent",
};
return map[action] || "muted";
}
export function formatActivityTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return 'Právě teď'
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return "Právě teď";
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`;
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
return date.toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleDateString('cs-CZ', { day: '2-digit', month: '2-digit' })
return date.toLocaleDateString("cs-CZ", { day: "2-digit", month: "2-digit" });
}
export function formatSessionDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('cs-CZ', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
const date = new Date(dateString);
return date.toLocaleDateString("cs-CZ", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

View File

@@ -1,26 +1,39 @@
export function formatCurrency(amount: number | string, currency: string): string {
const num = Number(amount) || 0
export function formatCurrency(
amount: number | string,
currency: string,
): string {
const num = Number(amount) || 0;
switch (currency) {
case 'EUR': return `${num.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
case 'USD': return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
case 'CZK': return `${num.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
case 'GBP': return `£${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
default: return `${num.toFixed(2)} ${currency}`
case "EUR":
return `${num.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
case "USD":
return `$${num.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
case "CZK":
return `${num.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`;
case "GBP":
return `£${num.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
default:
return `${num.toFixed(2)} ${currency}`;
}
}
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('cs-CZ')
if (!dateStr) return "—";
const d = new Date(dateStr);
return d.toLocaleDateString("cs-CZ");
}
export function formatKm(km: number | string): string {
return new Intl.NumberFormat('cs-CZ').format(Number(km) || 0)
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
}
export function czechPlural(n: number, one: string, few: string, many: string): string {
if (n === 1) return one
if (n >= 2 && n <= 4) return few
return many
export function czechPlural(
n: number,
one: string,
few: string,
many: string,
): string {
if (n === 1) return one;
if (n >= 2 && n <= 4) return few;
return many;
}