style: run prettier on entire codebase
This commit is contained in:
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default function ShortcutsHelp() {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 →</Link>
|
||||
<Link
|
||||
to="/audit-log"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
Detail →
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 →</Link>
|
||||
<Link
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
Detail →
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user