style: run prettier on entire codebase

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,31 @@
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from './AdminDatePicker'
import useModalLock from '../hooks/useModalLock'
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
interface BulkAttendanceForm {
month: string
user_ids: string[]
arrival_time: string
departure_time: string
break_start_time: string
break_end_time: string
month: string;
user_ids: string[];
arrival_time: string;
departure_time: string;
break_start_time: string;
break_end_time: string;
}
interface BulkAttendanceUser {
id: number | string
name: string
id: number | string;
name: string;
}
interface BulkAttendanceModalProps {
show: boolean
onClose: () => void
form: BulkAttendanceForm
setForm: (form: BulkAttendanceForm) => void
users: BulkAttendanceUser[]
onSubmit: () => void
submitting: boolean
toggleUser: (userId: number | string) => void
toggleAllUsers: () => void
show: boolean;
onClose: () => void;
form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void;
users: BulkAttendanceUser[];
onSubmit: () => void;
submitting: boolean;
toggleUser: (userId: number | string) => void;
toggleAllUsers: () => void;
}
export default function BulkAttendanceModal({
@@ -39,7 +39,7 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(show)
useModalLock(show);
return (
<AnimatePresence>
@@ -51,7 +51,10 @@ export default function BulkAttendanceModal({
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => !submitting && onClose()} />
<div
className="admin-modal-backdrop"
onClick={() => !submitting && onClose()}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -61,8 +64,15 @@ export default function BulkAttendanceModal({
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Existující záznamy se přeskočí.
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
fontSize: "0.875rem",
}}
>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
označí. Existující záznamy se přeskočí.
</p>
</div>
@@ -84,30 +94,32 @@ export default function BulkAttendanceModal({
type="button"
onClick={toggleAllUsers}
style={{
marginLeft: '0.75rem',
background: 'none',
border: 'none',
color: 'var(--accent-color)',
cursor: 'pointer',
fontSize: '0.8125rem',
marginLeft: "0.75rem",
background: "none",
border: "none",
color: "var(--accent-color)",
cursor: "pointer",
fontSize: "0.8125rem",
fontWeight: 500,
padding: 0,
}}
>
{form.user_ids.length === users.length ? 'Odznačit vše' : 'Vybrat vše'}
{form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</button>
</label>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
maxHeight: '200px',
overflowY: 'auto',
padding: '0.75rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--border-radius-sm)',
border: '1px solid var(--border-color)',
display: "flex",
flexDirection: "column",
gap: "0.375rem",
maxHeight: "200px",
overflowY: "auto",
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius-sm)",
border: "1px solid var(--border-color)",
}}
>
{users.map((user) => (
@@ -132,7 +144,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) => setForm({ ...form, arrival_time: val })}
onChange={(val) =>
setForm({ ...form, arrival_time: val })
}
/>
</div>
<div className="admin-form-group">
@@ -140,7 +154,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => setForm({ ...form, departure_time: val })}
onChange={(val) =>
setForm({ ...form, departure_time: val })
}
/>
</div>
</div>
@@ -151,7 +167,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) => setForm({ ...form, break_start_time: val })}
onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
/>
</div>
<div className="admin-form-group">
@@ -159,7 +177,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) => setForm({ ...form, break_end_time: val })}
onChange={(val) =>
setForm({ ...form, break_end_time: val })
}
/>
</div>
</div>
@@ -181,12 +201,12 @@ export default function BulkAttendanceModal({
className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0}
>
{submitting ? 'Vytvářím záznamy...' : 'Vyplnit měsíc'}
{submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
);
}

View File

