initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
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)
// 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)
}
const isTouchDevice = () =>
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
}
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
({ value, onClick, onChange, placeholder, required, readOnly, disabled }, ref) => (
<input
className="admin-form-input"
onClick={onClick}
onChange={onChange}
value={value}
placeholder={placeholder}
ref={ref}
required={required}
readOnly={readOnly}
disabled={disabled}
autoComplete="off"
/>
)
)
interface NativeInputProps {
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' }
function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabled }: NativeInputProps) {
const type = modeToInputType[mode] || 'date'
return (
<input
type={type}
lang="cs"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="admin-form-input"
required={required}
disabled={disabled}
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
}
export default function AdminDatePicker({
mode = 'date',
value,
onChange,
required,
minDate,
maxDate,
disabled,
placeholder,
}: AdminDatePickerProps) {
const useNative = useMemo(() => isTouchDevice(), [])
if (useNative) {
return (
<NativeInput
mode={mode}
value={value}
onChange={onChange}
required={required}
minDate={minDate}
maxDate={maxDate}
disabled={disabled}
/>
)
}
const toDate = (val: string | null | undefined): Date | 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 === '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'))
}
const parseMinMax = (val: string | undefined): Date | 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
}
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: 'cs',
customInput: <CustomInput required={required} placeholder={placeholder} disabled={disabled} />,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: 'bottom-start' as const,
portalId: 'datepicker-portal',
disabled,
}
if (mode === 'time') {
return (
<DatePicker
{...commonProps}
showTimeSelect
showTimeSelectOnly
timeIntervals={5}
timeCaption="Čas"
dateFormat="HH:mm"
timeFormat="HH:mm"
/>
)
}
if (mode === 'month') {
return (
<DatePicker
{...commonProps}
showMonthYearPicker
dateFormat="MM/yyyy"
/>
)
}
return (
<DatePicker
{...commonProps}
dateFormat="dd.MM.yyyy"
/>
)
}

View File

@@ -0,0 +1,107 @@
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()
// 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])
useModalLock(sidebarOpen)
if (loading) {
return (
<div className="admin-layout">
<div className="admin-loading" style={{ width: '100%' }}>
<div className="admin-spinner" />
</div>
</div>
)
}
if (!isAuthenticated) {
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 />
}
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' }
}
transition={{ duration: loggingOut ? 0.4 : 0.25, ease: [0.4, 0, 0.2, 1] }}
>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} onLogout={handleLogout} />
<div className="admin-main">
<header className="admin-header">
<button
onClick={() => setSidebarOpen(true)}
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">
<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" />
</svg>
</button>
<div className="flex-1" />
<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'}
>
<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">
<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">
<Outlet />
</main>
</div>
<ShortcutsHelp />
</motion.div>
)
}

View File

@@ -0,0 +1,67 @@
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">
<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">
<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">
<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">
<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()
return (
<div className="admin-alert-container" role="status" aria-live="polite">
<AnimatePresence>
{alerts.map(alert => (
<motion.div
key={alert.id}
className={`admin-toast admin-toast-${alert.type}`}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<span className="admin-toast-icon">{icons[alert.type]}</span>
<span className="admin-toast-message">{alert.message}</span>
<button
className="admin-toast-close"
onClick={() => removeAlert(alert.id)}
aria-label="Zavřít"
>
<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>
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,181 @@
import { Link } from 'react-router-dom'
import {
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
}
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
}
interface AttendanceShiftTableProps {
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)}`
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
}
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' }}>
{record.project_logs.map((log, i) => {
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
} 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
}
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>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '\u2014'
}
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 (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Zam\u011Bstnanec</th>
<th>Typ</th>
<th>P\u0159\u00EDchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Pozn\u00E1mka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
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)
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)}`}>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.arrival_time)}</td>
<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>
) : '\u2014'}
</td>
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
{record.notes || ''}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => onEdit(record)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => onDelete(record)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,192 @@
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
}
interface BulkAttendanceUser {
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
}
export default function BulkAttendanceModal({
show,
onClose,
form,
setForm,
users,
onSubmit,
submitting,
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(show)
return (
<AnimatePresence>
{show && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => !submitting && onClose()} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Existující záznamy se přeskočí.
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
value={form.month}
onChange={(val) => setForm({ ...form, month: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Zaměstnanci
<button
type="button"
onClick={toggleAllUsers}
style={{
marginLeft: '0.75rem',
background: 'none',
border: 'none',
color: 'var(--accent-color)',
cursor: 'pointer',
fontSize: '0.8125rem',
fontWeight: 500,
padding: 0,
}}
>
{form.user_ids.length === users.length ? 'Odznačit vše' : 'Vybrat vše'}
</button>
</label>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
maxHeight: '200px',
overflowY: 'auto',
padding: '0.75rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--border-radius-sm)',
border: '1px solid var(--border-color)',
}}
>
{users.map((user) => (
<label key={user.id} className="admin-form-checkbox">
<input
type="checkbox"
checked={form.user_ids.includes(String(user.id))}
onChange={() => toggleUser(user.id)}
/>
<span>{user.name}</span>
</label>
))}
</div>
<small className="admin-form-hint">
Vybráno: {form.user_ids.length} z {users.length}
</small>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Příchod</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) => setForm({ ...form, arrival_time: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => setForm({ ...form, departure_time: val })}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Začátek pauzy</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) => setForm({ ...form, break_start_time: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) => setForm({ ...form, break_end_time: val })}
/>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0}
>
{submitting ? 'Vytvářím záznamy...' : 'Vyplnit měsíc'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,52 @@
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
}
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 }}>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-confirm-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-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">
<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>
</div>
<h2 className="admin-confirm-title">{title}</h2>
<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 ${(confirmVariant === 'danger' || type === 'danger') ? 'admin-btn-danger' : 'admin-btn-primary'}`} disabled={loading}>
{loading ? 'Zpracování...' : confirmText}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,29 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'
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 }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info)
}
render() {
if (this.state.hasError) {
return (
<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>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,11 @@
import { Link } from 'react-router-dom'
export default function Forbidden() {
return (
<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>
</div>
)
}

View File

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

View File

@@ -0,0 +1,62 @@
interface PaginationProps {
pagination: {
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
const { page, total_pages } = pagination
const getPages = () => {
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('...')
}
}
return pages
}
return (
<div className="admin-pagination">
<div className="admin-pagination-pages">
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} className="admin-pagination-btn">
<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-dots">...</span>
) : (
<button key={p} onClick={() => onPageChange(p)} className={`admin-pagination-btn ${p === page ? 'active' : ''}`}>
{p}
</button>
)
)}
<button disabled={page >= total_pages} onClick={() => onPageChange(page + 1)} className="admin-pagination-btn">
<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))}
className="admin-form-select admin-pagination-select"
>
{[10, 25, 50, 100].map(n => (
<option key={n} value={n}>{n} / stránka</option>
))}
</select>
)}
</div>
)
}

View File

@@ -0,0 +1,105 @@
import { useMemo, useRef, useCallback } from 'react'
import ReactQuill from 'react-quill-new'
import 'react-quill-new/dist/quill.snow.css'
const Quill = ReactQuill.Quill
if (!(Quill as any).__bohaRegistered) {
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)
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
}
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'
]
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',
]
const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
['bold', 'italic', 'underline', 'strike'],
[{ color: COLORS }, { background: COLORS }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ align: [] }],
['link'],
['clean']
]
const FORMATS = [
'font', 'size',
'bold', 'italic', 'underline', 'strike',
'color', 'background',
'list', 'indent', 'align',
'link'
]
interface RichEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
minHeight?: string
}
export default function RichEditor({
value,
onChange,
placeholder = 'Obsah...',
minHeight = '120px'
}: RichEditorProps) {
const quillRef = useRef<ReactQuill>(null)
const lastValueRef = useRef(value)
const modules = useMemo(() => ({
toolbar: TOOLBAR,
clipboard: {
matchVisual: false,
},
}), [])
const handleChange = useCallback((content: string, _delta: any, source: string) => {
if (source !== 'user') return
if (content === lastValueRef.current) return
lastValueRef.current = content
onChange(content)
}, [onChange])
return (
<div className="rich-editor" style={{ '--re-min-height': minHeight } as React.CSSProperties}>
<ReactQuill
ref={quillRef}
theme="snow"
value={value || ''}
onChange={handleChange}
modules={modules}
formats={FORMATS}
placeholder={placeholder}
/>
</div>
)
}

View File

@@ -0,0 +1,521 @@
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
// ---------- 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
}
export interface ProjectLog {
_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
}
export interface User {
id: number | string
name: string
}
export interface EditingRecord {
user_name: string
shift_date: string
}
// ---------- Sub-component props ----------
interface ProjectTimeStatusProps {
form: ShiftFormData
projectLogs: ProjectLog[]
}
interface ProjectLogRowProps {
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
}
// ---------- 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)
if (!hasLogs || totalWork <= 0) return null
const isMatch = remaining === 0
return (
<div
style={{
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))',
color: isMatch
? '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))'
}`,
}}
>
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 ----------
function ProjectLogRow({
log,
index,
projectList,
onUpdate,
onRemove,
}: ProjectLogRowProps) {
return (
<div className="flex-row gap-2 mb-2">
<select
value={log.project_id}
onChange={(e) => onUpdate(index, 'project_id', e.target.value)}
className="admin-form-select"
style={{ flex: 3, marginBottom: 0 }}
>
<option value=""> Projekt </option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
<input
type="number"
min="0"
max="24"
value={log.hours}
onChange={(e) => onUpdate(index, 'hours', e.target.value)}
className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
placeholder="h"
/>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
h
</span>
<input
type="number"
min="0"
max="59"
value={log.minutes}
onChange={(e) => onUpdate(index, 'minutes', e.target.value)}
className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
placeholder="m"
/>
<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 }}
title="Odebrat"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)
}
// ---------- ShiftFormModal ----------
export default function ShiftFormModal({
mode,
show,
onClose,
onSubmit,
form,
setForm,
projectLogs,
setProjectLogs,
projectList,
users,
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(show)
const isCreate = mode === 'create'
const isWorkType = form.leave_type === 'work'
const updateField = (field: keyof ShiftFormData, value: string | number) => {
setForm({ ...form, [field]: value })
}
const updateProjectLog = (index: number, field: string, value: string) => {
const updated = [...projectLogs]
updated[index] = { ...updated[index], [field]: value }
setProjectLogs(updated)
}
const removeProjectLog = (index: number) => {
setProjectLogs(projectLogs.filter((_, j) => j !== index))
}
const addProjectLog = () => {
setProjectLogs([
...projectLogs,
{ _key: `log-${++_logKeyCounter}`, project_id: '', hours: '', minutes: '' },
])
}
return (
<AnimatePresence>
{show && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
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">
{isCreate ? 'Přidat záznam docházky' : 'Upravit docházku'}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: 'var(--text-secondary)',
marginTop: '0.25rem',
}}
>
{editingRecord.user_name} {' '}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<div className="admin-form">
{isCreate ? (
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label required">
Zaměstnanec
</label>
<select
value={form.user_id}
onChange={(e) =>
updateField('user_id', e.target.value)
}
className="admin-form-select"
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">
Datum směny
</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => onShiftDateChange(val)}
/>
</div>
</div>
) : (
<div className="admin-form-group">
<label className="admin-form-label">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => updateField('shift_date', val)}
/>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Typ záznamu</label>
<select
value={form.leave_type}
onChange={(e) =>
updateField('leave_type', e.target.value)
}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>
{!isWorkType && (
<div className="admin-form-group">
<label className="admin-form-label">Počet hodin</label>
<input
type="number"
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField('leave_hours', parseFloat(e.target.value))
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">
8 hodin = celý den
</small>
)}
</div>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val) =>
updateField('arrival_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - čas
</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) =>
updateField('arrival_time', val)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val) =>
updateField('break_start_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - čas
</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) =>
updateField('break_start_time', val)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val) =>
updateField('break_end_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - čas
</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) =>
updateField('break_end_time', val)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val) =>
updateField('departure_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - čas
</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) =>
updateField('departure_time', val)
}
/>
</div>
</div>
</>
)}
{isWorkType && projectList.length > 0 && (
<div className="admin-form-group">
<label className="admin-form-label">Projekty</label>
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
key={log._key || i}
log={log}
index={i}
projectList={projectList}
onUpdate={updateProjectLog}
onRemove={removeProjectLog}
/>
))}
<button
type="button"
onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat projekt
</button>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => updateField('notes', e.target.value)}
className="admin-form-textarea"
rows={3}
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

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