@@ -1,24 +1,41 @@
import type { ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import type { ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface ConfirmModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: ReactNode
confirmText?: string
cancelText?: string
type?: 'danger' | 'warning' | 'default' | 'info'
confirmVariant?: 'danger' | 'primary'
loading?: boolean
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: ReactNode;
confirmText?: string;
cancelText?: string;
type?: "danger" | "warning" | "default" | "info";
confirmVariant?: "danger" | "primary";
loading?: boolean;
}
export default function ConfirmModal({ isOpen, onClose, onConfirm, title, message, confirmText = 'Potvrdit', cancelText = 'Zrušit', type = 'default', confirmVariant, loading }: ConfirmModalProps) {
export default function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Potvrdit",
cancelText = "Zrušit",
type = "default",
confirmVariant,
loading,
}: ConfirmModalProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-confirm-modal"
@@ -29,7 +46,14 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
@@ -39,14 +63,26 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
<p className="admin-confirm-message">{message}</p>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={onClose} className="admin-btn admin-btn-secondary" disabled={loading}>{cancelText}</button>
<button type="button" onClick={onConfirm} className="admin-btn admin-btn-primary" disabled={loading}>
{loading ? 'Zpracování...' : confirmText}
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
className="admin-btn admin-btn-primary"
disabled={loading}
>
{loading ? "Zpracování..." : confirmText}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
);
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,107 +1,123 @@
import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../../context/AuthContext'
import { useAlert } from '../../context/AlertContext'
import useModalLock from '../../hooks/useModalLock'
import apiFetch from '../../utils/api'
import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface DashProfileProps {
totpEnabled: boolean
totpLoading: boolean
totpSubmitting: boolean
onStart2FASetup: () => void
onConfirm2FA: () => void
onDisable2FA: () => void
totpSecret: string | null
totpQrUri: string | null
totpCode: string
setTotpCode: (code: string) => void
backupCodes: string[] | null
setBackupCodes: (codes: string[] | null) => void
show2FASetup: boolean
setShow2FASetup: (show: boolean) => void
show2FADisable: boolean
setShow2FADisable: (show: boolean) => void
disableCode: string
setDisableCode: (code: string) => void
totpEnabled: boolean;
totpLoading: boolean;
totpSubmitting: boolean;
onStart2FASetup: () => void;
onConfirm2FA: () => void;
onDisable2FA: () => void;
totpSecret: string | null;
totpQrUri: string | null;
totpCode: string;
setTotpCode: (code: string) => void;
backupCodes: string[] | null;
setBackupCodes: (codes: string[] | null) => void;
show2FASetup: boolean;
setShow2FASetup: (show: boolean) => void;
show2FADisable: boolean;
setShow2FADisable: (show: boolean) => void;
disableCode: string;
setDisableCode: (code: string) => void;
}
interface ProfileFormData {
username: string
email: string
new_password: string
current_password: string
first_name: string
last_name: string
username: string;
email: string;
new_password: string;
current_password: string;
first_name: string;
last_name: string;
}
export default function DashProfile({
totpEnabled, totpLoading, totpSubmitting,
onStart2FASetup, onConfirm2FA, onDisable2FA,
totpSecret, totpQrUri, totpCode, setTotpCode,
backupCodes, setBackupCodes,
show2FASetup, setShow2FASetup,
show2FADisable, setShow2FADisable,
disableCode, setDisableCode,
totpEnabled,
totpLoading,
totpSubmitting,
onStart2FASetup,
onConfirm2FA,
onDisable2FA,
totpSecret,
totpQrUri,
totpCode,
setTotpCode,
backupCodes,
setBackupCodes,
show2FASetup,
setShow2FASetup,
show2FADisable,
setShow2FADisable,
disableCode,
setDisableCode,
}: DashProfileProps) {
const { user, updateUser } = useAuth()
const alert = useAlert()
const totpSetupRef = useRef<HTMLInputElement>(null)
const { user, updateUser } = useAuth();
const alert = useAlert();
const totpSetupRef = useRef<HTMLInputElement>(null);
const [showModal, setShowModal] = useState(false)
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({
username: '', email: '', new_password: '', current_password: '', first_name: '', last_name: ''
})
username: "",
email: "",
new_password: "",
current_password: "",
first_name: "",
last_name: "",
});
useModalLock(showModal)
useModalLock(showModal);
const openEditModal = () => {
const nameParts = (user?.fullName || '').split(' ')
const nameParts = (user?.fullName || "").split(" ");
setFormData({
username: user?.username || '',
email: user?.email || '',
new_password: '',
current_password: '',
first_name: nameParts[0] || '',
last_name: nameParts.slice(1).join(' ') || ''
})
setShowModal(true)
}
username: user?.username || "",
email: user?.email || "",
new_password: "",
current_password: "",
first_name: nameParts[0] || "",
last_name: nameParts.slice(1).join(" ") || "",
});
setShowModal(true);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
const dataToSave = { ...formData }
e?.preventDefault();
const dataToSave = { ...formData };
try {
const response = await apiFetch(`${API_BASE}/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
const data = await response.json()
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
const data = await response.json();
if (data.success) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
setShowModal(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success('Profil byl upraven')
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
setShowModal(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success("Profil byl upraven");
} else {
alert.error(data.error || 'Nepodařilo se uložit profil')
alert.error(data.error || "Nepodařilo se uložit profil");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
function getTotpStatusText(): string {
if (totpLoading) {
return 'Načítání...'
return "Načítání...";
}
return totpEnabled ? 'Aktivní' : 'Neaktivní'
return totpEnabled ? "Aktivní" : "Neaktivní";
}
return (
@@ -114,8 +130,18 @@ export default function DashProfile({
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Váš účet</h2>
<button onClick={openEditModal} className="admin-btn admin-btn-secondary admin-btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<button
onClick={openEditModal}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
@@ -138,42 +164,82 @@ export default function DashProfile({
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Role</span>
<span className="dash-profile-value">{user?.roleDisplay || String(user?.role || '')}</span>
<span className="dash-profile-value">
{user?.roleDisplay || String(user?.role || "")}
</span>
</div>
</div>
{/* 2FA Section */}
<div style={{ borderTop: '1px solid var(--border-color)', marginTop: '1rem', paddingTop: '1rem' }}>
<div
style={{
borderTop: "1px solid var(--border-color)",
marginTop: "1rem",
paddingTop: "1rem",
}}
>
<div className="flex-between">
<div className="flex-row-gap">
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: totpEnabled ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
color: totpEnabled ? 'var(--success)' : 'var(--text-secondary)'
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: totpEnabled
? "var(--success-light)"
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)",
color: totpEnabled
? "var(--success)"
: "var(--text-secondary)",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>Dvoufaktorové ověření (2FA)</div>
<div className={totpEnabled ? 'text-success' : 'text-secondary'} style={{ fontSize: '0.75rem' }}>
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}>
Dvoufaktorové ověření (2FA)
</div>
<div
className={totpEnabled ? "text-success" : "text-secondary"}
style={{ fontSize: "0.75rem" }}
>
{getTotpStatusText()}
</div>
</div>
</div>
{!totpLoading && (
totpEnabled ? (
<button onClick={() => { setDisableCode(''); setShow2FADisable(true) }} className="admin-btn admin-btn-primary admin-btn-sm">
{!totpLoading &&
(totpEnabled ? (
<button
onClick={() => {
setDisableCode("");
setShow2FADisable(true);
}}
className="admin-btn admin-btn-primary admin-btn-sm"
>
Deaktivovat
</button>
) : (
<button onClick={onStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary admin-btn-sm">
{totpSubmitting ? 'Generuji...' : 'Aktivovat'}
<button
onClick={onStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary admin-btn-sm"
>
{totpSubmitting ? "Generuji..." : "Aktivovat"}
</button>
)
)}
))}
</div>
</div>
</div>
@@ -182,45 +248,139 @@ export default function DashProfile({
{/* Edit Profile Modal */}
<AnimatePresence>
{showModal && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header"><h2 className="admin-modal-title">Upravit profil</h2></div>
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit profil</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Jméno</label>
<input type="text" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" />
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input type="text" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" />
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" />
<label className="admin-form-label">
Uživatelské jméno
</label>
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">E-mail</label>
<input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" />
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
<input type="password" value={formData.new_password} onChange={(e) => setFormData({ ...formData, new_password: e.target.value })} className="admin-form-input" />
<label className="admin-form-label">
Nové heslo (ponechte prázdné pro zachování stávajícího)
</label>
<input
type="password"
value={formData.new_password}
onChange={(e) =>
setFormData({
...formData,
new_password: e.target.value,
})
}
className="admin-form-input"
/>
</div>
{formData.new_password && (
<div className="admin-form-group">
<label className="admin-form-label required">Aktuální heslo</label>
<input type="password" value={formData.current_password} onChange={(e) => setFormData({ ...formData, current_password: e.target.value })} className="admin-form-input" placeholder="Zadejte aktuální heslo pro potvrzení" />
<label className="admin-form-label required">
Aktuální heslo
</label>
<input
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Zadejte aktuální heslo pro potvrzení"
/>
</div>
)}
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary">Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">Uložit změny</button>
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit změny
</button>
</div>
</motion.div>
</motion.div>
@@ -230,31 +390,105 @@ export default function DashProfile({
{/* 2FA Setup Modal */}
<AnimatePresence>
{show2FASetup && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => { if (!backupCodes) { setShow2FASetup(false) } }} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => {
if (!backupCodes) {
setShow2FASetup(false);
}
}}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}</h2>
<h2 className="admin-modal-title">
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
</h2>
</div>
<div className="admin-modal-body">
{backupCodes ? (
<div>
<div className="admin-role-locked-notice mb-4">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte.
Uložte si tyto kódy na bezpečné místo. Každý kód lze
použít pouze jednou. Po zavření tohoto okna je již
neuvidíte.
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "0.5rem",
padding: "1rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
fontFamily: "monospace",
fontSize: "1rem",
}}
>
{backupCodes.map((code) => (
<div key={code} style={{ padding: '0.25rem 0.5rem', textAlign: 'center', color: 'var(--text-primary)' }}>{code}</div>
<div
key={code}
style={{
padding: "0.25rem 0.5rem",
textAlign: "center",
color: "var(--text-primary)",
}}
>
{code}
</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<button onClick={() => { navigator.clipboard?.writeText(backupCodes.join('\n')); alert.success('Kódy zkopírovány') }} className="admin-btn admin-btn-secondary admin-btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={() => {
navigator.clipboard?.writeText(
backupCodes.join("\n"),
);
alert.success("Kódy zkopírovány");
}}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Kopírovat kódy
</button>
@@ -262,48 +496,143 @@ export default function DashProfile({
</div>
) : (
<div>
<p className="text-secondary" style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.)
<p
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
Naskenujte QR kód v autentizační aplikaci (Google
Authenticator, Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<div
style={{ textAlign: "center", marginBottom: "1rem" }}
>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
alt="TOTP QR Code"
style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }}
style={{
width: 200,
height: 200,
borderRadius: "0.5rem",
border: "1px solid var(--border-color)",
}}
/>
</div>
)}
{totpSecret && (
<div className="mb-4">
<label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Nebo zadejte klíč ručně:</label>
<div style={{ padding: '0.5rem 0.75rem', background: 'var(--bg-secondary)', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem', wordBreak: 'break-all', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<label
className="admin-form-label"
style={{ fontSize: "0.75rem" }}
>
Nebo zadejte klíč ručně:
</label>
<div
style={{
padding: "0.5rem 0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.375rem",
fontFamily: "monospace",
fontSize: "0.875rem",
wordBreak: "break-all",
color: "var(--text-primary)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span>{totpSecret}</span>
<button onClick={() => { navigator.clipboard?.writeText(totpSecret); alert.success('Klíč zkopírován') }} className="admin-btn-icon" title="Kopírovat" aria-label="Kopírovat" style={{ flexShrink: 0 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
<button
onClick={() => {
navigator.clipboard?.writeText(totpSecret);
alert.success("Klíč zkopírován");
}}
className="admin-btn-icon"
title="Kopírovat"
aria-label="Kopírovat"
style={{ flexShrink: 0 }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód z aplikace</label>
<input ref={totpSetupRef} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} />
<label className="admin-form-label">
Ověřovací kód z aplikace
</label>
<input
ref={totpSetupRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/\D/g, ""))
}
placeholder="000000"
className="admin-form-input"
style={{
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && totpCode.length === 6) {
onConfirm2FA();
}
}}
/>
</div>
</div>
)}
</div>
<div className="admin-modal-footer">
{backupCodes ? (
<button onClick={() => { setShow2FASetup(false); setBackupCodes(null) }} className="admin-btn admin-btn-primary">
<button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
) : (
<>
<button onClick={() => setShow2FASetup(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onConfirm2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || totpCode.length !== 6}>
{totpSubmitting ? 'Ověřuji...' : 'Aktivovat 2FA'}
<button
onClick={() => setShow2FASetup(false)}
className="admin-btn admin-btn-secondary"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
onClick={onConfirm2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || totpCode.length !== 6}
>
{totpSubmitting ? "Ověřuji..." : "Aktivovat 2FA"}
</button>
</>
)}
@@ -316,23 +645,80 @@ export default function DashProfile({
{/* 2FA Disable Modal */}
<AnimatePresence>
{show2FADisable && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => setShow2FADisable(false)} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header"><h2 className="admin-modal-title">Deaktivovat 2FA</h2></div>
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShow2FADisable(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Deaktivovat 2FA</h2>
</div>
<div className="admin-modal-body">
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace.
<p
style={{
color: "var(--text-secondary)",
fontSize: "0.875rem",
marginBottom: "1rem",
}}
>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
autentizační aplikace.
</p>
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód</label>
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={disableCode} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus />
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={disableCode}
onChange={(e) =>
setDisableCode(e.target.value.replace(/\D/g, ""))
}
placeholder="000000"
className="admin-form-input"
style={{
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && disableCode.length === 6) {
onDisable2FA();
}
}}
autoFocus
/>
</div>
</div>
<div className="admin-modal-footer">
<button onClick={() => setShow2FADisable(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onDisable2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || disableCode.length !== 6}>
{totpSubmitting ? 'Deaktivuji...' : 'Deaktivovat 2FA'}
<button
onClick={() => setShow2FADisable(false)}
className="admin-btn admin-btn-secondary"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
onClick={onDisable2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || disableCode.length !== 6}
>
{totpSubmitting ? "Deaktivuji..." : "Deaktivovat 2FA"}
</button>
</div>
</motion.div>
@@ -340,5 +726,5 @@ export default function DashProfile({
)}
</AnimatePresence>
</>
)
);
}

View File

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

View File

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