View File

@@ -0,0 +1,419 @@
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
}
interface MenuSection {
label: string
items: MenuItem[]
}
const menuSections: MenuSection[] = [
{
label: 'Přehled',
items: [
{
path: '/',
label: 'Přehled',
end: true,
icon: (
<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',
items: [
{
path: '/attendance',
label: 'Záznam',
permission: 'attendance.record',
end: true,
icon: (
<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',
icon: (
<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',
icon: (
<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',
icon: (
<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'],
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" />
<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',
icon: (
<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',
items: [
{
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>
)
},
{
path: '/trips/history',
label: 'Moje historie',
permission: 'trips.history',
icon: (
<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',
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" />
<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',
icon: (
<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',
items: [
{
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">
<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',
icon: (
<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',
icon: (
<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',
icon: (
<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',
icon: (
<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',
icon: (
<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',
items: [
{
path: '/users',
label: 'Uživatelé',
permission: 'users.view',
icon: (
<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'],
icon: (
<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',
icon: (
<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
}
export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
const { user, hasPermission } = useAuth()
const { theme } = useTheme()
const location = useLocation()
const isItemActive = (item: MenuItem) => {
if (item.matchPrefix) {
let active = location.pathname.startsWith(item.matchPrefix)
if (active && item.matchExclude) {
active = !item.matchExclude.some(ex => location.pathname.startsWith(ex))
}
return active
}
if (item.end) {
return location.pathname === item.path
}
return location.pathname.startsWith(item.path)
}
const hasItemPermission = (item: MenuItem) => {
if (!item.permission) {
return true
}
if (Array.isArray(item.permission)) {
return item.permission.some(p => hasPermission(p))
}
return hasPermission(item.permission)
}
const visibleSections = menuSections
.map(section => ({
...section,
items: section.items.filter(hasItemPermission)
}))
.filter(section => section.items.length > 0)
return (
<>
<div
className={`admin-sidebar-overlay${isOpen ? ' open' : ''}`}
onClick={onClose}
/>
<aside className={`admin-sidebar${isOpen ? ' open' : ''}`}>
<div className="admin-sidebar-header">
<img
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">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<nav className="admin-sidebar-nav">
{visibleSections.map((section) => (
<div key={section.label} className="admin-nav-section">
<div className="admin-nav-label">{section.label}</div>
{section.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.end}
onClick={onClose}
className={() => {
let active = isItemActive(item)
if (!active && item.matchAlso) {
active = item.matchAlso.some(p => location.pathname.startsWith(p))
}
return `admin-nav-item${active ? ' active' : ''}`
}}
>
{item.icon}
{item.label}
</NavLink>
))}
</div>
))}
</nav>
<div className="admin-sidebar-footer">
<div className="admin-user-chip">
<div className="admin-user-avatar">
{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>
</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">
<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>
Odhlásit se
</button>
</div>
</aside>
</>
)
}

View File

@@ -0,0 +1,20 @@
interface SortIconProps {
column: string
sort: string
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 }}>
<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>
)
}

View File

@@ -0,0 +1,80 @@
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
}
interface DashActivityFeedProps {
activities: Activity[] | null
}
function getActivityIcon(action: string) {
switch (action) {
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>
)
case 'update':
return (
<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':
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>
)
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>
)
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>
)
}
}
export default function DashActivityFeed({ activities }: DashActivityFeedProps) {
if (!activities) {
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>
</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)}`}>
{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>
<div className="dash-activity-time admin-mono">{formatActivityTime(act.created_at)}</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
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
}
interface AttendanceData {
users: AttendanceUser[]
}
interface DashAttendanceTodayProps {
attendance: AttendanceData | null
}
export default function DashAttendanceToday({ attendance }: DashAttendanceTodayProps) {
if (!attendance) {
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>
</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>
<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>
{u.arrived_at && <span className="admin-mono dash-presence-time">{u.arrived_at}</span>}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { motion } from 'framer-motion'
import { formatCurrency } from '../../utils/formatters'
interface KpiCard {
label: string
value: string
sub?: string
color: string
footer: string | null
}
interface RevenueItem {
amount: number
currency: string
}
interface InvoicesData {
revenue_this_month: RevenueItem[]
revenue_czk?: number | null
unpaid_count: number
}
interface DashData {
attendance?: {
present_today: number
total_active: number
on_leave: number
}
offers?: {
open_count: number
created_this_month: number
}
invoices?: InvoicesData
leave_pending?: {
count: number
}
}
interface DashKpiCardsProps {
dashData: DashData | null
}
function buildKpiCards(dashData: DashData | null): KpiCard[] {
const cards: KpiCard[] = []
if (dashData?.attendance) {
cards.push({
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,
})
}
if (dashData?.offers) {
cards.push({
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,
})
}
if (dashData?.invoices) {
cards.push(buildInvoiceKpi(dashData.invoices))
}
if (dashData?.leave_pending) {
cards.push({
label: 'Žádosti o volno',
value: `${dashData.leave_pending.count}`,
color: 'danger',
footer: dashData.leave_pending.count > 0 ? 'čeká na schválení' : null,
})
}
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 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)
return {
label: 'Tržby (měsíc)',
value: revenueText,
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' }
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
const kpiCards = buildKpiCards(dashData)
if (kpiCards.length === 0) {
return null
}
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || 'dash-kpi-4'
return (
<motion.div
className={`dash-kpi-grid ${kpiClass}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
{kpiCards.map((kpi) => (
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
<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>}
</div>
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
</div>
))}
</motion.div>
)
}

View File

@@ -0,0 +1,344 @@
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'
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
}
interface ProfileFormData {
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,
}: DashProfileProps) {
const { user, updateUser } = useAuth()
const alert = useAlert()
const totpSetupRef = useRef<HTMLInputElement>(null)
const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState<ProfileFormData>({
username: '', email: '', new_password: '', current_password: '', first_name: '', last_name: ''
})
useModalLock(showModal)
const openEditModal = () => {
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)
}
const handleSubmit = async (e?: React.FormEvent) => {
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()
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')
} else {
alert.error(data.error || 'Nepodařilo se uložit profil')
}
} catch {
alert.error('Chyba připojení')
}
}
function getTotpStatusText(): string {
if (totpLoading) {
return 'Načítání...'
}
return totpEnabled ? 'Aktivní' : 'Neaktivní'
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<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">
<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>
Upravit
</button>
</div>
<div className="admin-card-body">
<div className="dash-profile-grid">
<div className="dash-profile-item">
<span className="dash-profile-label">Uživatel</span>
<span className="dash-profile-value">{user?.username}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">E-mail</span>
<span className="dash-profile-value">{user?.email}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Jméno</span>
<span className="dash-profile-value">{user?.fullName}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">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 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" />
</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' }}>
{getTotpStatusText()}
</div>
</div>
</div>
{!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>
)
)}
</div>
</div>
</div>
</motion.div>
{/* Edit Profile Modal */}
<AnimatePresence>
{showModal && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header"><h2 className="admin-modal-title">Upravit profil</h2></div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Jméno</label>
<input type="text" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input type="text" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">E-mail</label>
<input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">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í" />
</div>
)}
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary">Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">Uložit změny</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 2FA Setup Modal */}
<AnimatePresence>
{show2FASetup && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => { if (!backupCodes) { setShow2FASetup(false) } }} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}</h2>
</div>
<div className="admin-modal-body">
{backupCodes ? (
<div>
<div className="admin-role-locked-notice mb-4">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte.
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
{backupCodes.map((code) => (
<div key={code} style={{ padding: '0.25rem 0.5rem', textAlign: 'center', color: 'var(--text-primary)' }}>{code}</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<button onClick={() => { navigator.clipboard?.writeText(backupCodes.join('\n')); alert.success('Kódy zkopírovány') }} className="admin-btn admin-btn-secondary admin-btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Kopírovat kódy
</button>
</div>
</div>
) : (
<div>
<p className="text-secondary" style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
alt="TOTP QR Code"
style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }}
/>
</div>
)}
{totpSecret && (
<div className="mb-4">
<label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Nebo zadejte klíč ručně:</label>
<div style={{ padding: '0.5rem 0.75rem', background: 'var(--bg-secondary)', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem', wordBreak: 'break-all', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<span>{totpSecret}</span>
<button onClick={() => { navigator.clipboard?.writeText(totpSecret); alert.success('Klíč zkopírován') }} className="admin-btn-icon" title="Kopírovat" aria-label="Kopírovat" style={{ flexShrink: 0 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód z aplikace</label>
<input ref={totpSetupRef} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} />
</div>
</div>
)}
</div>
<div className="admin-modal-footer">
{backupCodes ? (
<button onClick={() => { setShow2FASetup(false); setBackupCodes(null) }} className="admin-btn admin-btn-primary">
Rozumím, uložil jsem si kódy
</button>
) : (
<>
<button onClick={() => setShow2FASetup(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onConfirm2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || totpCode.length !== 6}>
{totpSubmitting ? 'Ověřuji...' : 'Aktivovat 2FA'}
</button>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 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>
<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>
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód</label>
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={disableCode} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus />
</div>
</div>
<div className="admin-modal-footer">
<button onClick={() => setShow2FADisable(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onDisable2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || disableCode.length !== 6}>
{totpSubmitting ? 'Deaktivuji...' : 'Deaktivovat 2FA'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,378 @@
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'
interface Vehicle {
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
}
interface TripErrors {
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
}
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 [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>({})
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)
try {
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 || [])
}
} catch {
// vozidla se nenacetla
}
}
const handleTripVehicleChange = async (vehicleId: string) => {
setTripForm(prev => ({ ...prev, vehicle_id: vehicleId }))
if (!vehicleId) {
return
}
try {
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 }))
}
} catch {
// last_km se nenacetlo
}
}
const handleTripSubmit = async () => {
const errs: TripErrors = {}
if (!tripForm.vehicle_id) {
errs.vehicle_id = 'Vyberte vozidlo'
}
if (!tripForm.trip_date) {
errs.trip_date = 'Zadejte datum'
}
if (!tripForm.start_km) {
errs.start_km = 'Zadejte počáteční km'
}
if (!tripForm.end_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.route_from) {
errs.route_from = 'Zadejte místo odjezdu'
}
if (!tripForm.route_to) {
errs.route_to = 'Zadejte místo příjezdu'
}
setTripErrors(errs)
if (Object.keys(errs).length > 0) {
return
}
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()
if (result.success) {
setShowTripModal(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
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 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
}> = []
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>,
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')) {
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 (
<>
<motion.div
className="dash-quick-actions"
initial={{ opacity: 0, y: 12 }}
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>
))}
</motion.div>
<AnimatePresence>
{showTripModal && (
<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={() => setShowTripModal(false)} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className={`admin-form-group${tripErrors.vehicle_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Vozidlo</label>
<select
value={tripForm.vehicle_id}
onChange={(e) => {
handleTripVehicleChange(e.target.value)
setTripErrors(prev => ({ ...prev, vehicle_id: undefined }))
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{tripVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.spz} - {v.name}</option>
))}
</select>
{tripErrors.vehicle_id && <span className="admin-form-error">{tripErrors.vehicle_id}</span>}
</div>
<div className={`admin-form-group${tripErrors.trip_date ? ' has-error' : ''}`}>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={tripForm.trip_date}
onChange={(val: string) => {
setTripForm(prev => ({ ...prev, trip_date: val }))
setTripErrors(prev => ({ ...prev, trip_date: undefined }))
}}
/>
{tripErrors.trip_date && <span className="admin-form-error">{tripErrors.trip_date}</span>}
</div>
</div>
<div className="admin-form-row admin-form-row-3">
<div className={`admin-form-group${tripErrors.start_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={tripForm.start_km}
onChange={(e) => {
setTripForm(prev => ({ ...prev, start_km: e.target.value }))
setTripErrors(prev => ({ ...prev, start_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{tripErrors.start_km && <span className="admin-form-error">{tripErrors.start_km}</span>}
</div>
<div className={`admin-form-group${tripErrors.end_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Konečný stav km</label>
<input
type="number"
inputMode="numeric"
value={tripForm.end_km}
onChange={(e) => {
setTripForm(prev => ({ ...prev, end_km: e.target.value }))
setTripErrors(prev => ({ ...prev, end_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{tripErrors.end_km && <span className="admin-form-error">{tripErrors.end_km}</span>}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label>
<input type="text" value={`${formatKm(tripDistance())} km`} className="admin-form-input" readOnly disabled />
</div>
</div>
<div className="admin-form-row">
<div className={`admin-form-group${tripErrors.route_from ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
value={tripForm.route_from}
onChange={(e) => {
setTripForm(prev => ({ ...prev, route_from: e.target.value }))
setTripErrors(prev => ({ ...prev, route_from: undefined }))
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
{tripErrors.route_from && <span className="admin-form-error">{tripErrors.route_from}</span>}
</div>
<div className={`admin-form-group${tripErrors.route_to ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo příjezdu</label>
<input
type="text"
value={tripForm.route_to}
onChange={(e) => {
setTripForm(prev => ({ ...prev, route_to: e.target.value }))
setTripErrors(prev => ({ ...prev, route_to: undefined }))
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
{tripErrors.route_to && <span className="admin-form-error">{tripErrors.route_to}</span>}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label>
<select
value={tripForm.is_business}
onChange={(e) => setTripForm(prev => ({ ...prev, is_business: parseInt(e.target.value) }))}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Poznámky</label>
<textarea
value={tripForm.notes}
onChange={(e) => setTripForm(prev => ({ ...prev, notes: e.target.value }))}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowTripModal(false)} className="admin-btn admin-btn-secondary" disabled={tripSubmitting}>
Zrušit
</button>
<button type="button" onClick={handleTripSubmit} className="admin-btn admin-btn-primary" disabled={tripSubmitting}>
{tripSubmitting ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,218 @@
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'
interface DeviceInfo {
icon?: string
browser?: string
os?: string
}
interface Session {
id: number | string
is_current: boolean
device_info?: DeviceInfo
ip_address: string
created_at: string
}
interface DeleteModalState {
isOpen: boolean
session: Session | null
}
function getDeviceIcon(iconType?: string) {
switch (iconType) {
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>
)
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>
)
default:
return (
<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" />
</svg>
)
}
}
export default function DashSessions() {
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)
useModalLock(deleteAllModal)
const fetchSessions = useCallback(async () => {
try {
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 || [])
}
} catch {
// session fetch failed silently
} finally {
setSessionsLoading(false)
}
}, [])
useEffect(() => {
fetchSessions()
}, [fetchSessions])
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return
}
const sessionId = deleteModal.session.id
setDeleting(true)
try {
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')
} else {
alert.error(data.error || 'Nepodařilo se ukončit relaci')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const handleDeleteAllSessions = async () => {
setDeleting(true)
try {
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')
} else {
alert.error(data.error || 'Nepodařilo se ukončit relace')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
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' }}>
<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">
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 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>
</div>
))}
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<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 className="session-info">
<div className="session-device">
{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>
)}
</div>
<div className="session-meta">
<span>{session.ip_address}</span>
<span className="session-meta-separator">|</span>
<span>{formatSessionDate(session.created_at)}</span>
</div>
</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" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, session: null })}
onConfirm={handleDeleteSession}
title="Ukončit relaci"
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
confirmText="Ukončit"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<ConfirmModal
isOpen={deleteAllModal}
onClose={() => setDeleteAllModal(false)}
onConfirm={handleDeleteAllSessions}
title="Odhlásit ostatní zařízení"
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
confirmText="Odhlásit vše"
cancelText="Zrušit"
type="warning"
loading={deleting}
/>
</>
)
}