Initial commit

This commit is contained in:
2026-03-12 12:43:56 +01:00
commit f733dee856
137 changed files with 51192 additions and 0 deletions

27
src/App.jsx Normal file
View File

@@ -0,0 +1,27 @@
import { Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import AdminApp from './admin/AdminApp'
function AdminLoader() {
return (
<div style={{
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-primary)'
}}>
<div className="admin-spinner" />
</div>
)
}
export default function App() {
return (
<Suspense fallback={<AdminLoader />}>
<Routes>
<Route path="/*" element={<AdminApp />} />
</Routes>
</Suspense>
)
}

96
src/admin/AdminApp.jsx Normal file
View File

@@ -0,0 +1,96 @@
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { AlertProvider } from './context/AlertContext'
import ErrorBoundary from './components/ErrorBoundary'
import AdminLayout from './components/AdminLayout'
import AlertContainer from './components/AlertContainer'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import './admin.css'
import './login.css'
import './dashboard.css'
import './attendance.css'
import './leave.css'
import './orders.css'
import './projects.css'
import './settings.css'
import './offers.css'
import './invoices.css'
const Users = lazy(() => import('./pages/Users'))
const Attendance = lazy(() => import('./pages/Attendance'))
const AttendanceHistory = lazy(() => import('./pages/AttendanceHistory'))
const AttendanceAdmin = lazy(() => import('./pages/AttendanceAdmin'))
const AttendanceBalances = lazy(() => import('./pages/AttendanceBalances'))
const AttendanceCreate = lazy(() => import('./pages/AttendanceCreate'))
const LeaveRequests = lazy(() => import('./pages/LeaveRequests'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
const AttendanceLocation = lazy(() => import('./pages/AttendanceLocation'))
const Trips = lazy(() => import('./pages/Trips'))
const TripsHistory = lazy(() => import('./pages/TripsHistory'))
const TripsAdmin = lazy(() => import('./pages/TripsAdmin'))
const Vehicles = lazy(() => import('./pages/Vehicles'))
const Offers = lazy(() => import('./pages/Offers'))
const OfferDetail = lazy(() => import('./pages/OfferDetail'))
const OffersCustomers = lazy(() => import('./pages/OffersCustomers'))
const OffersTemplates = lazy(() => import('./pages/OffersTemplates'))
const CompanySettings = lazy(() => import('./pages/CompanySettings'))
const Orders = lazy(() => import('./pages/Orders'))
const OrderDetail = lazy(() => import('./pages/OrderDetail'))
const Projects = lazy(() => import('./pages/Projects'))
const ProjectCreate = lazy(() => import('./pages/ProjectCreate'))
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
const Invoices = lazy(() => import('./pages/Invoices'))
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
const Settings = lazy(() => import('./pages/Settings'))
export default function AdminApp() {
return (
<AuthProvider>
<AlertProvider>
<AlertContainer />
<ErrorBoundary>
<Suspense fallback={<div className="admin-loading"><div className="admin-spinner" /></div>}>
<Routes>
<Route path="login" element={<Login />} />
<Route element={<AdminLayout />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
<Route path="attendance/history" element={<AttendanceHistory />} />
<Route path="attendance/admin" element={<AttendanceAdmin />} />
<Route path="attendance/balances" element={<AttendanceBalances />} />
<Route path="attendance/requests" element={<LeaveRequests />} />
<Route path="attendance/approval" element={<LeaveApproval />} />
<Route path="attendance/create" element={<AttendanceCreate />} />
<Route path="attendance/location/:id" element={<AttendanceLocation />} />
<Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} />
<Route path="vehicles" element={<Vehicles />} />
<Route path="offers" element={<Offers />} />
<Route path="offers/new" element={<OfferDetail />} />
<Route path="offers/:id" element={<OfferDetail />} />
<Route path="offers/customers" element={<OffersCustomers />} />
<Route path="offers/templates" element={<OffersTemplates />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/new" element={<ProjectCreate />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceCreate />} />
<Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AlertProvider>
</AuthProvider>
)
}

2150
src/admin/admin.css Normal file

File diff suppressed because it is too large Load Diff

434
src/admin/attendance.css Normal file
View File

@@ -0,0 +1,434 @@
/* ============================================================================
Attendance Module
============================================================================ */
/* Layout */
.attendance-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (min-width: 1024px) {
.attendance-layout {
flex-direction: row;
align-items: flex-start;
}
}
.attendance-main {
flex: 1;
min-width: 0;
}
.attendance-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 1024px) {
.attendance-sidebar {
width: 320px;
flex-shrink: 0;
}
}
/* Clock Card */
.attendance-clock-card {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 2rem;
}
.attendance-clock-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.attendance-clock-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 500;
color: var(--text-secondary);
}
.attendance-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.attendance-status-dot.active {
background: var(--success);
box-shadow: 0 0 8px color-mix(in srgb, var(--success) 50%, transparent);
animation: pulse 2s ease-in-out infinite;
}
.attendance-clock-time {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
}
/* Shift Info */
.attendance-shift-info {
margin-bottom: 2rem;
}
.attendance-shift-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 640px) {
.attendance-shift-row {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
.attendance-shift-item {
text-align: center;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.attendance-shift-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.attendance-shift-value {
display: block;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-secondary);
}
.attendance-shift-value.success {
color: var(--success);
}
/* Clock Actions */
.attendance-clock-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Notes */
.attendance-notes {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.attendance-notes-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
/* Project Section */
.attendance-project-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.attendance-project-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.attendance-project-header .attendance-shift-label {
margin-bottom: 0;
}
.attendance-project-section .admin-form-select {
margin-bottom: 0;
}
.attendance-project-logs {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.attendance-project-log-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
}
.attendance-project-log-name {
font-weight: 500;
color: var(--text-primary);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attendance-project-log-time {
color: var(--text-muted);
white-space: nowrap;
font-size: 0.75rem;
}
.attendance-project-log-duration {
color: var(--text-secondary);
font-weight: 600;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Balance Card */
.attendance-balance-card {
background: var(--gradient);
border-radius: var(--border-radius);
padding: 1.5rem;
color: #fff;
}
.attendance-balance-title {
font-size: 0.875rem;
font-weight: 500;
opacity: 0.9;
margin-bottom: 0.75rem;
color: inherit;
}
.attendance-balance-value {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 1rem;
}
.attendance-balance-number {
font-size: 3rem;
font-weight: 700;
line-height: 1;
color: inherit;
}
.attendance-balance-unit {
font-size: 1rem;
opacity: 0.9;
}
.attendance-balance-detail {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
opacity: 0.8;
margin-bottom: 0.75rem;
}
.attendance-balance-bar {
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
overflow: hidden;
}
.attendance-balance-progress {
height: 100%;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
transition: width 0.3s ease;
}
/* Quick Links */
.attendance-quick-links {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 1rem;
}
.attendance-quick-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.attendance-quick-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
margin: -0.25rem -0.5rem;
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition);
}
.attendance-quick-link:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.attendance-quick-link span {
flex: 1;
}
.attendance-quick-link svg:last-child {
opacity: 0.5;
}
/* Leave Type Badges */
.attendance-leave-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--border-radius-sm);
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.25rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.attendance-leave-badge.badge-vacation {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.attendance-leave-badge.badge-sick {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
.attendance-leave-badge.badge-holiday {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.attendance-leave-badge.badge-unpaid {
background: var(--muted-light);
color: var(--muted);
}
/* Working Status Badge */
.attendance-working-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
}
.attendance-working-badge.working {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.attendance-working-badge.finished {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* GPS Link */
.attendance-gps-link {
text-decoration: none;
font-size: 1rem;
}
.attendance-gps-link:hover {
transform: scale(1.1);
}
/* Location Page */
.attendance-location-map {
height: 400px;
border-radius: var(--border-radius-sm);
margin-bottom: 1.5rem;
background: var(--bg-secondary);
position: relative;
z-index: 0; /* stacking context - Leaflet z-indexy zustanou uvnitr */
}
.attendance-location-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.attendance-location-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 1rem;
}
.attendance-location-card.empty {
opacity: 0.6;
}
.attendance-location-title {
font-size: 1rem;
margin-bottom: 0.5rem;
color: var(--accent-color);
font-weight: 600;
}
.attendance-location-time {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.attendance-location-address {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.attendance-location-coords {
font-size: 0.8rem;
color: var(--text-muted);
font-family: monospace;
}

View File

@@ -0,0 +1,144 @@
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)
const CustomInput = forwardRef(({ 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"
/>
))
const modeToInputType = { month: 'month', time: 'time' }
function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabled }) {
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}
/>
)
}
export default function AdminDatePicker({ mode = 'date', value, onChange, required, minDate, maxDate, ...rest }) {
const useNative = useMemo(() => isTouchDevice(), [])
if (useNative) {
return (
<NativeInput
mode={mode}
value={value}
onChange={onChange}
required={required}
minDate={minDate}
maxDate={maxDate}
disabled={rest.disabled}
/>
)
}
const toDate = (val) => {
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) => {
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) => {
if (!val) return undefined
if (val instanceof Date) return val
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} />,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: 'bottom-start',
portalId: 'datepicker-portal',
...rest
}
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,106 @@
import { useState, useEffect, 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'
export default function AdminLayout() {
const { isAuthenticated, loading, checkSession, user, logout } = useAuth()
const { theme, toggleTheme } = useTheme()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [loggingOut, setLoggingOut] = useState(false)
const location = useLocation()
// Extend session on every route change
useEffect(() => {
checkSession()
}, [location.pathname, checkSession])
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.97 }}
animate={loggingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
}
transition={{ duration: loggingOut ? 0.5 : 0.35, 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 style={{ 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>
</motion.div>
)
}

View File

@@ -0,0 +1,66 @@
import { motion, AnimatePresence } from 'framer-motion'
import { useAlertState } from '../context/AlertContext'
const icons = {
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,156 @@
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from './AdminDatePicker'
import useModalLock from '../hooks/useModalLock'
export default function BulkAttendanceModal({
show, onClose, form, setForm, users,
onSubmit, submitting, toggleUser, toggleAllUsers
}) {
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,120 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import useModalLock from '../hooks/useModalLock'
export default function ConfirmModal({
isOpen,
onClose,
onConfirm,
title = 'Potvrdit akci',
message = 'Opravdu chcete provést tuto akci?',
confirmText = 'Potvrdit',
cancelText = 'Zrušit',
type = 'danger', // 'danger' | 'warning' | 'info'
loading = false
}) {
useModalLock(isOpen)
useEffect(() => {
if (!isOpen) return
const handleEsc = (e) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, onClose])
if (!isOpen) return null
const icons = {
danger: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
),
warning: (
<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>
),
info: (
<svg width="24" height="24" 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>
),
default: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
)
}
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"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
{icons[type]}
</div>
<h2 id="confirm-modal-title" 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}
disabled={loading}
className="admin-btn admin-btn-primary"
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
Zpracování...
</>
) : (
confirmText
)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,47 @@
import { Component } from 'react'
export default class ErrorBoundary extends Component {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error, info) {
if (import.meta.env.DEV) {
console.error('ErrorBoundary caught:', error, info)
}
}
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '50vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1rem',
color: 'var(--text-secondary, #888)'
}}>
<p>Něco se pokazilo při načítání stránky.</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '0.5rem 1.5rem',
borderRadius: '8px',
border: '1px solid var(--border-color, #333)',
background: 'var(--bg-secondary, #1a1a1a)',
color: 'var(--text-primary, #fff)',
cursor: 'pointer'
}}
>
Načíst znovu
</button>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,28 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
export default function Forbidden() {
return (
<motion.div
className="forbidden-page"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="forbidden-icon">
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
<circle cx="12" cy="16" r="1" />
</svg>
</div>
<h1 className="forbidden-title">Přístup odepřen</h1>
<p className="forbidden-text">
Nemáte oprávnění pro zobrazení této stránky. Kontaktujte administrátora pro přidělení přístupu.
</p>
<Link to="/" className="forbidden-link">
Zpět na přehled
</Link>
</motion.div>
)
}

View File

@@ -0,0 +1,194 @@
import { motion } from 'framer-motion'
import { formatCurrency } from '../utils/formatters'
export default function OfferItemsSection({
items, updateItem, addItem, removeItem, moveItem,
itemTemplates, showItemTemplateMenu, setShowItemTemplateMenu,
addItemFromTemplate, totals, currency, applyVat, vatRate,
itemsError, readOnly
}) {
return (
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div>
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
{itemsError && <span className="admin-form-error">{itemsError}</span>}
</div>
{!readOnly && (
<div style={{ display: 'flex', gap: '0.5rem', position: 'relative' }}>
{itemTemplates.length > 0 && (
<div style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setShowItemTemplateMenu(prev => !prev)}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Ze šablony
</button>
{showItemTemplateMenu && (
<div className="offers-template-menu">
{itemTemplates.map(t => (
<div
key={t.id}
className="offers-template-menu-item"
onClick={() => addItemFromTemplate(t)}
>
<div style={{ fontWeight: 500 }}>{t.name}</div>
{t.default_price > 0 && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{Number(t.default_price).toFixed(2)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
<button type="button" onClick={addItem} className="admin-btn admin-btn-primary admin-btn-sm">
+ Přidat položku
</button>
</div>
)}
</div>
<div className="offers-items-table">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis položky</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
<th style={{ width: '4.5rem', textAlign: 'center' }}>V ceně</th>
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
{!readOnly && <th style={{ width: '5.5rem', textAlign: 'center' }}></th>}
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item._key || index}>
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<input
type="text"
value={item.description}
onChange={(e) => updateItem(index, 'description', e.target.value)}
className="admin-form-input"
placeholder="Název položky"
style={{ marginBottom: '0.5rem', fontWeight: 500 }}
readOnly={readOnly}
/>
<input
type="text"
value={item.item_description}
onChange={(e) => updateItem(index, 'item_description', e.target.value)}
className="admin-form-input"
placeholder="Podrobný popis (volitelný)"
style={{ fontSize: '0.8rem', opacity: 0.8 }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
className="admin-form-input"
min="0"
step="1"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => updateItem(index, 'unit', e.target.value)}
className="admin-form-input"
placeholder="hod"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
className="admin-form-input"
min="0"
step="0.01"
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
readOnly={readOnly}
/>
</td>
<td style={{ textAlign: 'center' }}>
<label className="admin-form-checkbox" style={{ justifyContent: 'center' }}>
<input
type="checkbox"
checked={item.is_included_in_total}
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
disabled={readOnly}
/>
<span></span>
</label>
</td>
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{formatCurrency(lineTotal, currency)}
</td>
{!readOnly && (
<td>
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
{items.length > 1 && (
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, currency)}</span>
</div>
{applyVat && (
<div className="offers-totals-row">
<span>DPH ({vatRate}%):</span>
<span>{formatCurrency(totals.vatAmount, currency)}</span>
</div>
)}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, currency)}</span>
</div>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,163 @@
import { motion } from 'framer-motion'
import DOMPurify from 'dompurify'
import RichEditor from './RichEditor'
export default function OfferScopeSection({
sections, addSection, removeSection, updateSection, moveSection,
scopeTemplates, showScopeTemplateMenu, setShowScopeTemplateMenu,
loadScopeTemplate, form, updateForm, readOnly
}) {
return (
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 className="admin-card-title" style={{ margin: 0 }}>Rozsah projektu</h3>
{!readOnly && (
<div style={{ display: 'flex', gap: '0.5rem', position: 'relative' }}>
{scopeTemplates.length > 0 && (
<div style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setShowScopeTemplateMenu(prev => !prev)}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Ze šablony
</button>
{showScopeTemplateMenu && (
<div className="offers-template-menu">
{scopeTemplates.map(t => (
<div
key={t.id}
className="offers-template-menu-item"
onClick={() => loadScopeTemplate(t)}
>
{t.name}
</div>
))}
</div>
)}
</div>
)}
<button type="button" onClick={addSection} className="admin-btn admin-btn-primary admin-btn-sm">
+ Přidat sekci
</button>
</div>
)}
</div>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Název rozsahu</label>
<input
type="text"
value={form.scope_title}
onChange={(e) => updateForm('scope_title', e.target.value)}
className="admin-form-input"
placeholder="Rozsah projektu"
readOnly={readOnly}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Popis rozsahu</label>
<input
type="text"
value={form.scope_description}
onChange={(e) => updateForm('scope_description', e.target.value)}
className="admin-form-input"
placeholder="Volitelný popis"
readOnly={readOnly}
/>
</div>
</div>
</div>
{sections.length === 0 ? (
<div className="admin-empty-state" style={{ padding: '2rem' }}>
<p style={{ color: 'var(--text-tertiary)' }}>Žádné sekce rozsahu. Klikněte na "Přidat sekci" pro přidání.</p>
</div>
) : (
<div className="offers-scope-list">
{sections.map((section, index) => (
<div key={section._key || index} className="offers-scope-section">
<div className="offers-scope-section-header">
<span className="offers-scope-number">{index + 1}.</span>
<span className="offers-scope-title">{(form.language === 'CZ' ? (section.title_cz || section.title) : section.title) || `Sekce ${index + 1}`}</span>
{!readOnly && (
<div className="offers-scope-actions">
<button type="button" onClick={() => moveSection(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button type="button" onClick={() => moveSection(index, 1)} disabled={index === sections.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
<button type="button" onClick={() => removeSection(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)}
</div>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
<span className="offers-lang-badge">EN</span>
Název sekce
</label>
<input
type="text"
value={section.title}
onChange={(e) => updateSection(index, 'title', e.target.value)}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
readOnly={readOnly}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
<span className="offers-lang-badge offers-lang-badge-cz">CZ</span>
Název sekce
</label>
<input
type="text"
value={section.title_cz}
onChange={(e) => updateSection(index, 'title_cz', e.target.value)}
className="admin-form-input"
placeholder="Název sekce (česky)"
readOnly={readOnly}
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Obsah</label>
{readOnly ? (
section.content && (
<div
className="offers-scope-content rich-text-view"
style={{ padding: '1rem' }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(section.content) }}
/>
)
) : (
<RichEditor
value={section.content}
onChange={(val) => updateSection(index, 'content', val)}
placeholder="Obsah sekce..."
minHeight="150px"
/>
)}
</div>
</div>
</div>
))}
</div>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,137 @@
import { useMemo, useRef, useCallback } from 'react'
import ReactQuill from 'react-quill-new'
import 'react-quill-new/dist/quill.snow.css'
/**
* Rich text editor (Quill).
* Font = class-based attributor, Size = inline style attributor (kompatibilni s TCPDF).
*/
const Quill = ReactQuill.Quill
if (!Quill.__bohaRegistered) {
const Font = Quill.import('attributors/class/font')
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')
SizeStyle.whitelist = [
'8px', '9px', '10px', '11px', '12px', '14px', '16px',
'18px', '20px', '24px', '28px', '32px', '36px', '48px'
]
Quill.register(SizeStyle, true)
Quill.__bohaRegistered = true
}
const Font = Quill.import('attributors/class/font')
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'
]
export default function RichEditor({
value,
onChange,
placeholder = 'Obsah...',
minHeight = '120px'
}) {
const quillRef = useRef(null)
const lastValueRef = useRef(value)
const modules = useMemo(() => ({
toolbar: TOOLBAR,
clipboard: {
matchVisual: false, // strip nepovolenou formataci pri paste
},
keyboard: {
bindings: {
tab: {
key: 9,
handler(range) {
const quill = this.quill
const [line] = quill.getLine(range.index)
if (line && line.statics && line.statics.blotName === 'list-item') {
quill.format('indent', '+1', 'user')
return false
}
quill.insertText(range.index, ' ', 'user')
quill.setSelection(range.index + 4, 'silent')
return false
}
},
shiftTab: {
key: 9,
shiftKey: true,
handler(range) {
const quill = this.quill
const [line] = quill.getLine(range.index)
if (line && line.statics && line.statics.blotName === 'list-item') {
quill.format('indent', '-1', 'user')
return false
}
return true
}
}
}
}
}), [])
// Ignoruj programaticke zmeny, reaguj jen na user input
const handleChange = useCallback((content, delta, source) => {
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 }}>
<ReactQuill
ref={quillRef}
theme="snow"
value={value || ''}
onChange={handleChange}
modules={modules}
formats={FORMATS}
placeholder={placeholder}
bounds=".rich-editor"
/>
</div>
)
}

View File

@@ -0,0 +1,346 @@
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
function ProjectTimeStatus({ form, projectLogs }) {
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>
)
}
function ProjectLogRow({ log, index, projectList, onUpdate, onRemove }) {
return (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
<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>
)
}
export default function ShiftFormModal({
mode, show, onClose, onSubmit,
form, setForm,
projectLogs, setProjectLogs, projectList,
users, onShiftDateChange, editingRecord
}) {
useModalLock(show)
const isCreate = mode === 'create'
const isWorkType = form.leave_type === 'work'
const updateField = (field, value) => {
setForm({ ...form, [field]: value })
}
const updateProjectLog = (index, field, value) => {
const updated = [...projectLogs]
updated[index] = { ...updated[index], [field]: value }
setProjectLogs(updated)
}
const removeProjectLog = (index) => {
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,382 @@
import { NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../../context/ThemeContext'
const menuSections = [
{
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>
)
}
]
}
]
export default function Sidebar({ isOpen, onClose, onLogout }) {
const { user, hasPermission } = useAuth()
const { theme } = useTheme()
const location = useLocation()
const isItemActive = (item) => {
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) => {
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 || user?.role}
</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,8 @@
export default function SortIcon({ column, sort, order }) {
if (sort !== column) return null
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 4, verticalAlign: 'middle' }}>
<path d={order === 'ASC' ? 'M18 15l-6-6-6 6' : 'M6 9l6 6 6-6'} />
</svg>
)
}

View File

@@ -0,0 +1,59 @@
import { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react'
const AlertContext = createContext(null)
const AlertStateContext = createContext(null)
export function AlertProvider({ children }) {
const [alerts, setAlerts] = useState([])
const removeAlert = useCallback((id) => {
setAlerts(prev => prev.filter(alert => alert.id !== id))
}, [])
const counterRef = useRef(0)
const addAlert = useCallback((message, type = 'success', duration = 4000) => {
const id = `${Date.now()}-${counterRef.current++}`
setAlerts(prev => [...prev, { id, message, type }])
if (duration > 0) {
setTimeout(() => {
removeAlert(id)
}, duration)
}
return id
}, [removeAlert])
const methods = useMemo(() => ({
addAlert,
removeAlert,
success: (message, duration) => addAlert(message, 'success', duration),
error: (message, duration) => addAlert(message, 'error', duration),
warning: (message, duration) => addAlert(message, 'warning', duration),
info: (message, duration) => addAlert(message, 'info', duration)
}), [addAlert, removeAlert])
return (
<AlertContext.Provider value={methods}>
<AlertStateContext.Provider value={{ alerts, removeAlert }}>
{children}
</AlertStateContext.Provider>
</AlertContext.Provider>
)
}
export function useAlert() {
const context = useContext(AlertContext)
if (!context) {
throw new Error('useAlert must be used within an AlertProvider')
}
return context
}
export function useAlertState() {
const context = useContext(AlertStateContext)
if (!context) {
throw new Error('useAlertState must be used within an AlertProvider')
}
return context
}

View File

@@ -0,0 +1,371 @@
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { setSessionExpired, setTokenGetter, setRefreshFn } from '../utils/api'
const API_BASE = '/api/admin'
const AuthStateContext = createContext(null)
const AuthActionsContext = createContext(null)
// Prevod snake_case API user objektu na camelCase pro interni stav
function mapUser(u) {
if (!u) return null
return {
...u,
fullName: u.full_name ?? u.fullName,
roleDisplay: u.role_display ?? u.roleDisplay,
isAdmin: u.is_admin ?? u.isAdmin,
totpEnabled: u.totp_enabled ?? u.totpEnabled,
require2FA: u.require_2fa ?? u.require2FA,
}
}
let accessToken = null
let tokenExpiresAt = null
let cachedUser = null
let sessionFetched = false
export function AuthProvider({ children }) {
const [user, setUser] = useState(cachedUser)
const [loading, setLoading] = useState(!sessionFetched)
const [error, setError] = useState(null)
const refreshTimeoutRef = useRef(null)
useEffect(() => {
cachedUser = user
}, [user])
const getAccessToken = useCallback(() => {
if (tokenExpiresAt && Date.now() > tokenExpiresAt - 30000) {
return null
}
return accessToken
}, [])
const setAccessToken = useCallback((token, expiresIn) => {
accessToken = token
tokenExpiresAt = token ? Date.now() + (expiresIn * 1000) : null
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
refreshTimeoutRef.current = null
}
// Refresh 1 min pred expirem
if (token && expiresIn > 60) {
const refreshTime = (expiresIn - 60) * 1000
refreshTimeoutRef.current = setTimeout(() => {
silentRefresh()
}, refreshTime)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const silentRefresh = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/refresh.php`, {
method: 'POST',
credentials: 'include'
})
const data = await response.json()
if (data.success && data.data?.access_token) {
setAccessToken(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
return true
}
accessToken = null
tokenExpiresAt = null
setUser(null)
cachedUser = null
setSessionExpired()
return false
} catch (err) {
if (import.meta.env.DEV) console.error('Token refresh failed:', err)
return false
}
}, [setAccessToken])
const checkSession = useCallback(async () => {
try {
const token = getAccessToken()
const headers = {
'Content-Type': 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${API_BASE}/session.php`, {
method: 'GET',
credentials: 'include',
headers
})
// Neodhlasovat pri rate limitu nebo server erroru
if (response.status === 429 || response.status >= 500) {
return !!cachedUser
}
const data = await response.json()
if (data.success && data.data?.authenticated) {
if (data.data.access_token) {
setAccessToken(data.data.access_token, data.data.expires_in)
}
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
return true
}
setUser(null)
cachedUser = null
accessToken = null
tokenExpiresAt = null
return false
} catch (err) {
if (import.meta.env.DEV) console.error('Session check failed:', err)
return !!cachedUser
} finally {
setLoading(false)
sessionFetched = true
}
}, [getAccessToken, setAccessToken])
useEffect(() => {
setTokenGetter(getAccessToken)
setRefreshFn(silentRefresh)
}, [getAccessToken, silentRefresh])
useEffect(() => {
checkSession()
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
}
}
}, [checkSession])
const login = useCallback(async (username, password, remember = false) => {
setError(null)
try {
const response = await fetch(`${API_BASE}/login.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
username,
password,
remember
})
})
const data = await response.json()
if (data.success) {
if (data.data?.requires_2fa) {
return {
success: false,
requires2FA: true,
loginToken: data.data.login_token,
remember
}
}
setAccessToken(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
sessionFetched = true
return { success: true }
}
setError(data.error)
return { success: false, error: data.error }
} catch {
const errorMsg = 'Chyba připojení. Zkontrolujte prosím připojení k internetu a zkuste to znovu.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}, [setAccessToken])
const verify2FA = useCallback(async (loginToken, code, remember = false, isBackup = false) => {
setError(null)
try {
const action = isBackup ? 'backup_verify' : 'verify'
const response = await fetch(`${API_BASE}/totp.php?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
login_token: loginToken,
code,
remember
})
})
const data = await response.json()
if (data.success) {
setAccessToken(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
sessionFetched = true
return { success: true }
}
setError(data.error)
return { success: false, error: data.error }
} catch {
const errorMsg = 'Chyba připojení. Zkontrolujte prosím připojení k internetu a zkuste to znovu.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}, [setAccessToken])
const logout = useCallback(async () => {
try {
const token = getAccessToken()
await fetch(`${API_BASE}/logout.php`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` })
},
credentials: 'include'
})
} catch (err) {
if (import.meta.env.DEV) console.error('Logout error:', err)
} finally {
accessToken = null
tokenExpiresAt = null
setUser(null)
cachedUser = null
sessionFetched = false
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
refreshTimeoutRef.current = null
}
}
}, [getAccessToken])
const apiRequest = useCallback(async (endpoint, options = {}) => {
let token = getAccessToken()
if (!token && user) {
const refreshed = await silentRefresh()
if (refreshed) {
token = getAccessToken()
}
}
const headers = {
'Content-Type': 'application/json',
...options.headers
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
credentials: 'include'
})
if (response.status === 401 && user) {
const refreshed = await silentRefresh()
if (refreshed) {
token = getAccessToken()
headers['Authorization'] = `Bearer ${token}`
return fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
credentials: 'include'
})
}
}
return response
}, [getAccessToken, silentRefresh, user])
const updateUser = useCallback((updates) => {
setUser(prev => prev ? { ...prev, ...updates } : null)
}, [])
const hasPermission = useCallback((permission) => {
if (!user) return false
if (user.isAdmin) return true
return (user.permissions || []).includes(permission)
}, [user])
const permissions = useMemo(() => user?.permissions || [], [user])
// Stabilni objekt - meni se jen kdyz se zmeni user/loading/error
const stateValue = useMemo(() => ({
user,
loading,
error,
isAuthenticated: !!user,
isAdmin: user?.isAdmin || false,
permissions,
hasPermission
}), [user, loading, error, permissions, hasPermission])
// Stabilni objekt - callback reference se nemeni
const actionsValue = useMemo(() => ({
login,
verify2FA,
logout,
checkSession,
getAccessToken,
apiRequest,
silentRefresh,
updateUser
}), [login, verify2FA, logout, checkSession, getAccessToken, apiRequest, silentRefresh, updateUser])
return (
<AuthActionsContext.Provider value={actionsValue}>
<AuthStateContext.Provider value={stateValue}>
{children}
</AuthStateContext.Provider>
</AuthActionsContext.Provider>
)
}
// Plny pristup (zpetna kompatibilita) - re-renderuje pri zmene stavu i akci
export function useAuth() {
const state = useContext(AuthStateContext)
const actions = useContext(AuthActionsContext)
if (!state || !actions) {
throw new Error('useAuth must be used within an AuthProvider')
}
return { ...state, ...actions }
}
// Pouze stav (user, permissions, loading) - re-renderuje pri zmene uzivatele
export function useAuthState() {
const context = useContext(AuthStateContext)
if (!context) {
throw new Error('useAuthState must be used within an AuthProvider')
}
return context
}
// Pouze akce (login, logout, ...) - stabilni reference, nere-renderuje
export function useAuthActions() {
const context = useContext(AuthActionsContext)
if (!context) {
throw new Error('useAuthActions must be used within an AuthProvider')
}
return context
}
export default AuthStateContext

544
src/admin/dashboard.css Normal file
View File

@@ -0,0 +1,544 @@
/* ============================================================================
Stat Cards
============================================================================ */
.admin-stat-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
}
.admin-stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent-color);
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.admin-stat-card.success::before { background: var(--success); }
.admin-stat-card.warning::before { background: var(--warning); }
.admin-stat-card.danger::before { background: var(--danger); }
.admin-stat-card.info::before { background: var(--info); }
.admin-stat-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
background: var(--accent-soft);
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.admin-stat-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.admin-stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-mono);
letter-spacing: -0.02em;
line-height: 1.2;
}
.admin-stat-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-stat-footer {
font-size: 0.75rem;
color: var(--text-secondary);
}
.admin-stat-icon.danger { background: var(--danger-soft); color: var(--danger); }
.admin-stat-icon.info { background: var(--info-soft); color: var(--info); }
.admin-stat-icon.success { background: var(--success-soft); color: var(--success); }
.admin-stat-icon.warning { background: var(--warning-soft); color: var(--warning); }
/* ============================================================================
Dashboard
============================================================================ */
/* Dashboard layout */
.dash {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.dash .admin-page-header {
margin-bottom: 0;
}
/* KPI grid */
.dash-kpi-grid {
display: grid;
gap: 0.875rem;
}
.dash-kpi-4 { grid-template-columns: repeat(4, 1fr); }
.dash-kpi-3 { grid-template-columns: repeat(3, 1fr); }
.dash-kpi-2 { grid-template-columns: repeat(2, 1fr); }
.dash-kpi-1 { grid-template-columns: 1fr; max-width: 320px; }
/* Quick actions */
.dash-quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.625rem;
}
.dash-quick-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: none;
border-radius: var(--border-radius-sm);
font-size: 0.8125rem;
font-weight: 600;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.dash-quick-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none !important;
}
.dash-quick-btn-success { background: var(--success-soft); color: var(--success); }
.dash-quick-btn-info { background: var(--info-soft); color: var(--info); }
.dash-quick-btn-warning { background: var(--warning-soft); color: var(--warning); }
.dash-quick-btn-danger { background: var(--danger-soft); color: var(--danger); }
.dash-quick-btn:hover {
transform: translateY(-1px);
filter: brightness(0.95);
}
[data-theme="light"] .dash-quick-btn-success { background: var(--success); color: #fff; }
[data-theme="light"] .dash-quick-btn-info { background: var(--info); color: #fff; }
[data-theme="light"] .dash-quick-btn-warning { background: var(--warning); color: #fff; }
[data-theme="light"] .dash-quick-btn-danger { background: var(--danger); color: #fff; }
/* Main content 3-col grid */
.dash-main-grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr;
gap: 1rem;
}
/* Card link */
.dash-card-link {
font-size: 0.75rem;
color: var(--accent-color);
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s;
}
.dash-card-link:hover {
opacity: 0.8;
}
/* Activity rows */
.dash-activity-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
transition: background 0.1s;
}
.dash-activity-row:last-child {
border-bottom: none;
}
.dash-activity-row:hover {
background: var(--bg-tertiary);
}
.dash-activity-icon {
width: 34px;
height: 34px;
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dash-activity-icon.success { background: var(--success-soft); color: var(--success); }
.dash-activity-icon.info { background: var(--info-soft); color: var(--info); }
.dash-activity-icon.warning { background: var(--warning-soft); color: var(--warning); }
.dash-activity-icon.danger { background: var(--danger-soft); color: var(--danger); }
.dash-activity-icon.accent { background: var(--accent-soft); color: var(--accent-color); }
.dash-activity-icon.muted { background: var(--bg-tertiary); color: var(--text-secondary); }
.dash-activity-main {
flex: 1;
min-width: 0;
}
.dash-activity-text {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-activity-sub {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
.dash-activity-time {
font-size: 0.6875rem;
color: var(--text-muted);
flex-shrink: 0;
}
/* Presence rows */
.dash-presence-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
.dash-presence-row:last-child {
border-bottom: none;
}
.dash-presence-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 700;
flex-shrink: 0;
text-transform: uppercase;
}
.dash-presence-avatar.dash-status-in { background: var(--success-soft); color: var(--success); }
.dash-presence-avatar.dash-status-away { background: var(--warning-soft); color: var(--warning); }
.dash-presence-avatar.dash-status-out { background: var(--bg-tertiary); color: var(--text-muted); }
.dash-presence-avatar.dash-status-leave { background: var(--info-soft); color: var(--info); }
.dash-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dash-status-dot.dash-status-in { background: var(--success); }
.dash-status-dot.dash-status-away { background: var(--warning); }
.dash-status-dot.dash-status-out { background: var(--text-muted); }
.dash-status-dot.dash-status-leave { background: var(--info); }
.dash-presence-label.dash-status-in { color: var(--success); }
.dash-presence-label.dash-status-away { color: var(--warning); }
.dash-presence-label.dash-status-out { color: var(--text-muted); }
.dash-presence-label.dash-status-leave { color: var(--info); }
.dash-presence-name {
flex: 1;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.dash-presence-end {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.dash-presence-label {
font-size: 0.75rem;
font-weight: 500;
}
.dash-presence-time {
font-size: 0.6875rem;
color: var(--text-muted);
}
/* Right column */
.dash-right-col {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Project rows */
.dash-project-row {
display: block;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
text-decoration: none;
transition: background 0.1s;
}
.dash-project-row:last-child {
border-bottom: none;
}
.dash-project-row:hover {
background: var(--bg-tertiary);
}
.dash-project-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.dash-project-customer {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
/* Stat mini rows */
.dash-stat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.8125rem;
color: var(--text-secondary);
}
.dash-stat-row:last-child {
border-bottom: none;
}
/* Empty row */
.dash-empty-row {
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
}
/* Bottom stacked layout (profile + sessions) */
.dash-bottom {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.dash .admin-card {
margin-bottom: 0;
}
/* Profile grid inside account card */
.dash-profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
}
.dash-profile-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.dash-profile-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
}
.dash-profile-value {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 1024px) {
.dash-main-grid {
grid-template-columns: 1fr 1fr;
}
.dash-right-col {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr 1fr;
}
.dash-kpi-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.dash-kpi-grid { grid-template-columns: repeat(2, 1fr); }
.dash-quick-actions { grid-template-columns: repeat(2, 1fr); }
.dash-main-grid {
grid-template-columns: 1fr;
}
.dash-right-col {
grid-template-columns: 1fr;
}
.dash-bottom {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.dash-quick-actions { grid-template-columns: 1fr 1fr; }
.dash-kpi-grid { grid-template-columns: 1fr; }
.dash-profile-grid { grid-template-columns: 1fr; }
}
/* ============================================================================
Sessions / Devices
============================================================================ */
.sessions-list {
display: flex;
flex-direction: column;
}
.session-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: var(--transition);
}
.session-item:last-child {
border-bottom: none;
}
.session-item:hover {
background: var(--bg-tertiary);
}
.session-item-current {
background: var(--row-current);
}
.session-item-current:hover {
background: var(--row-current-hover);
}
.session-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.session-item-current .session-icon {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.session-info {
flex: 1;
min-width: 0;
}
.session-device {
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
}
.session-meta {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.session-meta-separator {
color: var(--border-color);
}
.session-actions {
flex-shrink: 0;
}
@media (max-width: 640px) {
.session-item {
padding: 1rem;
gap: 0.75rem;
}
.session-icon {
width: 36px;
height: 36px;
}
.session-device {
font-size: 0.875rem;
}
.session-meta {
font-size: 0.75rem;
}
}

View File

@@ -0,0 +1,63 @@
import { useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import apiFetch from '../utils/api'
/**
* Hook pro API volani s automatickym error handlingem a AbortControllerem.
*
* Pouziti:
* const apiCall = useApiCall()
* const { data, ok } = await apiCall(url, options)
*
* Vraci { data, ok, response } nebo { data: null, ok: false } pri chybe.
* Automaticky zobrazuje alert.error pri selhani (lze potlacit options.silent).
*/
export default function useApiCall() {
const alert = useAlert()
const controllerRef = useRef(null)
const call = useCallback(async (url, options = {}) => {
const { silent = false, errorMsg = 'Chyba připojení', ...fetchOpts } = options
// Zrus predchozi request se stejnym controllerem
if (controllerRef.current) {
controllerRef.current.abort()
}
const controller = new AbortController()
controllerRef.current = controller
try {
const response = await apiFetch(url, { ...fetchOpts, signal: controller.signal })
if (response.status === 401) {
return { data: null, ok: false, response }
}
const data = await response.json()
if (!data.success && !silent) {
alert.error(data.error || errorMsg)
}
return { data, ok: data.success, response }
} catch (err) {
if (err.name === 'AbortError') {
return { data: null, ok: false, aborted: true }
}
if (!silent) {
alert.error(errorMsg)
}
return { data: null, ok: false }
}
}, [alert])
const abort = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.abort()
controllerRef.current = null
}
}, [])
return { call, abort }
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
/**
* Hook pro nacitani seznamovych dat s abort kontrolou, hledanim a razenim.
*
* @param {string} endpoint - PHP endpoint (napr. 'offers.php')
* @param {object} opts
* @param {string} opts.dataKey - Klic v result.data (napr. 'quotations')
* @param {string} opts.search - Hledany text
* @param {string} opts.sort - Sloupec pro razeni
* @param {string} opts.order - ASC/DESC
* @param {string} [opts.errorMsg] - Chybova zprava pri neuspechu
*/
export default function useListData(endpoint, { dataKey, search, sort, order, extraParams, errorMsg = 'Nepodařilo se načíst data' } = {}) {
const alert = useAlert()
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const abortRef = useRef(null)
const extraParamsStr = extraParams ? JSON.stringify(extraParams) : ''
const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
try {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (sort) params.set('sort', sort)
if (order) params.set('order', order)
if (extraParamsStr) {
const extra = JSON.parse(extraParamsStr)
Object.entries(extra).forEach(([k, v]) => { if (v) params.set(k, v) })
}
const response = await apiFetch(`${API_BASE}/${endpoint}?${params}`, { signal: controller.signal })
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setItems(result.data[dataKey] || [])
} else {
alert.error(result.error || errorMsg)
}
} catch (err) {
if (err.name === 'AbortError') return
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert, endpoint, dataKey, search, sort, order, extraParamsStr, errorMsg])
useEffect(() => {
fetchData()
return () => { if (abortRef.current) abortRef.current.abort() }
}, [fetchData])
return { items, setItems, loading, refetch: fetchData }
}

View File

@@ -0,0 +1,54 @@
import { useEffect } from 'react'
let lockCount = 0
function preventScroll(e) {
let el = e.target
while (el && el !== document.body) {
if (el.scrollHeight > el.clientHeight) {
const style = window.getComputedStyle(el)
const overflowY = style.overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return
}
}
el = el.parentElement
}
e.preventDefault()
}
function lock() {
if (lockCount === 0) {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
document.documentElement.style.overflow = 'hidden'
document.body.style.overflow = 'hidden'
document.addEventListener('touchmove', preventScroll, { passive: false })
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`
}
}
lockCount++
}
function unlock() {
lockCount--
if (lockCount <= 0) {
lockCount = 0
document.documentElement.style.overflow = ''
document.body.style.overflow = ''
document.body.style.paddingRight = ''
document.removeEventListener('touchmove', preventScroll)
}
}
export default function useModalLock(isOpen) {
useEffect(() => {
if (isOpen) {
lock()
return () => unlock()
}
}, [isOpen])
}

View File

@@ -0,0 +1,23 @@
import { useState, useCallback, useRef } from 'react'
export default function useTableSort(initialColumn, initialOrder = 'DESC') {
const [sort, setSort] = useState(initialColumn)
const [order, setOrder] = useState(initialOrder)
const userClicked = useRef(false)
const handleSort = useCallback((column) => {
userClicked.current = true
setSort(prev => {
if (prev === column) {
setOrder(o => o === 'ASC' ? 'DESC' : 'ASC')
return prev
}
setOrder('DESC')
return column
})
}, [])
const activeSort = userClicked.current ? sort : null
return { sort, order, handleSort, activeSort }
}

126
src/admin/invoices.css Normal file
View File

@@ -0,0 +1,126 @@
/* ============================================================================
Invoice Status Badges
============================================================================ */
.admin-badge-invoice-issued {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-badge-invoice-paid {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.admin-badge-invoice-overdue {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* ============================================================================
Invoice Month Navigation
============================================================================ */
.invoice-month-nav {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin-bottom: 0.875rem;
}
.invoice-month-nav span {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
min-width: 120px;
text-align: center;
}
.invoice-month-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.invoice-month-btn:hover:not(:disabled) {
border-color: var(--accent-color);
color: var(--accent-color);
}
.invoice-month-btn:disabled {
opacity: 0.3;
cursor: default;
}
/* ============================================================================
Received Invoices - Upload Modal
============================================================================ */
.received-upload-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.received-upload-card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
}
.received-upload-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.received-upload-file-info {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
color: var(--text-secondary);
}
.received-upload-file-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.received-upload-file-size {
font-size: 0.75rem;
color: var(--text-tertiary);
white-space: nowrap;
}
.received-upload-card-fields {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.received-upload-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}

23
src/admin/leave.css Normal file
View File

@@ -0,0 +1,23 @@
/* ============================================================================
Leave Request Status Badges
============================================================================ */
.badge-pending {
background: color-mix(in srgb, var(--warning) 15%, transparent);
color: var(--warning);
}
.badge-approved {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.badge-rejected {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
.badge-cancelled {
background: var(--muted-light);
color: var(--muted);
}

143
src/admin/login.css Normal file
View File

@@ -0,0 +1,143 @@
/* ============================================================================
Login Page
============================================================================ */
.admin-login {
min-height: 100vh;
min-height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bg-primary);
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.admin-login .bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
.admin-login .bg-orb-1 {
width: 400px;
height: 400px;
background: var(--orb-color-1);
top: 10%;
left: 20%;
animation: float 20s ease-in-out infinite;
}
.admin-login .bg-orb-2 {
width: 320px;
height: 320px;
background: var(--orb-color-2);
bottom: 20%;
right: 15%;
animation: float 25s ease-in-out infinite reverse;
}
.admin-login-theme-btn {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
overflow: hidden;
transition: var(--transition);
z-index: 10;
}
.admin-login-theme-btn:hover {
background: var(--glass-bg-solid);
color: var(--text-primary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-login-card {
width: 100%;
max-width: 420px;
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
padding: 2rem;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--glass-shadow);
position: relative;
z-index: 1;
overflow-y: auto;
}
@media (min-width: 640px) {
.admin-login-card {
padding: 2.5rem;
}
}
.admin-login-header {
text-align: center;
margin-bottom: 2rem;
}
.admin-login-logo {
height: 48px;
width: auto;
margin-bottom: 1rem;
}
.admin-login-2fa-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
margin-bottom: 1rem;
}
.admin-login-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.5rem;
}
.admin-login-subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
}
.admin-back-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: var(--transition);
margin-top: 1.5rem;
display: inline-block;
}
.admin-back-link:hover {
color: var(--text-primary);
text-decoration: underline;
}

775
src/admin/offers.css Normal file
View File

@@ -0,0 +1,775 @@
/* ============================================
Offers Module
============================================ */
/* Editor section cards */
.offers-editor-section {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
/* Settings grid */
.offers-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.offers-settings-grid > .admin-card {
margin-bottom: 0;
}
@media (max-width: 900px) {
.offers-settings-grid {
grid-template-columns: 1fr;
}
}
/* Logo section */
.offers-logo-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.offers-logo-preview {
max-width: 200px;
max-height: 100px;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: #fff;
}
.offers-logo-preview img {
max-width: 100%;
max-height: 80px;
object-fit: contain;
}
/* Items table */
.offers-items-table {
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
}
.offers-items-table .admin-table {
min-width: 700px;
margin: 0;
}
.offers-items-table .admin-table thead th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
padding: 8px 10px;
white-space: nowrap;
}
.offers-items-table .admin-table td {
vertical-align: top;
padding: 8px;
}
.offers-items-table .admin-table tbody tr {
transition: background var(--transition);
}
.offers-items-table .admin-table tbody tr:hover {
background: var(--table-row-hover);
}
.offers-items-table .admin-table td .admin-form-input {
display: block;
padding: 6px 8px;
font-size: 13px;
min-height: 32px;
}
/* Totals summary */
.offers-totals-summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
}
.offers-totals-row {
display: flex;
gap: 2rem;
justify-content: flex-end;
min-width: 250px;
padding: 0.25rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.offers-totals-row span:last-child {
min-width: 100px;
text-align: right;
font-weight: 500;
color: var(--text-primary);
}
.offers-totals-total {
border-top: 2px solid var(--text-primary);
margin-top: 0.25rem;
padding-top: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.offers-totals-total span:last-child {
font-weight: 700;
}
/* Scope sections list wrapper */
.offers-scope-list {
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Scope section card */
.offers-scope-section {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
transition: border-color var(--transition);
background: var(--bg-primary);
}
.offers-scope-content {
overflow: hidden;
}
.offers-scope-section:hover {
border-color: color-mix(in srgb, var(--border-color) 70%, var(--accent-color));
}
.offers-scope-section-header {
display: flex;
align-items: center;
padding: 0.625rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
border-radius: 0.5rem 0.5rem 0 0;
gap: 0.5rem;
}
.offers-scope-section-header .offers-scope-number {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
flex-shrink: 0;
min-width: 1.25rem;
}
.offers-scope-section-header .offers-scope-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.offers-scope-section-header .offers-scope-actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
flex-shrink: 0;
}
.offers-scope-section .admin-form {
padding: 1rem;
}
/* Customer selector */
.offers-customer-select {
position: relative;
}
.offers-customer-selected {
display: flex;
align-items: center;
gap: 0.5rem;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--input-bg);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.offers-customer-selected span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.offers-customer-selected .admin-btn-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-right: -4px;
}
.offers-customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
}
.offers-customer-dropdown::-webkit-scrollbar {
width: 5px;
}
.offers-customer-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.offers-customer-dropdown::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.offers-customer-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.offers-customer-dropdown-item {
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.offers-customer-dropdown-item:hover {
background: var(--bg-secondary);
}
.offers-customer-dropdown-item div:first-child {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.offers-customer-dropdown-item div:last-child {
font-size: 11.5px;
color: var(--text-tertiary);
margin-top: 1px;
}
.offers-customer-dropdown-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.8125rem;
}
/* Template dropdown menu */
.offers-template-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
min-width: 200px;
max-height: 250px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 0.25rem;
}
.offers-template-menu-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition);
}
.offers-template-menu-item:hover {
background: var(--bg-secondary);
}
/* Language badges */
.offers-lang-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
margin-right: 0.375rem;
vertical-align: middle;
}
.offers-lang-badge-cz {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* Compact form row for 3+ columns */
.offers-form-row-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.offers-form-row-3 {
grid-template-columns: 1fr;
}
}
/* Tabs - zachovany pro zpetnou kompatibilitu, nove pouzivat admin-tabs/admin-tab */
.offers-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
margin-bottom: 1.5rem;
max-width: 100%;
overflow-x: auto;
}
.offers-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
background: transparent;
border: none;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
letter-spacing: 0.01em;
white-space: nowrap;
}
.offers-tab:hover {
color: var(--text-primary);
}
.offers-tab.active {
color: var(--text-primary);
font-weight: 600;
background: var(--bg-secondary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--border-color);
}
/* RichEditor (Quill) */
.rich-editor {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
}
.rich-editor .quill {
display: flex;
flex-direction: column;
}
/* Toolbar */
.rich-editor .ql-toolbar.ql-snow {
background: var(--bg-secondary);
border: none;
border-bottom: 1px solid var(--border-color);
border-radius: 0.5rem 0.5rem 0 0;
padding: 0.5rem;
flex-wrap: wrap;
gap: 2px;
}
.rich-editor .ql-toolbar .ql-formats {
margin-right: 8px;
}
/* Toolbar buttons */
.rich-editor .ql-snow .ql-stroke {
stroke: var(--text-secondary);
}
.rich-editor .ql-snow .ql-fill {
fill: var(--text-secondary);
}
.rich-editor .ql-snow .ql-picker-label {
color: var(--text-secondary);
border-color: var(--border-color);
}
.rich-editor .ql-snow button:hover .ql-stroke,
.rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
stroke: var(--text-primary);
}
.rich-editor .ql-snow button:hover .ql-fill,
.rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
fill: var(--text-primary);
}
.rich-editor .ql-snow button:hover,
.rich-editor .ql-snow .ql-picker-label:hover {
color: var(--text-primary);
}
/* Active state */
.rich-editor .ql-snow button.ql-active {
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
border-radius: 4px;
}
.rich-editor .ql-snow button.ql-active .ql-stroke {
stroke: var(--accent-color);
}
.rich-editor .ql-snow button.ql-active .ql-fill,
.rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
fill: var(--accent-color);
}
.rich-editor .ql-snow .ql-picker-item.ql-selected {
color: var(--accent-color);
}
.rich-editor .ql-snow .ql-picker-label.ql-active {
color: var(--accent-color);
}
.rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
stroke: var(--accent-color);
}
/* Dropdowns (font, size, color, align) */
.rich-editor .ql-snow .ql-picker-options {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
z-index: 1000;
padding: 0.25rem;
}
.rich-editor .ql-snow .ql-picker-item {
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.rich-editor .ql-snow .ql-picker-item:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Font picker */
.rich-editor .ql-snow .ql-font .ql-picker-options { min-width: 11rem; max-height: 200px; overflow-y: auto; }
.rich-editor .ql-snow .ql-size .ql-picker-options { max-height: 200px; overflow-y: auto; }
/* Font labels - vysoka specificita kvuli quill.snow.css */
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="arial"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="arial"]::before { content: 'Arial' !important; font-family: Arial, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="tahoma"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="tahoma"]::before { content: 'Tahoma' !important; font-family: Tahoma, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="verdana"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="verdana"]::before { content: 'Verdana' !important; font-family: Verdana, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="georgia"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="georgia"]::before { content: 'Georgia' !important; font-family: Georgia, serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="times-new-roman"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="times-new-roman"]::before { content: 'Times New Roman' !important; font-family: 'Times New Roman', serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="courier-new"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="courier-new"]::before { content: 'Courier New' !important; font-family: 'Courier New', monospace; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="trebuchet-ms"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="trebuchet-ms"]::before { content: 'Trebuchet MS' !important; font-family: 'Trebuchet MS', sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="impact"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="impact"]::before { content: 'Impact' !important; font-family: Impact, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="comic-sans-ms"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="comic-sans-ms"]::before { content: 'Comic Sans MS' !important; font-family: 'Comic Sans MS', cursive; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="lucida-console"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="lucida-console"]::before { content: 'Lucida Console' !important; font-family: 'Lucida Console', monospace; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="palatino-linotype"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="palatino-linotype"]::before { content: 'Palatino Linotype' !important; font-family: 'Palatino Linotype', serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="garamond"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="garamond"]::before { content: 'Garamond' !important; font-family: Garamond, serif; }
/* Font classes */
.ql-font-arial { font-family: Arial, sans-serif; }
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
.ql-font-verdana { font-family: Verdana, sans-serif; }
.ql-font-georgia { font-family: Georgia, serif; }
.ql-font-times-new-roman { font-family: 'Times New Roman', serif; }
.ql-font-courier-new { font-family: 'Courier New', monospace; }
.ql-font-trebuchet-ms { font-family: 'Trebuchet MS', sans-serif; }
.ql-font-impact { font-family: Impact, sans-serif; }
.ql-font-comic-sans-ms { font-family: 'Comic Sans MS', cursive; }
.ql-font-lucida-console { font-family: 'Lucida Console', monospace; }
.ql-font-palatino-linotype { font-family: 'Palatino Linotype', serif; }
.ql-font-garamond { font-family: Garamond, serif; }
/* Size picker */
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="8px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="8px"]::before { content: '8px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="9px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="9px"]::before { content: '9px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before { content: '10px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="11px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="11px"]::before { content: '11px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before { content: '12px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before { content: '14px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before { content: '16px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before { content: '18px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before { content: '20px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before { content: '24px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before { content: '28px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before { content: '32px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="36px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="36px"]::before { content: '36px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="48px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="48px"]::before { content: '48px' !important; }
/* Editor area */
.rich-editor .ql-container.ql-snow {
border: none;
border-radius: 0 0 0.5rem 0.5rem;
font-size: 0.875rem;
}
.rich-editor .ql-editor {
min-height: var(--re-min-height, 120px);
padding: 0.75rem;
color: var(--text-primary);
line-height: 1.6;
font-size: 0.875rem;
background: var(--input-bg);
}
.rich-editor .ql-editor.ql-blank::before {
color: var(--text-tertiary);
font-style: normal;
}
/* Lists inside editor */
.rich-editor .ql-editor ul,
.rich-editor .ql-editor ol {
padding-left: 1.5rem;
}
/* Color picker */
.rich-editor .ql-snow .ql-color-picker .ql-picker-options[aria-hidden="false"] {
width: 176px;
padding: 0.375rem;
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.rich-editor .ql-snow .ql-color-picker .ql-picker-item {
width: 18px;
height: 18px;
border-radius: 2px;
margin: 0;
padding: 0;
flex-shrink: 0;
}
/* Tooltip (link editor) */
.rich-editor .ql-snow .ql-tooltip {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
color: var(--text-primary);
}
.rich-editor .ql-snow .ql-tooltip input[type="text"] {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
color: var(--text-primary);
padding: 0.25rem 0.5rem;
}
.rich-editor .ql-snow .ql-tooltip a {
color: var(--accent-color);
}
/* Read-only rendered rich text (Quill HTML output) */
.rich-text-view {
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.875rem;
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.rich-text-view ul,
.rich-text-view ol {
padding-left: 1.5rem;
margin: 0.25rem 0 0.75rem;
}
.rich-text-view li {
margin-bottom: 0.15rem;
}
.rich-text-view a {
color: var(--accent-color);
}
.rich-text-view strong,
.rich-text-view b {
font-weight: 600;
color: var(--text-primary);
display: inline-block;
margin-top: 0.5rem;
}
.rich-text-view br + b,
.rich-text-view br + strong {
margin-top: 0.75rem;
}
.rich-text-view > br:first-child,
.rich-text-view ul + br,
.rich-text-view ol + br {
display: none;
}
@media (max-width: 640px) {
.offers-editor-section {
padding: 1rem;
}
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
}
.offers-totals-summary {
align-items: stretch;
}
.offers-totals-row {
min-width: unset;
}
}
/* Offer draft row in table */
.offers-draft-row {
background: var(--row-draft);
}
.offers-draft-row-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--warning);
background: color-mix(in srgb, var(--warning) 14%, transparent);
padding: 0.2rem 0.55rem;
border-radius: 99px;
}
/* Expired offer without order */
.offers-expired-row {
background: var(--row-expired);
}
.offers-expired-row td {
color: var(--danger) !important;
}
.offers-expired-row a {
color: var(--danger) !important;
}
/* Invalidated offer */
.offers-invalidated-row {
opacity: 0.6;
}
.offers-invalidated-row td {
color: var(--text-muted) !important;
}
.offers-invalidated-row a {
color: var(--text-muted) !important;
}
/* Read-only form (invalidated offer detail) */
.offers-readonly input[readonly],
.offers-readonly select:disabled {
background-color: var(--bg-secondary);
cursor: default;
}
/* Offer draft indicator */
.offers-draft-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-tertiary);
margin-top: 0.2rem;
opacity: 0.8;
}

23
src/admin/orders.css Normal file
View File

@@ -0,0 +1,23 @@
/* ============================================================================
Order Status Badges
============================================================================ */
.admin-badge-order-prijata {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-badge-order-realizace {
background: color-mix(in srgb, var(--warning) 15%, transparent);
color: var(--warning);
}
.admin-badge-order-dokoncena {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.admin-badge-order-stornovana {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}

View File

@@ -0,0 +1,883 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import { formatTime, calculateWorkMinutes, formatMinutes } from '../utils/attendanceHelpers'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
function pluralizeDays(n) {
if (n === 1) return 'den'
if (n >= 2 && n <= 4) return 'dny'
return 'dnů'
}
function getFundBarBackground(fund) {
if (fund.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
if (fund.covered >= fund.fund) return 'linear-gradient(135deg, var(--success), #059669)'
return 'var(--gradient)'
}
export default function Attendance() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [data, setData] = useState({
ongoing_shift: null,
today_shifts: [],
date: '',
leave_balance: { vacation_total: 160, vacation_used: 0, vacation_remaining: 160, sick_used: 0 },
monthly_fund: null
})
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [leaveForm, setLeaveForm] = useState({
leave_type: 'vacation',
date_from: new Date().toISOString().split('T')[0],
date_to: new Date().toISOString().split('T')[0],
notes: ''
})
const [requestSubmitting, setRequestSubmitting] = useState(false)
const [notes, setNotes] = useState('')
const [projects, setProjects] = useState([])
const [switchingProject, setSwitchingProject] = useState(false)
const [projectLogs, setProjectLogs] = useState([])
const [activeProjectId, setActiveProjectId] = useState(null)
const [gpsConfirm, setGpsConfirm] = useState({ show: false, action: null })
const geoAbortRef = useRef(null)
// Cleanup geocoding fetch pri unmount
useEffect(() => {
return () => {
if (geoAbortRef.current) geoAbortRef.current.abort()
}
}, [])
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setData(result.data)
setNotes(result.data.ongoing_shift?.notes || '')
setProjectLogs(result.data.project_logs || [])
setActiveProjectId(result.data.active_project_id || null)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=projects`)
const result = await response.json()
if (result.success) {
setProjects(result.data.projects || [])
}
} catch {
// silent - projects are supplementary
}
}
loadProjects()
}, [])
useModalLock(showLeaveModal)
if (!hasPermission('attendance.record')) return <Forbidden />
const handlePunch = (action) => {
setSubmitting(true)
if (!navigator.geolocation) {
alert.warning('GPS není dostupná')
submitPunch(action, {})
return
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords
// Punch odeslat hned, adresu doplnit na pozadi
submitPunch(action, { latitude, longitude, accuracy, address: '' })
if (geoAbortRef.current) geoAbortRef.current.abort()
const controller = new AbortController()
geoAbortRef.current = controller
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, {
headers: { 'Accept-Language': 'cs' },
signal: controller.signal
})
.then(r => r.json())
.then(data => {
if (data.display_name) {
apiFetch(`${API_BASE}/attendance.php?action=update_address`, {
method: 'POST',
body: JSON.stringify({ latitude, longitude, address: data.display_name, punch_action: action })
}).catch(() => {})
}
})
.catch(() => {})
},
(error) => {
let errorMsg = 'Nepodařilo se získat polohu'
if (error.code === error.PERMISSION_DENIED) {
errorMsg = 'Přístup k poloze byl zamítnut'
} else if (error.code === error.TIMEOUT) {
errorMsg = 'Vypršel časový limit'
}
alert.error(errorMsg)
setGpsConfirm({ show: true, action })
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
)
}
const submitPunch = async (action, gpsData = {}) => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
punch_action: action,
...gpsData
})
})
if (response.status === 401) return
const result = await response.json()
setSubmitting(false)
if (result.success) {
await fetchData()
setTimeout(() => {
alert.success(result.message)
}, 300)
} else {
alert.error(result.error)
}
} catch {
setSubmitting(false)
alert.error('Chyba připojení')
}
}
const handleBreak = async () => {
setSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ punch_action: 'break_start' })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
await fetchData()
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSubmitting(false)
}
}
const handleSaveNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
alert.success('Poznámka byla uložena')
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const handleSwitchProject = async (newProjectId) => {
setSwitchingProject(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=switch_project`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: newProjectId || null })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
await fetchData()
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSwitchingProject(false)
}
}
const calculateBusinessDays = (from, to) => {
if (!from || !to) return 0
const start = new Date(from)
const end = new Date(to)
if (end < start) return 0
let days = 0
const current = new Date(start)
while (current <= end) {
const day = current.getDay()
if (day !== 0 && day !== 6) days++
current.setDate(current.getDate() + 1)
}
return days
}
const handleRequestSubmit = async () => {
setRequestSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(leaveForm)
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setShowLeaveModal(false)
await fetchData()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
setLeaveForm({
leave_type: 'vacation',
date_from: new Date().toISOString().split('T')[0],
date_to: new Date().toISOString().split('T')[0],
notes: ''
})
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setRequestSubmitting(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
</div>
<div style={{ display: 'flex', gap: '1.5rem' }}>
<div className="admin-card" style={{ flex: 2 }}>
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-line h-8" style={{ width: '120px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line h-10" style={{ width: '180px' }} />
<div className="admin-skeleton-row">
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
</div>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.25rem' }} />
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
<div className="admin-skeleton-line" style={{ width: '100%', height: '6px', borderRadius: '3px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.25rem' }} />
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
<div className="admin-skeleton-line" style={{ width: '100%', height: '6px', borderRadius: '3px' }} />
</div>
</div>
</div>
</div>
</div>
)
}
const { ongoing_shift: ongoingShift, today_shifts: todayShifts, leave_balance: leaveBalance } = data
const isOngoingShift = ongoingShift && !ongoingShift.departure_time
const completedToday = todayShifts.filter(s => s.departure_time)
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8)
const vacationHoursRemaining = leaveBalance.vacation_remaining % 8
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Docházka</h1>
<p className="admin-page-subtitle">
{new Date().toLocaleDateString('cs-CZ', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
</motion.div>
<div className="attendance-layout">
{/* Left Column - Clock In/Out */}
<motion.div
className="attendance-main"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="attendance-clock-card">
<div className="attendance-clock-header">
<div className="attendance-clock-status">
{isOngoingShift ? (
<>
<span className="attendance-status-dot active" />
<span>Pracuji</span>
</>
) : (
<>
<span className="attendance-status-dot" />
<span>Nepracuji</span>
</>
)}
</div>
<div className="attendance-clock-time">
{new Date().toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
{isOngoingShift ? (
<>
<div className="attendance-shift-info">
<div className="attendance-shift-row">
<div className="attendance-shift-item">
<span className="attendance-shift-label">Příchod</span>
<span className="attendance-shift-value success">
{formatTime(ongoingShift.arrival_time)}
</span>
</div>
<div className="attendance-shift-item">
<span className="attendance-shift-label">Pauza</span>
<span className={`attendance-shift-value ${ongoingShift.break_start ? 'success' : ''}`}>
{ongoingShift.break_start
? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
: '—'}
</span>
</div>
<div className="attendance-shift-item">
<span className="attendance-shift-label">Odchod</span>
<span className="attendance-shift-value"></span>
</div>
</div>
</div>
{projects.length > 0 && (
<div className="attendance-project-section">
<div className="attendance-project-header">
<span className="attendance-shift-label">Projekt</span>
{activeProjectId ? (
<span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.8125rem' }}>
{projects.find(p => String(p.id) === String(activeProjectId))
? `${projects.find(p => String(p.id) === String(activeProjectId)).project_number} ${projects.find(p => String(p.id) === String(activeProjectId)).name}`
: `Projekt #${activeProjectId}`}
</span>
) : (
<span className="text-muted" style={{ fontSize: '0.8125rem' }}>Žádný</span>
)}
</div>
<select
value={activeProjectId || ''}
onChange={(e) => handleSwitchProject(e.target.value || null)}
disabled={switchingProject}
className="admin-form-select"
style={{ fontSize: '0.875rem' }}
>
<option value=""> Bez projektu </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.project_number} {p.name}</option>
))}
</select>
{projectLogs.length > 0 && (
<div className="attendance-project-logs">
{projectLogs.map((log, i) => {
const start = new Date(log.started_at)
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end - start) / 60000)
const h = Math.floor(mins / 60)
const m = mins % 60
return (
<div key={log.id || i} className="attendance-project-log-item">
<span className="attendance-project-log-name">{log.project_name || `Projekt #${log.project_id}`}</span>
<span className="attendance-project-log-time">
{formatTime(log.started_at)} {log.ended_at ? formatTime(log.ended_at) : 'nyní'}
</span>
<span className="attendance-project-log-duration">{h}:{String(m).padStart(2, '0')} h</span>
</div>
)
})}
</div>
)}
</div>
)}
<div className="attendance-clock-actions">
{!ongoingShift.break_start && (
<button
onClick={handleBreak}
disabled={submitting}
className="admin-btn admin-btn-secondary"
style={{ width: '100%' }}
>
Pauza (30 min)
</button>
)}
<button
onClick={() => handlePunch('departure')}
disabled={submitting}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{submitting ? 'Zpracovávám...' : 'Odchod'}
</button>
<button
onClick={() => setShowLeaveModal(true)}
className="admin-btn admin-btn-secondary"
style={{ width: '100%' }}
>
Žádost o nepřítomnost
</button>
</div>
<div className="attendance-notes">
<label className="attendance-notes-label">Poznámka ke směně</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Co jste dělali během směny..."
className="admin-form-textarea"
rows={3}
/>
<div style={{ marginTop: '0.5rem' }}>
<button
onClick={handleSaveNotes}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Uložit poznámku
</button>
</div>
</div>
</>
) : (
<div className="attendance-clock-actions">
<button
onClick={() => handlePunch('arrival')}
disabled={submitting}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{submitting ? 'Zpracovávám...' : 'Příchod'}
</button>
<button
onClick={() => setShowLeaveModal(true)}
className="admin-btn admin-btn-secondary"
style={{ width: '100%' }}
>
Žádost o nepřítomnost
</button>
</div>
)}
</div>
{/* Completed Today */}
{completedToday.length > 0 && (
<div className="admin-card" style={{ marginTop: '1.5rem' }}>
<div className="admin-card-header">
<h2 className="admin-card-title">Dnešní dokončené směny</h2>
</div>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Odpracováno</th>
{projects.length > 0 && <th>Projekty</th>}
</tr>
</thead>
<tbody>
{completedToday.map((shift) => {
const shiftLogs = shift.project_logs || []
return (
<tr key={shift.id}>
<td className="admin-mono">{formatTime(shift.arrival_time)}</td>
<td className="admin-mono">
{shift.break_start && shift.break_end
? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
: '—'}
</td>
<td className="admin-mono">{formatTime(shift.departure_time)}</td>
<td className="admin-mono">{formatMinutes(calculateWorkMinutes(shift), true)}</td>
{projects.length > 0 && (
<td>
{shiftLogs.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{shiftLogs.map((log, i) => {
const mins = log.ended_at ? Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000) : 0
const h = Math.floor(mins / 60)
const m = mins % 60
return (
<span key={log.id || i} style={{ fontSize: '12px' }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)
</span>
)
})}
</div>
) : '—'}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</motion.div>
{/* Right Column - Stats & Quick Links */}
<motion.div
className="attendance-sidebar"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
{/* Leave Balance Card */}
<div className="attendance-balance-card">
<h3 className="attendance-balance-title">Dovolená {new Date().getFullYear()}</h3>
<div className="attendance-balance-value">
<span className="attendance-balance-number">{vacationDaysRemaining}</span>
<span className="attendance-balance-unit">
{pluralizeDays(vacationDaysRemaining)}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span>
</div>
<div className="attendance-balance-detail">
<span>Celkem: {leaveBalance.vacation_total}h</span>
<span>Čerpáno: {leaveBalance.vacation_used}h</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{ width: `${(leaveBalance.vacation_remaining / leaveBalance.vacation_total) * 100}%` }}
/>
</div>
</div>
{/* Monthly Fund Card */}
{data.monthly_fund && (
<div className="admin-stat-card" style={{ flexDirection: 'column', alignItems: 'stretch' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="admin-stat-icon info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-label">{data.monthly_fund.month_name}</span>
<span className="admin-stat-value">{data.monthly_fund.worked}h / {data.monthly_fund.fund}h</span>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8125rem', marginBottom: '0.5rem' }}>
<span>Odpracováno: {data.monthly_fund.worked}h</span>
{data.monthly_fund.overtime > 0 ? (
<span className="text-warning fw-600">Přesčas: +{data.monthly_fund.overtime}h</span>
) : (
<span>Zbývá: {data.monthly_fund.remaining}h</span>
)}
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, (data.monthly_fund.covered / data.monthly_fund.fund) * 100)}%`,
background: getFundBarBackground(data.monthly_fund)
}}
/>
</div>
{data.monthly_fund.leave_hours > 0 && (
<div className="text-muted" style={{ fontSize: '0.75rem', marginTop: '0.375rem' }}>
{'Pokryto: '}{data.monthly_fund.covered}h (práce {data.monthly_fund.worked}h
{data.monthly_fund.vacation_hours > 0 && ` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 && ` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.holiday_hours > 0 && ` + svátek ${data.monthly_fund.holiday_hours}h`}
{data.monthly_fund.unpaid_hours > 0 && ` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
</div>
)}
</div>
</div>
)}
{/* Sick Leave Card */}
<div className="admin-stat-card">
<div className="admin-stat-icon danger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-label">Nemoc {new Date().getFullYear()}</span>
<span className="admin-stat-value">{leaveBalance.sick_used}h čerpáno</span>
</div>
</div>
{/* Quick Links */}
<div className="attendance-quick-links">
<h4 className="attendance-quick-title">Rychlé odkazy</h4>
<Link to="/attendance/requests" className="attendance-quick-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
<span>Moje žádosti</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
<Link to="/attendance/history" className="attendance-quick-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 3v18h18" />
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3" />
</svg>
<span>Historie docházky</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{hasPermission('attendance.admin') && (
<Link to="/attendance/admin" className="attendance-quick-link">
<svg width="20" height="20" 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>
<span>Správa docházky</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
)}
{hasPermission('attendance.balances') && (
<Link to="/attendance/balances" className="attendance-quick-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
<span>Správa bilancí</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
)}
</div>
</motion.div>
</div>
{/* Leave Modal */}
<AnimatePresence>
{showLeaveModal && (
<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={() => setShowLeaveModal(false)} />
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Typ nepřítomnosti</label>
<select
value={leaveForm.leave_type}
onChange={(e) => setLeaveForm({ ...leaveForm, leave_type: e.target.value })}
className="admin-form-select"
>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Od</label>
<AdminDatePicker
mode="date"
value={leaveForm.date_from}
onChange={(val) => {
setLeaveForm(prev => ({
...prev,
date_from: val,
date_to: prev.date_to < val ? val : prev.date_to
}))
}}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Do</label>
<AdminDatePicker
mode="date"
value={leaveForm.date_to}
minDate={leaveForm.date_from}
onChange={(val) => setLeaveForm({ ...leaveForm, date_to: val })}
/>
</div>
</div>
{leaveForm.date_from && leaveForm.date_to && (
<div className="admin-form-group">
<div style={{
display: 'flex',
gap: '1.5rem',
padding: '0.75rem 1rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--border-radius)',
fontSize: '0.875rem'
}}>
<span>
<strong>{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)}</strong>{' '}
{(() => {
const d = calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)
if (d === 1) return 'pracovní den'
if (d >= 2 && d <= 4) return 'pracovní dny'
return 'pracovních dnů'
})()}
</span>
<span className="text-muted">
{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) * 8} hodin
</span>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={leaveForm.notes}
onChange={(e) => setLeaveForm({ ...leaveForm, notes: e.target.value })}
placeholder="Volitelná poznámka..."
className="admin-form-textarea"
rows={2}
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowLeaveModal(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleRequestSubmit}
disabled={requestSubmitting || calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0}
className="admin-btn admin-btn-primary"
>
{requestSubmitting ? 'Odesílám...' : 'Odeslat žádost'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={gpsConfirm.show}
onClose={() => { setGpsConfirm({ show: false, action: null }); setSubmitting(false) }}
onConfirm={() => { setGpsConfirm({ show: false, action: null }); submitPunch(gpsConfirm.action, {}) }}
title="GPS nedostupná"
message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?"
confirmText="Pokračovat"
cancelText="Zrušit"
type="warning"
/>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,671 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const getVacationClass = (remaining) => {
if (remaining <= 0) return 'text-danger'
if (remaining < 20) return 'text-warning'
return ''
}
const renderFundDiff = (data) => {
if (data.overtime > 0) {
return <span className="text-warning fw-600">+{data.overtime}h</span>
}
if (data.missing > 0) {
return <span className="text-danger">-{data.missing}h</span>
}
return <span className="text-success">0h</span>
}
const renderMonthlyStatus = (us, isFulfilled, isCurrentMonth) => {
if (us.overtime > 0) {
return <span className="text-warning fw-600" style={{ fontSize: '11px' }}>+{us.overtime}h</span>
}
if (us.missing > 0) {
return <span className="text-danger fw-600" style={{ fontSize: '11px' }}>-{us.missing}h</span>
}
if (isFulfilled && !isCurrentMonth) {
return <span className="text-success" style={{ fontSize: '11px' }}>OK</span>
}
return null
}
const getProgressBackground = (us, isFulfilled, isCurrentMonth) => {
if (us.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
if (isFulfilled) return 'linear-gradient(135deg, var(--success), #059669)'
if (isCurrentMonth) return 'var(--gradient)'
return 'var(--danger)'
}
export default function AttendanceBalances() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [year, setYear] = useState(new Date().getFullYear())
const [data, setData] = useState({
users: [],
balances: {}
})
const [fundLoading, setFundLoading] = useState(true)
const [fundData, setFundData] = useState({
months: {},
holidays: [],
users: [],
balances: {}
})
const [projectLoading, setProjectLoading] = useState(true)
const [projectData, setProjectData] = useState({ months: {} })
const [showEditModal, setShowEditModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [editForm, setEditForm] = useState({
vacation_total: 160,
vacation_used: 0,
sick_used: 0
})
const [resetConfirm, setResetConfirm] = useState({ show: false, userId: null, userName: '' })
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=balances&year=${year}`, {
})
const result = await response.json()
if (result.success) {
setData(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [year, alert])
const fetchFundData = useCallback(async () => {
setFundLoading(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=workfund&year=${year}`)
const result = await response.json()
if (result.success) {
setFundData(result.data)
}
} catch {
// silent - fund data is supplementary
} finally {
setFundLoading(false)
}
}, [year])
const fetchProjectData = useCallback(async () => {
setProjectLoading(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=project_report&year=${year}`)
const result = await response.json()
if (result.success) {
setProjectData(result.data)
}
} catch {
// silent - project data is supplementary
} finally {
setProjectLoading(false)
}
}, [year])
useEffect(() => {
fetchData()
fetchFundData()
fetchProjectData()
}, [fetchData, fetchFundData, fetchProjectData])
useModalLock(showEditModal)
if (!hasPermission('attendance.balances')) return <Forbidden />
const openEditModal = (userId, balance) => {
setEditingUser({ id: userId, name: balance.name })
setEditForm({
vacation_total: balance.vacation_total,
vacation_used: balance.vacation_used,
sick_used: balance.sick_used
})
setShowEditModal(true)
}
const handleEditSubmit = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=balances`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: editingUser.id,
year,
action_type: 'edit',
...editForm
})
})
const result = await response.json()
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const handleReset = async () => {
if (!resetConfirm.userId) return
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=balances`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: resetConfirm.userId,
year,
action_type: 'reset'
})
})
const result = await response.json()
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: '' })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const years = []
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
years.push(y)
}
// Sum fund data across all months in the year for a user
const getYearFundTotals = (userId) => {
if (!fundData.months || Object.keys(fundData.months).length === 0) return null
let totalFund = 0
let totalWorked = 0
let totalCovered = 0
for (const monthData of Object.values(fundData.months)) {
totalFund += monthData.fund
const us = monthData.users?.[userId]
if (us) {
totalWorked += us.worked
totalCovered += us.covered
}
}
const missing = Math.max(0, Math.round((totalFund - totalCovered) * 10) / 10)
const overtime = Math.max(0, Math.round((totalCovered - totalFund) * 10) / 10)
return { fund: totalFund, worked: Math.round(totalWorked * 10) / 10, covered: Math.round(totalCovered * 10) / 10, missing, overtime }
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Správa bilancí</h1>
</div>
<div className="admin-page-actions">
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="admin-form-select"
style={{ minWidth: '100px' }}
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && Object.keys(data.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{!loading && Object.keys(data.balances).length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Nárok (h)</th>
<th>Čerpáno (h)</th>
<th>Zbývá (h)</th>
<th>Nemoc (h)</th>
<th>Fond roku</th>
<th>Odpracováno</th>
<th>+/</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Object.entries(data.balances).map(([userId, balance]) => {
const yf = getYearFundTotals(userId)
return (
<tr key={userId}>
<td style={{ fontWeight: 500 }}>{balance.name}</td>
<td className="admin-mono">{balance.vacation_total}</td>
<td className="admin-mono">{balance.vacation_used.toFixed(1)}</td>
<td className="admin-mono">
<span
className={getVacationClass(balance.vacation_remaining)}
>
{balance.vacation_remaining.toFixed(1)}
</span>
</td>
<td className="admin-mono">{balance.sick_used.toFixed(1)}</td>
<td className="admin-mono">{yf ? `${yf.fund}h` : '—'}</td>
<td className="admin-mono">{yf ? `${yf.worked}h` : '—'}</td>
<td className="admin-mono">
{yf ? renderFundDiff(yf) : '—'}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(userId, balance)}
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" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setResetConfirm({ show: true, userId, userName: balance.name })}
className="admin-btn-icon danger"
title="Resetovat"
aria-label="Resetovat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Monthly Fund Overview */}
{!fundLoading && fundData.months && Object.keys(fundData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
style={{ marginTop: '1.5rem' }}
>
<h2 className="admin-page-title" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>
Měsíční přehled fondu {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(fundData.months).map(([monthKey, monthData]) => {
const isCurrentMonth = year === currentYear && parseInt(monthKey) === currentMonth
return (
<div
key={monthKey}
className="admin-card"
style={isCurrentMonth ? {
borderColor: 'var(--accent-color)',
boxShadow: '0 0 0 1px var(--accent-color)'
} : {}}
>
<div className="admin-card-body">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 style={{ fontWeight: 600, fontSize: '1rem', margin: 0 }}>
{monthData.month_name}
{isCurrentMonth && (
<span style={{
marginLeft: '0.5rem',
fontSize: '0.7rem',
padding: '0.125rem 0.375rem',
background: 'var(--accent-light)',
color: 'var(--accent-color)',
borderRadius: 'var(--border-radius-sm)',
fontWeight: 500
}}>
aktuální
</span>
)}
</h3>
<span className="text-secondary" style={{ fontSize: '12px' }}>
{monthData.fund}h ({monthData.business_days} dnů)
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{fundData.users && fundData.users.map(user => {
const us = monthData.users?.[user.id]
if (!us) return null
const pct = monthData.fund > 0 ? Math.min(100, (us.covered / monthData.fund) * 100) : 0
const isFulfilled = us.covered >= monthData.fund
return (
<div key={user.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '12px' }}>
<span style={{ color: 'var(--text-primary)' }}>{us.name}</span>
<span style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<span className="text-secondary">{us.worked}h</span>
{renderMonthlyStatus(us, isFulfilled, isCurrentMonth)}
</span>
</div>
<div style={{
marginTop: '0.125rem',
height: '3px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${pct}%`,
background: getProgressBackground(us, isFulfilled, isCurrentMonth),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)
})}
</div>
</div>
</div>
)
})}
</div>
</motion.div>
)}
{fundLoading && (
<div style={{ marginTop: '1.5rem' }}>
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)}
{/* Monthly Project Overview */}
{!projectLoading && projectData.months && Object.keys(projectData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
style={{ marginTop: '1.5rem' }}
>
<h2 className="admin-page-title" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>
Měsíční přehled projektů {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(projectData.months).map(([monthKey, monthInfo]) => {
const isCurrentMonth = year === currentYear && parseInt(monthKey) === currentMonth
const totalHours = monthInfo.projects.reduce((sum, p) => sum + p.hours, 0)
if (monthInfo.projects.length === 0) return null
return (
<div
key={monthKey}
className="admin-card"
style={isCurrentMonth ? {
borderColor: 'var(--accent-color)',
boxShadow: '0 0 0 1px var(--accent-color)'
} : {}}
>
<div className="admin-card-body">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 style={{ fontWeight: 600, fontSize: '1rem', margin: 0 }}>
{monthInfo.month_name}
{isCurrentMonth && (
<span style={{
marginLeft: '0.5rem',
fontSize: '0.7rem',
padding: '0.125rem 0.375rem',
background: 'var(--accent-light)',
color: 'var(--accent-color)',
borderRadius: 'var(--border-radius-sm)',
fontWeight: 500
}}>
aktuální
</span>
)}
</h3>
<span className="text-secondary fw-600" style={{ fontSize: '12px' }}>
{totalHours.toFixed(1)}h
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{monthInfo.projects.map((proj) => (
<div key={proj.project_id || 'no-project'}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-primary)' }}>
{proj.project_id ? proj.project_number : 'Bez projektu'}
</span>
<span className="text-secondary fw-600" style={{ fontSize: '12px' }}>
{proj.hours.toFixed(1)}h
</span>
</div>
{proj.project_id && proj.project_name && (
<div className="text-muted" style={{ fontSize: '0.7rem', marginBottom: '0.25rem' }}>
{proj.project_name}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{proj.users.map((u) => {
const pct = proj.hours > 0 ? Math.min(100, (u.hours / proj.hours) * 100) : 0
return (
<div key={u.user_id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '11px' }}>
<span className="text-secondary">{u.user_name}</span>
<span className="text-secondary">{u.hours.toFixed(1)}h</span>
</div>
<div style={{
marginTop: '1px',
height: '3px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${pct}%`,
background: proj.project_id
? 'var(--gradient)'
: '#94a3b8',
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
</div>
)
})}
</div>
</motion.div>
)}
{projectLoading && (
<div style={{ marginTop: '1.5rem' }}>
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)}
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingUser && (
<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={() => setShowEditModal(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 dovolenou</h2>
<p className="text-secondary" style={{ marginTop: '0.25rem' }}>
{editingUser.name}
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Nárok na dovolenou (hodiny)</label>
<input
type="number"
value={editForm.vacation_total}
onChange={(e) => setEditForm({ ...editForm, vacation_total: parseFloat(e.target.value) })}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Čerpáno dovolené (hodiny)</label>
<input
type="number"
value={editForm.vacation_used}
onChange={(e) => setEditForm({ ...editForm, vacation_used: parseFloat(e.target.value) })}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Čerpáno nemocenské (hodiny)</label>
<input
type="number"
value={editForm.sick_used}
onChange={(e) => setEditForm({ ...editForm, sick_used: parseFloat(e.target.value) })}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Reset Confirmation */}
<ConfirmModal
isOpen={resetConfirm.show}
onClose={() => setResetConfirm({ show: false, userId: null, userName: '' })}
onConfirm={handleReset}
title="Resetovat bilanci"
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
/>
</div>
)
}

View File

@@ -0,0 +1,318 @@
import { useState, useEffect } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function AttendanceCreate() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [users, setUsers] = useState([])
const today = new Date().toISOString().split('T')[0]
const [form, setForm] = useState({
user_id: '',
shift_date: today,
leave_type: 'work',
leave_hours: 8,
arrival_date: today,
arrival_time: '',
break_start_date: today,
break_start_time: '',
break_end_date: today,
break_end_time: '',
departure_date: today,
departure_time: '',
notes: ''
})
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=users`, {
})
const result = await response.json()
if (result.success) {
setUsers(result.data.users)
}
} catch {
alert.error('Nepodařilo se načíst uživatele')
} finally {
setLoading(false)
}
}
fetchUsers()
}, [alert])
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.user_id || !form.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
}
setSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
const result = await response.json()
if (result.success) {
alert.success(result.message)
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSubmitting(false)
}
}
const handleShiftDateChange = (newDate) => {
setForm({
...form,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate
})
}
const isWorkType = form.leave_type === 'work'
if (!hasPermission('attendance.admin')) return <Forbidden />
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-card" style={{ maxWidth: '600px' }}>
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i}>
<div className="admin-skeleton-line w-1/4" style={{ marginBottom: '0.5rem', height: '10px' }} />
<div className="admin-skeleton-line w-full h-10" />
</div>
))}
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
style={{ maxWidth: '600px' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<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) => setForm({ ...form, user_id: e.target.value })}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</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) => handleShiftDateChange(val)}
required
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">Typ záznamu</label>
<select
value={form.leave_type}
onChange={(e) => setForm({ ...form, 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"
value={form.leave_hours}
onChange={(e) => setForm({ ...form, leave_hours: parseFloat(e.target.value) })}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">Výchozí 8 hodin pro celý den</small>
</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) => setForm({ ...form, 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) => setForm({ ...form, 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) => setForm({ ...form, 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) => setForm({ ...form, 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) => setForm({ ...form, 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) => setForm({ ...form, 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) => setForm({ ...form, 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) => setForm({ ...form, departure_time: val })}
/>
</div>
</div>
</>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</div>
<div className="admin-form-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</form>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,512 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import DOMPurify from 'dompurify'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const formatBreakRange = (record) => {
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 '—'
}
const renderProjectCell = (record) => {
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, m, isActive = false
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0
m = parseInt(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 - new Date(log.started_at)) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
</span>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '—'
}
const renderPrintFundStatus = (fund) => {
if (fund.overtime > 0) {
return <span className="leave-badge badge-overtime">+{fund.overtime}h přesčas</span>
}
if (fund.remaining > 0) {
return <span style={{ color: '#dc2626' }}>{fund.remaining}h</span>
}
return <span style={{ color: '#16a34a' }}>splněno</span>
}
export default function AttendanceHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const printRef = useRef(null)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [data, setData] = useState({
records: [],
month_name: '',
year: new Date().getFullYear(),
total_minutes: 0,
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
leave_balance: null,
monthly_fund: null
})
const fetchData = useCallback(async () => {
setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=history&month=${month}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setData(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
setLoading(false)
}
}, [month, alert])
useEffect(() => {
fetchData()
}, [fetchData])
if (!hasPermission('attendance.history')) return <Forbidden />
const handlePrint = () => {
if (!printRef.current) return
const printWindow = window.open('', '_blank')
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${data.month_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 15mm;
}
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
.user-header {
background: #f5f5f5;
border: 1px solid #ddd;
padding: 10px 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-header h3 { font-size: 13px; font-weight: 600; }
.user-header .total { font-size: 12px; font-weight: 600; }
.leave-summary {
margin-top: 10px;
padding: 8px 15px;
background: #f9f9f9;
border: 1px solid #ddd;
font-size: 10px;
}
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 500;
}
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 0; margin: 0; }
@page { size: A4 portrait; margin: 10mm; }
.user-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
${DOMPurify.sanitize(printRef.current.innerHTML)}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Historie docházky</h1>
<p className="admin-page-subtitle">{data.month_name}</p>
</div>
<div className="admin-page-actions">
{data.records.length > 0 && (
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
)}
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card"
style={{ marginBottom: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
value={month}
onChange={(val) => setMonth(val)}
/>
</div>
</div>
</div>
</motion.div>
{/* Monthly Fund Card */}
{!loading && data.monthly_fund && (
<motion.div
className="admin-card"
style={{ marginBottom: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="admin-card-body">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div className="admin-stat-icon info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: '200px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.375rem' }}>
<span style={{ fontWeight: 600, fontSize: '1rem', color: 'var(--text-primary)' }}>
Fond: {data.monthly_fund.worked}h / {data.monthly_fund.fund}h
</span>
<span className="text-secondary" style={{ fontSize: '0.8125rem' }}>
{data.monthly_fund.business_days} prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, data.monthly_fund.fund > 0 ? (data.monthly_fund.covered / data.monthly_fund.fund) * 100 : 0)}%`,
background: data.monthly_fund.covered >= data.monthly_fund.fund
? 'linear-gradient(135deg, var(--success), #059669)'
: 'var(--gradient)'
}}
/>
</div>
<div className="text-muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginTop: '0.375rem' }}>
<span>
{'Pokryto: '}{data.monthly_fund.covered}h (práce {data.monthly_fund.worked}h
{data.vacation_hours > 0 && ` + dovolená ${data.vacation_hours}h`}
{data.sick_hours > 0 && ` + nemoc ${data.sick_hours}h`}
{data.holiday_hours > 0 && ` + svátek ${data.holiday_hours}h`}
{data.unpaid_hours > 0 && ` + neplacené ${data.unpaid_hours}h`}
)
</span>
{data.monthly_fund.overtime > 0 ? (
<span className="text-warning fw-600">Přesčas: +{data.monthly_fund.overtime}h</span>
) : (
<span>Zbývá: {data.monthly_fund.remaining}h</span>
)}
</div>
</div>
</div>
</div>
</motion.div>
)}
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && data.records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{!loading && data.records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{data.records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = isLeave
? (record.leave_hours || 8) * 60
: calculateWorkMinutes(record)
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono">
{isLeave ? '—' : formatBreakRange(record)}
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? formatMinutes(workMinutes, true) : '—'}</td>
<td>
{renderProjectCell(record)}
</td>
<td style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{record.notes || ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Hidden Print Content */}
{data.records.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}>
<table className="print-wrapper-table">
<thead>
<tr><td>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{data.month_name}</div>
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div>
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ''}</h3>
<span className="total">Odpracováno: {formatMinutes(data.total_minutes, true)}</span>
</div>
{data.leave_balance && (
<div className="leave-summary">
<strong>Dovolená {data.year}:</strong> Zbývá {data.leave_balance.vacation_remaining.toFixed(1)}h z {data.leave_balance.vacation_total}h
{data.vacation_hours > 0 && <> | <span className="leave-badge badge-vacation">Tento měsíc: {data.vacation_hours}h</span></>}
{data.sick_hours > 0 && <> | <span className="leave-badge badge-sick">Nemoc: {data.sick_hours}h</span></>}
{data.holiday_hours > 0 && <> | <span className="leave-badge badge-holiday">Svátek: {data.holiday_hours}h</span></>}
{data.monthly_fund?.overtime > 0 && <> | <span className="leave-badge badge-overtime">Přesčas: +{data.monthly_fund.overtime}h</span></>}
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{[...data.records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td className="text-center">
{isLeave || !record.break_start || !record.break_end
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
}
</td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style={{ fontSize: '8px' }}>
{(record.project_logs && record.project_logs.length > 0)
? record.project_logs.map((log, i) => {
let h, m
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0; m = parseInt(log.minutes) || 0
} else if (log.started_at && log.ended_at) {
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000))
h = Math.floor(mins2 / 60); m = mins2 % 60
} else { h = 0; m = 0 }
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
})
: record.project_name || '—'}
</td>
<td>{record.notes || ''}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">Odpracováno:</td>
<td className="text-center">{formatMinutes(data.total_minutes, true)}</td>
<td colSpan={2}></td>
</tr>
{data.monthly_fund && (
<tr>
<td colSpan={6} className="text-right">Fond měsíce:</td>
<td className="text-center">{data.monthly_fund.covered}h / {data.monthly_fund.fund}h</td>
<td colSpan={2}>
{renderPrintFundStatus(data.monthly_fund)}
</td>
</tr>
)}
</tfoot>
</table>
</div>
</td></tr>
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,308 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { formatDate, formatTime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function AttendanceLocation() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const { id } = useParams()
const [loading, setLoading] = useState(true)
const [record, setRecord] = useState(null)
const mapRef = useRef(null)
const mapInstanceRef = useRef(null)
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=location&id=${id}`, {
})
const result = await response.json()
if (result.success) {
setRecord(result.data.record)
} else {
alert.error('Záznam nebyl nalezen')
navigate('/attendance/admin')
}
} catch {
alert.error('Nepodařilo se načíst data')
navigate('/attendance/admin')
} finally {
setLoading(false)
}
}
fetchData()
}, [id, alert, navigate])
useEffect(() => {
if (!record || loading) return
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
if (!hasAnyLocation || !mapRef.current) return
const loadLeaflet = async () => {
if (window.L) {
initMap()
return
}
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
document.head.appendChild(link)
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.onload = initMap
document.body.appendChild(script)
}
const initMap = () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove()
}
const L = window.L
const map = L.map(mapRef.current)
mapInstanceRef.current = map
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map)
const bounds = []
const locations = []
if (hasArrivalLocation) {
locations.push({
lat: parseFloat(record.arrival_lat),
lng: parseFloat(record.arrival_lng),
type: 'arrival',
label: 'Příchod',
time: formatTime(record.arrival_time),
accuracy: record.arrival_accuracy || 0
})
}
if (hasDepartureLocation) {
locations.push({
lat: parseFloat(record.departure_lat),
lng: parseFloat(record.departure_lng),
type: 'departure',
label: 'Odchod',
time: formatTime(record.departure_time),
accuracy: record.departure_accuracy || 0
})
}
locations.forEach(loc => {
// Leaflet needs raw color strings; CSS vars don't work in canvas/SVG markers
const color = loc.type === 'arrival' ? '#22c55e' : '#ef4444'
const marker = L.circleMarker([loc.lat, loc.lng], {
radius: 10,
fillColor: color,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(map)
marker.bindPopup(`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`)
if (loc.accuracy > 0) {
L.circle([loc.lat, loc.lng], {
radius: loc.accuracy,
fillColor: color,
color: color,
weight: 1,
opacity: 0.3,
fillOpacity: 0.1
}).addTo(map)
}
bounds.push([loc.lat, loc.lng])
})
if (bounds.length === 1) {
map.setView(bounds[0], 16)
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [50, 50] })
}
}
loadLeaflet()
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove()
mapInstanceRef.current = null
}
}
}, [record, loading])
const formatDatetime = (datetime) => {
if (!datetime) return '—'
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`
}
if (!hasPermission('attendance.admin')) return <Forbidden />
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton-line" style={{ width: '100%', height: '300px', borderRadius: '8px' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem' }}>
{[0, 1].map(i => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line h-8" style={{ width: '50%' }} />
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-3/4" />
</div>
</div>
))}
</div>
</div>
)
}
if (!record) {
return null
}
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
const month = record.shift_date.substring(0, 7)
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link to={`/attendance/admin?month=${month}`} className="admin-btn admin-btn-secondary">
Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div
ref={mapRef}
className="attendance-location-map"
/>
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div className={`attendance-location-card ${!hasArrivalLocation ? 'empty' : ''}`}>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.arrival_time ? formatDatetime(record.arrival_time) : '—'}
</div>
{hasArrivalLocation ? (
<>
<div className="attendance-location-address">
{record.arrival_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy && ` (přesnost: ${Math.round(record.arrival_accuracy)}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ marginTop: '0.5rem' }}
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div className={`attendance-location-card ${!hasDepartureLocation ? 'empty' : ''}`}>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.departure_time ? formatDatetime(record.departure_time) : '—'}
</div>
{hasDepartureLocation ? (
<>
<div className="attendance-location-address">
{record.departure_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy && ` (přesnost: ${Math.round(record.departure_accuracy)}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ marginTop: '0.5rem' }}
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
)}
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,934 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const DEFAULT_FIELD_ORDER = ['street', 'city_postal', 'country', 'company_id', 'vat_id']
const FIELD_LABELS = {
street: 'Ulice',
city_postal: 'Město + PSČ',
country: 'Země',
company_id: 'IČO',
vat_id: 'DIČ',
}
const currentYear = new Date().getFullYear().toString().slice(-2)
export default function CompanySettings() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [logoUrl, setLogoUrl] = useState(null)
const [form, setForm] = useState({
company_name: '',
street: '',
city: '',
postal_code: '',
country: '',
company_id: '',
vat_id: '',
quotation_prefix: 'N',
default_currency: 'EUR',
default_vat_rate: 21,
order_type_code: '71',
invoice_type_code: '81',
})
const [customFields, setCustomFields] = useState([])
const customFieldKeyCounter = useRef(0)
const [fieldOrder, setFieldOrder] = useState([...DEFAULT_FIELD_ORDER])
const [bankAccounts, setBankAccounts] = useState([])
const [bankLoading, setBankLoading] = useState(true)
const [bankSaving, setBankSaving] = useState(false)
const [editingBank, setEditingBank] = useState(null)
const [bankForm, setBankForm] = useState({ account_name: '', bank_name: '', account_number: '', iban: '', bic: '', currency: 'CZK', is_default: false })
const getFullFieldOrder = useCallback(() => {
const allBuiltIn = [...DEFAULT_FIELD_ORDER]
const order = [...fieldOrder].filter(k => k !== 'company_name')
for (const f of allBuiltIn) {
if (!order.includes(f)) order.push(f)
}
for (let i = 0; i < customFields.length; i++) {
const key = `custom_${i}`
if (!order.includes(key)) order.push(key)
}
return order.filter(key => {
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
return idx < customFields.length
}
return true
})
}, [fieldOrder, customFields])
const moveField = (index, direction) => {
const order = getFullFieldOrder()
const newIndex = index + direction
if (newIndex < 0 || newIndex >= order.length) return
const updated = [...order]
;[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]]
setFieldOrder(updated)
}
const getFieldDisplayName = (key) => {
if (FIELD_LABELS[key]) return FIELD_LABELS[key]
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
const cf = customFields[idx]
if (cf) return cf.name ? `${cf.name}: ${cf.value || '...'}` : cf.value || `Vlastní pole ${idx + 1}`
}
return key
}
const fetchLogo = useCallback(async () => {
try {
const resp = await apiFetch(`${API_BASE}/company-settings.php?action=logo`)
if (resp.ok) {
const blob = await resp.blob()
setLogoUrl(prev => {
if (prev) URL.revokeObjectURL(prev)
return URL.createObjectURL(blob)
})
}
} catch {
// ignore - no logo
}
}, [])
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings.php`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const d = result.data
setForm({
company_name: d.company_name || '',
street: d.street || '',
city: d.city || '',
postal_code: d.postal_code || '',
country: d.country || '',
company_id: d.company_id || '',
vat_id: d.vat_id || '',
quotation_prefix: d.quotation_prefix || 'N',
default_currency: d.default_currency || 'EUR',
default_vat_rate: d.default_vat_rate || 21,
order_type_code: d.order_type_code || '71',
invoice_type_code: d.invoice_type_code || '81',
})
const cf = Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map(f => ({ ...f, _key: `cf-${++customFieldKeyCounter.current}` }))
: []
setCustomFields(cf)
if (Array.isArray(d.supplier_field_order) && d.supplier_field_order.length > 0) {
setFieldOrder(d.supplier_field_order)
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER])
}
if (d.has_logo) {
fetchLogo()
}
} else {
alert.error(result.error || 'Nepodařilo se načíst nastavení')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert, fetchLogo])
const fetchBankAccounts = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/bank-accounts.php`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setBankAccounts(result.data)
}
} catch {
// ignore
} finally {
setBankLoading(false)
}
}, [])
const resetBankForm = () => {
setEditingBank(null)
setBankForm({ account_name: '', bank_name: '', account_number: '', iban: '', bic: '', currency: 'CZK', is_default: false })
}
const handleBankSave = async () => {
if (!bankForm.account_name.trim()) {
alert.error('Název účtu je povinný')
return
}
setBankSaving(true)
try {
const isEdit = editingBank !== null
const url = isEdit ? `${API_BASE}/bank-accounts.php?id=${editingBank}` : `${API_BASE}/bank-accounts.php`
const response = await apiFetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bankForm)
})
const result = await response.json()
if (result.success) {
alert.success(result.message)
resetBankForm()
fetchBankAccounts()
} else {
alert.error(result.error || 'Chyba při ukládání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setBankSaving(false)
}
}
const handleBankDelete = async (id) => {
if (!confirm('Opravdu smazat tento bankovní účet?')) return
try {
const response = await apiFetch(`${API_BASE}/bank-accounts.php?id=${id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
alert.success(result.message)
if (editingBank === id) resetBankForm()
fetchBankAccounts()
} else {
alert.error(result.error || 'Chyba při mazání')
}
} catch {
alert.error('Chyba připojení')
}
}
const startEditBank = (account) => {
setEditingBank(account.id)
setBankForm({
account_name: account.account_name || '',
bank_name: account.bank_name || '',
account_number: account.account_number || '',
iban: account.iban || '',
bic: account.bic || '',
currency: account.currency || 'CZK',
is_default: !!account.is_default
})
}
useEffect(() => {
fetchData()
fetchBankAccounts()
}, [fetchData, fetchBankAccounts])
const handleSave = async () => {
setSaving(true)
try {
const payload = {
...form,
custom_fields: customFields.filter(f => f.name.trim() || f.value.trim()),
supplier_field_order: getFullFieldOrder(),
}
const response = await apiFetch(`${API_BASE}/company-settings.php`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Nastavení bylo uloženo')
} else {
alert.error(result.error || 'Nepodařilo se uložit nastavení')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleLogoUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
setUploadingLogo(true)
try {
const formData = new FormData()
formData.append('logo', file)
const response = await apiFetch(`${API_BASE}/company-settings.php?action=logo`, {
method: 'POST',
body: formData
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Logo bylo nahráno')
fetchLogo()
} else {
alert.error(result.error || 'Nepodařilo se nahrát logo')
}
} catch {
alert.error('Chyba připojení')
} finally {
setUploadingLogo(false)
e.target.value = ''
}
}
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
}
if (!hasPermission('offers.settings')) return <Forbidden />
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1.25rem' }}>
{[0, 1, 2, 3, 4, 5].map(i => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-line h-8" style={{ width: '60%' }} />
{[0, 1, 2].map(j => (
<div key={j} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
const fullFieldOrder = getFullFieldOrder()
function renderBankButtonContent() {
if (bankSaving) {
return <><div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />Ukládání...</>
}
if (editingBank !== null) return 'Uložit změny'
return (
<>
<svg width="14" height="14" 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>
Přidat účet
</>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Nastavení firmy</h1>
<p className="admin-page-subtitle">Firemní údaje, číslování dokladů a výchozí hodnoty</p>
</div>
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (
<>
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
Ukládání...
</>
) : 'Uložit nastavení'}
</button>
</motion.div>
<div className="offers-settings-grid">
{/* Company Info */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Firemní údaje</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Název firmy</label>
<input
type="text"
value={form.company_name}
onChange={(e) => updateField('company_name', e.target.value)}
className="admin-form-input"
/>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Ulice</label>
<input
type="text"
value={form.street}
onChange={(e) => updateField('street', e.target.value)}
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Město</label>
<input
type="text"
value={form.city}
onChange={(e) => updateField('city', e.target.value)}
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">PSČ</label>
<input
type="text"
value={form.postal_code}
onChange={(e) => updateField('postal_code', e.target.value)}
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Země</label>
<input
type="text"
value={form.country}
onChange={(e) => updateField('country', e.target.value)}
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">IČO</label>
<input
type="text"
value={form.company_id}
onChange={(e) => updateField('company_id', e.target.value)}
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DIČ</label>
<input
type="text"
value={form.vat_id}
onChange={(e) => updateField('vat_id', e.target.value)}
className="admin-form-input"
/>
</div>
</div>
<div style={{ marginTop: 4 }}>
<label className="admin-form-label" style={{ display: 'block', marginBottom: 4 }}>Vlastní pole</label>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div className="admin-form-row" style={{ marginBottom: 0, alignItems: 'flex-end' }}>
<div className="admin-form-group" style={{ flex: 1 }}>
{idx === 0 && <label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Název</label>}
<input
type="text"
value={field.name}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], name: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
placeholder="Např. Tel."
/>
</div>
<div className="admin-form-group" style={{ flex: 1 }}>
{idx === 0 && <label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Hodnota</label>}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="text"
value={field.value}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], value: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const key = `custom_${idx}`
setFieldOrder(prev => {
return prev
.filter(k => k !== key)
.map(k => {
if (k.startsWith('custom_')) {
const ki = parseInt(k.split('_')[1])
if (ki > idx) return `custom_${ki - 1}`
}
return k
})
})
setCustomFields(customFields.filter((_, i) => i !== idx))
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
</div>
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
<input
type="checkbox"
checked={field.showLabel !== false}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], showLabel: e.target.checked }
setCustomFields(updated)
}}
/>
<span style={{ fontSize: '0.8rem' }}>Zobrazit název v PDF</span>
</label>
</div>
))}
<button
type="button"
onClick={() => setCustomFields([...customFields, { name: '', value: '', showLabel: true, _key: `cf-${++customFieldKeyCounter.current}` }])}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: '0.85rem' }}
>
<svg width="14" height="14" 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>
Přidat pole
</button>
</div>
</div>
</div>
</motion.div>
{/* Bank Accounts */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.12 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Bankovní účty</h3>
</div>
<div className="admin-card-body">
{bankLoading ? (
<div className="admin-skeleton" style={{ gap: '1rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
) : (
<>
{bankAccounts.length > 0 && (
<div className="admin-table-wrapper" style={{ marginBottom: 16 }}>
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Banka</th>
<th>Číslo účtu</th>
<th>IBAN</th>
<th>BIC/SWIFT</th>
<th>Měna</th>
<th style={{ width: 70 }}>Výchozí</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{bankAccounts.map(acc => (
<tr key={acc.id} style={editingBank === acc.id ? { background: 'var(--bg-tertiary)' } : undefined}>
<td>{acc.account_name}</td>
<td>{acc.bank_name}</td>
<td className="admin-mono">{acc.account_number}</td>
<td className="admin-mono">{acc.iban}</td>
<td className="admin-mono">{acc.bic}</td>
<td>{acc.currency}</td>
<td style={{ textAlign: 'center' }}>
{acc.is_default ? (
<span className="text-accent fw-600"></span>
) : ''}
</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
onClick={() => startEditBank(acc)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<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>
</button>
<button
type="button"
onClick={() => handleBankDelete(acc.id)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ background: 'var(--bg-tertiary)', borderRadius: 'var(--border-radius)', padding: 16 }}>
<h4 className="text-secondary" style={{ margin: '0 0 12px', fontSize: '0.9rem' }}>
{editingBank !== null ? 'Upravit účet' : 'Přidat nový účet'}
</h4>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label required">Název účtu</label>
<input
type="text"
value={bankForm.account_name}
onChange={e => setBankForm(f => ({ ...f, account_name: e.target.value }))}
className="admin-form-input"
placeholder="Např. Hlavní CZK účet"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Název banky</label>
<input
type="text"
value={bankForm.bank_name}
onChange={e => setBankForm(f => ({ ...f, bank_name: e.target.value }))}
className="admin-form-input"
placeholder="Např. MONETA Money Bank, a.s."
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Číslo účtu</label>
<input
type="text"
value={bankForm.account_number}
onChange={e => setBankForm(f => ({ ...f, account_number: e.target.value }))}
className="admin-form-input"
placeholder="123456789/0600"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Měna</label>
<select
value={bankForm.currency}
onChange={e => setBankForm(f => ({ ...f, currency: e.target.value }))}
className="admin-form-select"
>
<option value="CZK">CZK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">IBAN</label>
<input
type="text"
value={bankForm.iban}
onChange={e => setBankForm(f => ({ ...f, iban: e.target.value }))}
className="admin-form-input"
placeholder="CZ65 0800 0000 1920 0014 5399"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">BIC / SWIFT</label>
<input
type="text"
value={bankForm.bic}
onChange={e => setBankForm(f => ({ ...f, bic: e.target.value }))}
className="admin-form-input"
placeholder="GIBACZPX"
/>
</div>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={bankForm.is_default}
onChange={e => setBankForm(f => ({ ...f, is_default: e.target.checked }))}
/>
<span>Výchozí účet (použije se automaticky při vytváření faktury)</span>
</label>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
type="button"
onClick={handleBankSave}
className="admin-btn admin-btn-primary"
disabled={bankSaving}
style={{ fontSize: '0.85rem' }}
>
{renderBankButtonContent()}
</button>
{editingBank !== null && (
<button
type="button"
onClick={resetBankForm}
className="admin-btn admin-btn-secondary"
style={{ fontSize: '0.85rem' }}
>
Zrušit
</button>
)}
</div>
</div>
</div>
</>
)}
</div>
</motion.div>
{/* PDF Field Order */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Pořadí polí dodavatele v PDF</h3>
</div>
<div className="admin-card-body">
<small className="admin-form-hint" style={{ display: 'block', marginBottom: 12 }}>
Určuje pořadí řádků v adresním bloku dodavatele na PDF nabídce.
</small>
<div className="admin-reorder-list">
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button
type="button"
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Nahoru"
aria-label="Nahoru"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button
type="button"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
className="admin-btn-icon"
title="Dolů"
aria-label="Dolů"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
</div>
<span className={`admin-reorder-label${key.startsWith('custom_') ? ' accent' : ''}`}>
{getFieldDisplayName(key)}
</span>
</div>
))}
</div>
</div>
</motion.div>
{/* Logo */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Logo</h3>
</div>
<div className="admin-card-body">
<div className="offers-logo-section">
{logoUrl && (
<div className="offers-logo-preview">
<img src={logoUrl} alt="Logo" />
</div>
)}
<label className="admin-btn admin-btn-secondary" style={{ cursor: 'pointer' }}>
{uploadingLogo ? (
<>
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
Nahrávání...
</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát logo
</>
)}
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
style={{ display: 'none' }}
disabled={uploadingLogo}
/>
</label>
<small className="admin-form-hint">PNG, JPEG, GIF nebo WebP, max 5 MB</small>
</div>
</div>
</motion.div>
{/* Cislovani dokladu */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Číslování dokladů</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
{/* Nabídky */}
<div className="admin-form-group">
<label className="admin-form-label">Nabídky prefix</label>
<input
type="text"
value={form.quotation_prefix}
onChange={(e) => updateField('quotation_prefix', e.target.value)}
className="admin-form-input"
placeholder="N"
style={{ maxWidth: 120 }}
/>
<small className="admin-form-hint">
Formát: ROK/PREFIX/ČÍSLO ukázka: {new Date().getFullYear()}/{form.quotation_prefix || 'N'}/001
</small>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
{/* Objednávky / Projekty */}
<div className="admin-form-group">
<label className="admin-form-label">Objednávky a projekty typový kód</label>
<input
type="text"
value={form.order_type_code}
onChange={(e) => updateField('order_type_code', e.target.value)}
className="admin-form-input"
placeholder="71"
style={{ maxWidth: 120 }}
/>
<small className="admin-form-hint">
Formát: RRKÓD#### ukázka: {currentYear}{form.order_type_code || '71'}0001
</small>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
{/* Faktury */}
<div className="admin-form-group">
<label className="admin-form-label">Faktury typový kód</label>
<input
type="text"
value={form.invoice_type_code}
onChange={(e) => updateField('invoice_type_code', e.target.value)}
className="admin-form-input"
placeholder="81"
style={{ maxWidth: 120 }}
/>
<small className="admin-form-hint">
Formát: RRKÓD#### ukázka: {currentYear}{form.invoice_type_code || '81'}0001
</small>
</div>
</div>
</div>
</motion.div>
{/* Default values */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Výchozí hodnoty</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Výchozí měna</label>
<select
value={form.default_currency}
onChange={(e) => updateField('default_currency', e.target.value)}
className="admin-form-select"
>
<option value="EUR">EUR ()</option>
<option value="USD">USD ($)</option>
<option value="CZK">CZK ()</option>
<option value="GBP">GBP (£)</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Výchozí sazba DPH (%)</label>
<input
type="number"
value={form.default_vat_rate}
onChange={(e) => updateField('default_vat_rate', parseFloat(e.target.value) || 0)}
className="admin-form-input"
step="0.1"
/>
</div>
</div>
</div>
</div>
</motion.div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,807 @@
import { useState, useEffect, useMemo, useRef, useCallback, lazy, Suspense } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker'
import { motion } from 'framer-motion'
import apiFetch from '../utils/api'
import { formatCurrency } from '../utils/formatters'
const RichEditor = lazy(() => import('../components/RichEditor'))
const API_BASE = '/api/admin'
const VAT_OPTIONS = [
{ value: 21, label: '21%' },
{ value: 12, label: '12%' },
{ value: 0, label: '0%' }
]
let _keyCounter = 0
const emptyItem = () => ({
_key: `inv-${++_keyCounter}`,
description: '',
quantity: 1,
unit: 'ks',
unit_price: 0,
vat_rate: 21
})
export default function InvoiceCreate() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const alert = useAlert()
const { hasPermission, user } = useAuth()
const rawOrderId = searchParams.get('fromOrder')
const fromOrderId = rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null
const [form, setForm] = useState({
customer_id: null,
customer_name: '',
order_id: fromOrderId ? Number(fromOrderId) : null,
issue_date: new Date().toISOString().split('T')[0],
due_date: new Date(Date.now() + 14 * 86400000).toISOString().split('T')[0],
tax_date: new Date().toISOString().split('T')[0],
currency: 'CZK',
apply_vat: 1,
vat_rate: 21,
payment_method: 'Příkazem',
constant_symbol: '0308',
issued_by: user?.fullName || '',
notes: '',
bank_account_id: '',
bank_name: '',
bank_swift: '',
bank_iban: '',
bank_account: ''
})
const [bankAccounts, setBankAccounts] = useState([])
const [dueDays, setDueDays] = useState(14)
const [items, setItems] = useState([emptyItem()])
const [errors, setErrors] = useState({})
const [saving, setSaving] = useState(false)
const [loadingInit, setLoadingInit] = useState(true)
const [invoiceNumber, setInvoiceNumber] = useState('')
// Customer selector
const [customers, setCustomers] = useState([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
// Draft (jen rucni faktury, ne z objednavky)
const DRAFT_KEY = 'boha_invoice_draft'
const isManual = !fromOrderId
const [draftSavedAt, setDraftSavedAt] = useState(null)
const draftDataRef = useRef({ form, items, dueDays: 14 })
const draftRestoredRef = useRef(false)
// Obnovit draft
useEffect(() => {
if (!isManual) return
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return
const draft = JSON.parse(raw)
if (!draft || typeof draft !== 'object' || !draft.form || !Array.isArray(draft.items)) {
localStorage.removeItem(DRAFT_KEY)
return
}
const { form: dForm, items: dItems, savedAt } = draft
setForm(prev => ({
...prev,
customer_id: dForm.customer_id ?? prev.customer_id,
customer_name: dForm.customer_name ?? prev.customer_name,
currency: dForm.currency ?? prev.currency,
apply_vat: dForm.apply_vat ?? prev.apply_vat,
payment_method: dForm.payment_method ?? prev.payment_method,
constant_symbol: dForm.constant_symbol ?? prev.constant_symbol,
issued_by: prev.issued_by,
notes: dForm.notes ?? prev.notes,
issue_date: dForm.issue_date ?? prev.issue_date,
due_date: dForm.due_date ?? prev.due_date,
tax_date: dForm.tax_date ?? prev.tax_date,
bank_account_id: dForm.bank_account_id ?? prev.bank_account_id,
bank_name: dForm.bank_name ?? prev.bank_name,
bank_swift: dForm.bank_swift ?? prev.bank_swift,
bank_iban: dForm.bank_iban ?? prev.bank_iban,
bank_account: dForm.bank_account ?? prev.bank_account
}))
if (dItems.length > 0) setItems(dItems.map(i => ({ ...i, _key: i._key || `inv-${++_keyCounter}` })))
if (draft.due_days) setDueDays(Number(draft.due_days))
draftRestoredRef.current = true
if (savedAt) setDraftSavedAt(new Date(savedAt))
} catch {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
}
}, [isManual])
useEffect(() => {
draftDataRef.current = { form, items, dueDays }
}, [form, items, dueDays])
// Auto-save draft
useEffect(() => {
if (!isManual) return
const timer = setTimeout(() => {
try {
const { form: f, items: it, dueDays: dd } = draftDataRef.current
const { bank_name: _bn, bank_swift: _bs, bank_iban: _bi, bank_account: _ba, ...safeDraftForm } = f
const savedAt = new Date().toISOString()
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: safeDraftForm, items: it, due_days: dd, savedAt }))
setDraftSavedAt(new Date(savedAt))
} catch { /* ignore */ }
}, 500)
return () => clearTimeout(timer)
}, [form, items, isManual])
const clearDraft = useCallback(() => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraftSavedAt(null)
}, [])
const draftSavedAtLabel = useMemo(() => {
if (!draftSavedAt) return null
return draftSavedAt.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}, [draftSavedAt])
// Nacteni dat
useEffect(() => {
const load = async () => {
try {
const promises = [
apiFetch(`${API_BASE}/invoices.php?action=next_number`),
apiFetch(`${API_BASE}/customers.php`),
apiFetch(`${API_BASE}/bank-accounts.php`)
]
if (fromOrderId) {
promises.push(apiFetch(`${API_BASE}/invoices.php?action=order_data&id=${fromOrderId}`))
}
const results = await Promise.all(promises)
const numRes = results[0]
if (numRes.ok) {
const numData = await numRes.json()
if (numData.success) {
setInvoiceNumber(numData.data.number)
}
}
const custRes = results[1]
if (custRes.ok) {
const custData = await custRes.json()
if (custData.success) {
setCustomers(custData.data.customers)
}
}
// Bankovni ucty
const bankRes = results[2]
if (bankRes.ok) {
const bankData = await bankRes.json()
if (bankData.success && Array.isArray(bankData.data)) {
setBankAccounts(bankData.data)
const defaultAcc = bankData.data.find(a => a.is_default)
setForm(prev => {
// Draft ma ulozene bank_account_id - najdi ucet a dopln detaily
const draftId = prev.bank_account_id
const matched = draftId
? bankData.data.find(a => String(a.id) === String(draftId))
: null
const acc = matched || defaultAcc
if (!acc) {
return prev
}
return {
...prev,
bank_account_id: acc.id,
bank_name: acc.bank_name || '',
bank_swift: acc.bic || '',
bank_iban: acc.iban || '',
bank_account: acc.account_number || ''
}
})
}
}
// Pre-fill z objednavky
if (fromOrderId && results[3]?.ok) {
const orderData = await results[3].json()
if (orderData.success) {
const order = orderData.data
const vatRate = Number(order.vat_rate) || 21
setForm(prev => ({
...prev,
customer_id: order.customer_id,
customer_name: order.customer_name || '',
order_id: order.id,
currency: order.currency || 'CZK',
apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate
}))
if (order.items?.length > 0) {
setItems(order.items.map(item => ({
_key: `inv-${++_keyCounter}`,
description: item.description || '',
quantity: Number(item.quantity) || 1,
unit: item.unit || '',
unit_price: Number(item.unit_price) || 0,
vat_rate: vatRate
})))
}
}
}
} catch {
alert.error('Chyba při načítání dat')
} finally {
setLoadingInit(false)
}
}
load()
}, [fromOrderId]) // eslint-disable-line react-hooks/exhaustive-deps
// Vypocet due_date z issue_date + dueDays
useEffect(() => {
if (!form.issue_date) return
const d = new Date(form.issue_date)
d.setDate(d.getDate() + dueDays)
setForm(prev => ({ ...prev, due_date: d.toISOString().split('T')[0] }))
}, [form.issue_date, dueDays])
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [showCustomerDropdown])
const selectBankAccount = (accountId) => {
const acc = bankAccounts.find(a => a.id === Number(accountId))
if (acc) {
setForm(prev => ({
...prev,
bank_account_id: acc.id,
bank_name: acc.bank_name || '',
bank_swift: acc.bic || '',
bank_iban: acc.iban || '',
bank_account: acc.account_number || ''
}))
} else {
setForm(prev => ({
...prev,
bank_account_id: '',
bank_name: '',
bank_swift: '',
bank_iban: '',
bank_account: ''
}))
}
}
const selectCustomer = (customer) => {
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
setErrors(prev => ({ ...prev, customer_id: undefined }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
// Items management
const updateItem = (index, field, value) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addItem = () => setItems(prev => [...prev, emptyItem()])
const removeItem = (index) => {
if (items.length <= 1) return
setItems(prev => prev.filter((_, i) => i !== index))
}
const moveItem = (index, direction) => {
setItems(prev => {
const newItems = [...prev]
const target = index + direction
if (target < 0 || target >= newItems.length) return prev
;[newItems[index], newItems[target]] = [newItems[target], newItems[index]]
return newItems
})
}
// Totals
const totals = useMemo(() => {
let subtotal = 0
const vatByRate = {}
items.forEach(item => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
subtotal += lineTotal
if (form.apply_vat) {
const rate = Number(item.vat_rate) || 0
if (!vatByRate[rate]) vatByRate[rate] = 0
vatByRate[rate] += lineTotal * rate / 100
}
})
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0)
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }
}, [items, form.apply_vat])
const handleSubmit = async (e) => {
e.preventDefault()
const newErrors = {}
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
if (!form.issue_date) newErrors.issue_date = 'Zadejte datum'
if (!form.tax_date) newErrors.tax_date = 'Zadejte datum'
if (!form.bank_account_id) newErrors.bank_account_id = 'Vyberte bankovní účet'
if (items.length === 0 || items.every(i => !i.description.trim())) {
newErrors.items = 'Přidejte alespoň jednu položku'
}
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/invoices.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...form,
invoice_number: invoiceNumber,
items: items.filter(i => i.description.trim()).map((item, i) => ({
...item,
position: i
}))
})
})
const result = await response.json()
if (result.success) {
clearDraft()
alert.success(result.message || 'Faktura byla vytvořena')
navigate(`/invoices/${result.data.invoice_id}`)
} else {
alert.error(result.error || 'Nepodařilo se vytvořit fakturu')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
if (!hasPermission('invoices.create')) return <Forbidden />
if (loadingInit) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/invoices" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
Nová faktura {invoiceNumber && <span className="text-tertiary">({invoiceNumber})</span>}
</h1>
{fromOrderId ? (
<p className="admin-page-subtitle">Z objednávky</p>
) : draftSavedAtLabel && (
<div className="offers-draft-indicator">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
Koncept uložen {draftSavedAtLabel}
</div>
)}
</div>
</div>
<div className="admin-page-actions">
<button onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (
<>
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
Ukládání...
</>
) : 'Uložit'}
</button>
</div>
</motion.div>
<form onSubmit={handleSubmit}>
{/* Zakaznik + zakladni udaje */}
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="offers-form-row-3">
<div className="admin-form-group">
<label className="admin-form-label">Číslo faktury</label>
<input
type="text"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
className="admin-form-input"
/>
</div>
<div className={`admin-form-group${errors.customer_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Odběratel</label>
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
<button
type="button"
onClick={() => setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))}
className="admin-btn-icon"
title="Odebrat zákazníka"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
) : (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value)
setShowCustomerDropdown(true)
}}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka (název, IČ, město)..."
autoComplete="off"
/>
{showCustomerDropdown && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">Žádní zákazníci</div>
) : (
filteredCustomers.slice(0, 10).map(c => (
<div
key={c.id}
className="offers-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>
{(c.company_id || c.city) && (
<div>
{c.company_id && `IČ: ${c.company_id}`}
{c.city && ` · ${c.city}`}
</div>
)}
</div>
))
)}
</div>
)}
</div>
)}
{errors.customer_id && <span className="admin-form-error">{errors.customer_id}</span>}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vystavil</label>
<input
type="text"
value={form.issued_by}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/>
</div>
</div>
<div className="admin-form-row">
<div className={`admin-form-group${errors.issue_date ? ' has-error' : ''}`}>
<label className="admin-form-label required">Datum vystavení</label>
<AdminDatePicker
mode="date"
value={form.issue_date}
onChange={(val) => {
setForm(prev => ({ ...prev, issue_date: val }))
setErrors(prev => ({ ...prev, issue_date: undefined }))
}}
/>
{errors.issue_date && <span className="admin-form-error">{errors.issue_date}</span>}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Splatnost (dny)</label>
<select
value={dueDays}
onChange={(e) => setDueDays(Number(e.target.value))}
className="admin-form-select"
>
{Array.from({ length: 60 }, (_, i) => i + 1).map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
{form.due_date && (
<span className="text-tertiary" style={{ fontSize: '0.75rem', marginTop: '0.25rem' }}>
Splatnost: {new Date(form.due_date).toLocaleDateString('cs-CZ')}
</span>
)}
</div>
<div className={`admin-form-group${errors.tax_date ? ' has-error' : ''}`}>
<label className="admin-form-label required">DÚZP</label>
<AdminDatePicker
mode="date"
value={form.tax_date}
onChange={(val) => {
setForm(prev => ({ ...prev, tax_date: val }))
setErrors(prev => ({ ...prev, tax_date: undefined }))
}}
/>
{errors.tax_date && <span className="admin-form-error">{errors.tax_date}</span>}
</div>
</div>
<div className="offers-form-row-3">
<div className="admin-form-group">
<label className="admin-form-label">Forma úhrady</label>
<select
value={form.payment_method}
onChange={(e) => setForm(prev => ({ ...prev, payment_method: e.target.value }))}
className="admin-form-select"
>
<option value="Příkazem">Příkazem</option>
<option value="Hotově">Hotově</option>
<option value="Dobírka">Dobírka</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Měna</label>
<select
value={form.currency}
onChange={(e) => setForm(prev => ({ ...prev, currency: e.target.value }))}
className="admin-form-select"
>
<option value="CZK">CZK ()</option>
<option value="EUR">EUR ()</option>
<option value="USD">USD ($)</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DPH</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<label className="admin-form-checkbox" style={{ whiteSpace: 'nowrap' }}>
<input
type="checkbox"
checked={!!form.apply_vat}
onChange={(e) => setForm(prev => ({ ...prev, apply_vat: e.target.checked ? 1 : 0 }))}
/>
<span>Uplatnit DPH</span>
</label>
</div>
</div>
</div>
<div className={`admin-form-group${errors.bank_account_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Bankovní účet</label>
<select
value={form.bank_account_id}
onChange={(e) => {
selectBankAccount(e.target.value)
setErrors(prev => ({ ...prev, bank_account_id: undefined }))
}}
className="admin-form-select"
>
<option value=""> Vyberte účet </option>
{bankAccounts.map(acc => (
<option key={acc.id} value={acc.id}>
{acc.account_name}{acc.account_number ? ` (${acc.account_number})` : ''}{acc.is_default ? ' ★' : ''}
</option>
))}
</select>
{errors.bank_account_id && <span className="admin-form-error">{errors.bank_account_id}</span>}
</div>
</div>
</motion.div>
{/* Polozky */}
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div>
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
{errors.items && <span className="admin-form-error">{errors.items}</span>}
</div>
<button type="button" onClick={addItem} className="admin-btn admin-btn-primary admin-btn-sm">
+ Přidat položku
</button>
</div>
<div className="offers-items-table">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item._key || index}>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<input
type="text"
value={item.description}
onChange={(e) => updateItem(index, 'description', e.target.value)}
className="admin-form-input"
placeholder="Popis položky..."
style={{ fontWeight: 500 }}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
className="admin-form-input"
min="0"
step="any"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => updateItem(index, 'unit', e.target.value)}
className="admin-form-input"
placeholder="ks"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
className="admin-form-input"
step="any"
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
/>
</td>
{form.apply_vat ? (
<td>
<select
value={item.vat_rate}
onChange={(e) => updateItem(index, 'vat_rate', Number(e.target.value))}
className="admin-form-input"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
>
{VAT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</td>
) : null}
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
{formatCurrency(lineTotal, form.currency)}
</td>
<td>
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
{items.length > 1 && (
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Soucty */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, form.currency)}</span>
</div>
{form.apply_vat && Object.entries(totals.vatByRate).map(([rate, amount]) => (
<div key={rate} className="offers-totals-row">
<span>DPH {rate}%:</span>
<span>{formatCurrency(amount, form.currency)}</span>
</div>
))}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, form.currency)}</span>
</div>
</div>
</motion.div>
{/* Poznamky */}
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
>
<h3 className="admin-card-title">Veřejné poznámky na faktuře</h3>
<Suspense fallback={<div className="admin-form-input" style={{ minHeight: 120 }} />}>
<RichEditor
value={form.notes}
onChange={(html) => setForm(prev => ({ ...prev, notes: html }))}
placeholder="Poznámky zobrazené na faktuře..."
minHeight="120px"
/>
</Suspense>
</motion.div>
</form>
</div>
)
}

View File

@@ -0,0 +1,740 @@
import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import DOMPurify from 'dompurify'
const RichEditor = lazy(() => import('../components/RichEditor'))
import { formatCurrency, formatDate } from '../utils/formatters'
const API_BASE = '/api/admin'
const STATUS_LABELS = {
issued: 'Vystavena',
paid: 'Zaplacena',
overdue: 'Po splatnosti'
}
const STATUS_CLASSES = {
issued: 'admin-badge-invoice-issued',
paid: 'admin-badge-invoice-paid',
overdue: 'admin-badge-invoice-overdue'
}
const TRANSITION_LABELS = {
paid: 'Zaplaceno'
}
const TRANSITION_CLASSES = {
paid: 'admin-btn admin-btn-primary'
}
const VAT_OPTIONS = [
{ value: 21, label: '21%' },
{ value: 12, label: '12%' },
{ value: 0, label: '0%' }
]
export default function InvoiceDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [invoice, setInvoice] = useState(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState(null)
const [statusConfirm, setStatusConfirm] = useState({ show: false, status: null })
const [pdfLoading, setPdfLoading] = useState(false)
const [langModal, setLangModal] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
// Editace polozek (jen draft)
const [editingItems, setEditingItems] = useState(false)
const [editItems, setEditItems] = useState([])
const editKeyCounter = useRef(0)
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/invoices.php?action=detail&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setInvoice(result.data)
setNotes(result.data.notes || '')
} else {
alert.error(result.error || 'Nepodařilo se načíst fakturu')
navigate('/invoices')
}
} catch {
alert.error('Chyba připojení')
navigate('/invoices')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchDetail()
}, [id]) // eslint-disable-line react-hooks/exhaustive-deps
const totals = useMemo(() => {
if (!invoice?.items) return { subtotal: 0, vatByRate: {}, totalVat: 0, total: 0 }
let subtotal = 0
const vatByRate = {}
invoice.items.forEach(item => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
subtotal += lineTotal
if (Number(invoice.apply_vat)) {
const rate = Number(item.vat_rate) || 0
if (!vatByRate[rate]) vatByRate[rate] = 0
vatByRate[rate] += lineTotal * rate / 100
}
})
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0)
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }
}, [invoice])
if (!hasPermission('invoices.view')) return <Forbidden />
const handleStatusChange = async () => {
if (!statusConfirm.status) return
setStatusChanging(statusConfirm.status)
setStatusConfirm({ show: false, status: null })
try {
const response = await apiFetch(`${API_BASE}/invoices.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: statusConfirm.status })
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Stav byl změněn')
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
} finally {
setStatusChanging(null)
}
}
const handleSaveNotes = async () => {
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/invoices.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: notes })
})
const result = await response.json()
if (result.success) {
alert.success('Poznámky byly uloženy')
} else {
alert.error(result.error || 'Nepodařilo se uložit poznámky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleViewPdf = async (lang = 'cs') => {
setLangModal(false)
const newWindow = window.open('', '_blank')
setPdfLoading(true)
try {
const response = await apiFetch(`${API_BASE}/invoices-pdf.php?id=${id}&lang=${encodeURIComponent(lang)}`)
if (!response.ok) {
newWindow.close()
alert.error('Nepodařilo se vygenerovat PDF')
return
}
const html = await response.text()
newWindow.document.open()
newWindow.document.write(html)
newWindow.document.close()
newWindow.onload = () => newWindow.print()
} catch {
newWindow.close()
alert.error('Chyba připojení')
} finally {
setPdfLoading(false)
}
}
// Editace polozek
const startEditItems = () => {
setEditItems(invoice.items.map(item => ({
_key: `ei-${++editKeyCounter.current}`,
description: item.description || '',
quantity: Number(item.quantity) || 1,
unit: item.unit || '',
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || 21
})))
setEditingItems(true)
}
const updateEditItem = (index, field, value) => {
setEditItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addEditItem = () => {
setEditItems(prev => [...prev, { _key: `ei-${++editKeyCounter.current}`, description: '', quantity: 1, unit: 'ks', unit_price: 0, vat_rate: 21 }])
}
const removeEditItem = (index) => {
if (editItems.length <= 1) return
setEditItems(prev => prev.filter((_, i) => i !== index))
}
const saveEditItems = async () => {
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/invoices.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: editItems.filter(i => i.description.trim()).map((item, i) => ({
...item,
position: i
}))
})
})
const result = await response.json()
if (result.success) {
alert.success('Položky byly uloženy')
setEditingItems(false)
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se uložit položky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/invoices.php?id=${id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Faktura byla smazána')
navigate('/invoices')
} else {
alert.error(result.error || 'Nepodařilo se smazat fakturu')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
setDeleteConfirm(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-full" /></div>
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-3/4" /></div>
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-1/2" /></div>
</div>
))}
</div>
</div>
</div>
)
}
if (!invoice) return null
const isDraft = invoice.status === 'issued'
const isPaid = invoice.status === 'paid'
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/invoices" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
Faktura {invoice.invoice_number}
<span className={`admin-badge ${STATUS_CLASSES[invoice.status] || ''}`}>
{STATUS_LABELS[invoice.status] || invoice.status}
</span>
</h1>
</div>
</div>
<div className="admin-page-actions">
{hasPermission('invoices.export') && (
<button
onClick={() => setLangModal(true)}
className="admin-btn admin-btn-secondary"
disabled={pdfLoading}
>
{pdfLoading ? (
<>
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
PDF...
</>
) : (
<>
<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>
PDF
</>
)}
</button>
)}
{hasPermission('invoices.edit') && invoice.valid_transitions?.length > 0 && (
invoice.valid_transitions.map(status => (
<button
key={status}
onClick={() => setStatusConfirm({ show: true, status })}
className={TRANSITION_CLASSES[status] || 'admin-btn admin-btn-secondary'}
disabled={statusChanging === status}
>
{statusChanging === status ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
) : (
TRANSITION_LABELS[status] || status
)}
</button>
))
)}
{hasPermission('invoices.delete') && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
</motion.div>
{/* Info */}
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form">
<div className="offers-form-row-3" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Zákazník</label>
<div style={{ fontWeight: 500 }}>{invoice.customer_name || '—'}</div>
{invoice.customer && (
<div className="text-tertiary" style={{ fontSize: '0.8rem', marginTop: '0.2rem' }}>
{invoice.customer.company_id && `IČ: ${invoice.customer.company_id}`}
{invoice.customer.vat_id && ` · DIČ: ${invoice.customer.vat_id}`}
</div>
)}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Objednávka</label>
<div>
{invoice.order_id ? (
<Link to={`/orders/${invoice.order_id}`} className="link-accent">
{invoice.order_number}
</Link>
) : '—'}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Měna</label>
<div>{invoice.currency}</div>
</div>
</div>
<div className="offers-form-row-3" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Datum vystavení</label>
<div>{formatDate(invoice.issue_date)}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum splatnosti</label>
<div className={invoice.status === 'overdue' ? 'text-danger fw-600' : ''}>
{formatDate(invoice.due_date)}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DÚZP</label>
<div>{formatDate(invoice.tax_date)}</div>
</div>
</div>
<div className="offers-form-row-3">
<div className="admin-form-group">
<label className="admin-form-label">Forma úhrady</label>
<div>{invoice.payment_method}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Variabilní symbol</label>
<div>{invoice.invoice_number}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vystavil</label>
<div>{invoice.issued_by || '—'}</div>
</div>
</div>
{invoice.paid_date && (
<div className="admin-form-row" style={{ marginTop: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Datum úhrady</label>
<div style={{ color: 'var(--success)', fontWeight: 500 }}>{formatDate(invoice.paid_date)}</div>
</div>
</div>
)}
</div>
</motion.div>
{/* Polozky */}
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
{isDraft && hasPermission('invoices.edit') && (
editingItems ? (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" onClick={addEditItem} className="admin-btn admin-btn-secondary admin-btn-sm">
+ Přidat položku
</button>
<button onClick={saveEditItems} className="admin-btn admin-btn-primary admin-btn-sm" disabled={saving}>
{saving ? 'Ukládání...' : 'Uložit položky'}
</button>
<button onClick={() => setEditingItems(false)} className="admin-btn admin-btn-secondary admin-btn-sm">
Zrušit
</button>
</div>
) : (
<button onClick={startEditItems} className="admin-btn admin-btn-secondary admin-btn-sm">
Upravit položky
</button>
)
)}
</div>
{editingItems ? (
<>
<div className="offers-items-table">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
<th style={{ width: '5rem', textAlign: 'center' }}>%DPH</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}></th>
</tr>
</thead>
<tbody>
{editItems.map((item, index) => (
<tr key={item._key}>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<input
type="text"
value={item.description}
onChange={(e) => updateEditItem(index, 'description', e.target.value)}
className="admin-form-input"
placeholder="Popis položky..."
style={{ fontWeight: 500 }}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) => updateEditItem(index, 'quantity', e.target.value)}
className="admin-form-input"
min="0"
step="any"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => updateEditItem(index, 'unit', e.target.value)}
className="admin-form-input"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) => updateEditItem(index, 'unit_price', e.target.value)}
className="admin-form-input"
step="any"
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
/>
</td>
<td>
{Number(invoice.apply_vat) ? (
<select
value={item.vat_rate}
onChange={(e) => updateEditItem(index, 'vat_rate', Number(e.target.value))}
className="admin-form-input"
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
>
{VAT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : (
<span className="text-tertiary" style={{ display: 'block', textAlign: 'center' }}>0%</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
{editItems.length > 1 && (
<button type="button" onClick={() => removeEditItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<>
{invoice.items?.length > 0 ? (
<div className="offers-items-table">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '8rem', textAlign: 'right' }}>Jedn. cena</th>
<th style={{ width: '4rem', textAlign: 'center' }}>%DPH</th>
<th style={{ width: '9rem', textAlign: 'right' }}>Celkem</th>
</tr>
</thead>
<tbody>
{invoice.items.map((item, index) => {
const lineSubtotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
const lineVat = Number(invoice.apply_vat) ? lineSubtotal * (Number(item.vat_rate) || 0) / 100 : 0
return (
<tr key={item.id || index}>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td style={{ fontWeight: 500 }}>{item.description || '—'}</td>
<td style={{ textAlign: 'center' }}>{item.quantity} {item.unit && <span className="text-tertiary">{item.unit}</span>}</td>
<td style={{ textAlign: 'center' }}>{item.unit || '—'}</td>
<td className="admin-mono" style={{ textAlign: 'right' }}>{formatCurrency(item.unit_price, invoice.currency)}</td>
<td style={{ textAlign: 'center' }}>{Number(invoice.apply_vat) ? Number(item.vat_rate) : 0}%</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600 }}>{formatCurrency(lineSubtotal + lineVat, invoice.currency)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p className="text-tertiary">Žádné položky.</p>
)}
</>
)}
{/* Soucty */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, invoice.currency)}</span>
</div>
{Number(invoice.apply_vat) > 0 && Object.entries(totals.vatByRate).map(([rate, amount]) => (
<div key={rate} className="offers-totals-row">
<span>DPH {rate}%:</span>
<span>{formatCurrency(amount, invoice.currency)}</span>
</div>
))}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, invoice.currency)}</span>
</div>
</div>
</motion.div>
{/* Poznamky */}
<motion.div
className="offers-editor-section"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<h3 className="admin-card-title">Veřejné poznámky na faktuře</h3>
{isPaid && notes && notes.trim() && notes !== '<p><br></p>' && (
<div
className="ql-editor"
style={{ padding: 0, minHeight: 'auto' }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }}
/>
)}
{isPaid && (!notes || !notes.trim() || notes === '<p><br></p>') && (
<p className="text-tertiary">Žádné poznámky.</p>
)}
{!isPaid && (
<>
<Suspense fallback={<div className="admin-form-input" style={{ minHeight: 120 }} />}>
<RichEditor
value={notes}
onChange={(html) => setNotes(html)}
placeholder="Poznámky zobrazené na faktuře..."
minHeight="120px"
/>
</Suspense>
{hasPermission('invoices.edit') && (
<div style={{ marginTop: '0.5rem' }}>
<button
onClick={handleSaveNotes}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={saving}
>
{saving ? 'Ukládání...' : 'Uložit poznámky'}
</button>
</div>
)}
</>
)}
</motion.div>
{/* Status change confirm */}
<ConfirmModal
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav faktury"
message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${STATUS_LABELS[statusConfirm.status]}"?`}
confirmText={TRANSITION_LABELS[statusConfirm.status] || 'Potvrdit'}
cancelText="Zrušit"
type="default"
/>
{/* Delete confirm */}
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${invoice.invoice_number}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
{/* Language selection for PDF */}
<AnimatePresence>
{langModal && (
<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={() => setLangModal(false)} />
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
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-info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h2 className="admin-confirm-title">Jazyk faktury</h2>
<p className="admin-confirm-message">V jakém jazyce chcete vygenerovat fakturu?</p>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => handleViewPdf('cs')} className="admin-btn admin-btn-primary">
Čeština
</button>
<button type="button" onClick={() => handleViewPdf('en')} className="admin-btn admin-btn-primary">
English
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,686 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
const ReceivedInvoices = lazy(() => import('./ReceivedInvoices'))
const API_BASE = '/api/admin'
const DRAFT_KEY = 'boha_invoice_draft'
const MONTH_NAMES = [
'leden', 'únor', 'březen', 'duben', 'květen', 'červen',
'červenec', 'srpen', 'září', 'říjen', 'listopad', 'prosinec'
]
function formatMultiCurrency(amounts) {
if (!amounts || amounts.length === 0) return '0 Kč'
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
}
function formatCzkWithDetail(amounts, totalCzk) {
if (!amounts || amounts.length === 0) return { value: '0 Kč', detail: null }
const hasForeign = amounts.some(a => a.currency !== 'CZK')
if (hasForeign && totalCzk !== null && totalCzk !== undefined) {
return {
value: formatCurrency(totalCzk, 'CZK'),
detail: formatMultiCurrency(amounts),
}
}
return { value: formatMultiCurrency(amounts), detail: null }
}
const STATUS_LABELS = {
issued: 'Vystavena',
paid: 'Zaplacena',
overdue: 'Po splatnosti'
}
const STATUS_CLASSES = {
issued: 'admin-badge-invoice-issued',
paid: 'admin-badge-invoice-paid',
overdue: 'admin-badge-invoice-overdue'
}
const STATUS_FILTERS = [
{ value: '', label: 'Vše' },
{ value: 'issued', label: 'Vystavené' },
{ value: 'paid', label: 'Zaplacené' },
{ value: 'overdue', label: 'Po splatnosti' }
]
export default function Invoices() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [activeTab, setActiveTab] = useState('issued')
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false)
const { sort, order, handleSort, activeSort } = useTableSort('invoice_number')
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const now = new Date()
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1)
const [statsYear, setStatsYear] = useState(now.getFullYear())
const [stats, setStats] = useState(null)
const [statsLoading, setStatsLoading] = useState(true)
const hasLoadedOnce = useRef(false)
const slideDirection = useRef(0)
const [slideKey, setSlideKey] = useState(0)
const isCurrentMonth = statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear()
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`
const fetchStats = useCallback(async () => {
setStatsLoading(true)
try {
const res = await apiFetch(`${API_BASE}/invoices.php?action=stats&month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
setSlideKey(k => k + 1)
}
} catch { /* ignore */ } finally {
setStatsLoading(false)
}
}, [statsMonth, statsYear])
useEffect(() => { fetchStats() }, [fetchStats])
const prevMonth = () => {
slideDirection.current = -1
if (statsMonth === 1) {
setStatsMonth(12)
setStatsYear(y => y - 1)
} else {
setStatsMonth(m => m - 1)
}
}
const nextMonth = () => {
if (isCurrentMonth) return
slideDirection.current = 1
if (statsMonth === 12) {
setStatsMonth(1)
setStatsYear(y => y + 1)
} else {
setStatsMonth(m => m + 1)
}
}
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, invoice: null })
const [deleting, setDeleting] = useState(false)
const [pdfLoading, setPdfLoading] = useState(null)
const [langModal, setLangModal] = useState(null)
const [draft, setDraft] = useState(null)
useEffect(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
if (parsed && parsed.form && Array.isArray(parsed.items)) {
setDraft(parsed)
}
} catch { /* ignore */ }
}, [])
const discardDraft = () => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraft(null)
}
const { items: invoices, loading, refetch: fetchData } = useListData('invoices.php', {
dataKey: 'invoices', search, sort, order,
extraParams: statusFilter ? { status: statusFilter } : {},
errorMsg: 'Nepodařilo se načíst faktury'
})
if (!hasPermission('invoices.view')) return <Forbidden />
const handleDelete = async () => {
if (!deleteConfirm.invoice) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/invoices.php?id=${deleteConfirm.invoice.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, invoice: null })
alert.success(result.message || 'Faktura byla smazána')
fetchData()
fetchStats()
} else {
alert.error(result.error || 'Nepodařilo se smazat fakturu')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const toggleStatus = async (inv) => {
if (inv.status === 'paid') return
try {
const res = await apiFetch(`${API_BASE}/invoices.php?id=${inv.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'paid' }),
})
const data = await res.json()
if (data.success) {
alert.success('Faktura označena jako zaplacená')
fetchData()
fetchStats()
} else {
alert.error(data.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
}
}
const handlePdf = async (inv, lang = 'cs') => {
if (pdfLoading) return
setLangModal(null)
setPdfLoading(inv.id)
try {
const response = await apiFetch(`${API_BASE}/invoices-pdf.php?id=${inv.id}&lang=${encodeURIComponent(lang)}`)
if (response.status === 401) return
if (!response.ok) {
alert.error('Nepodařilo se vygenerovat PDF')
return
}
const html = await response.text()
const w = window.open('', '_blank')
if (w) {
w.document.open()
w.document.write(html)
w.document.close()
w.onload = () => w.print()
} else {
alert.error('Prohlížeč zablokoval vyskakovací okno')
}
} catch {
alert.error('Chyba při generování PDF')
} finally {
setPdfLoading(null)
}
}
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</div>
<div className="dash-kpi-grid dash-kpi-4">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line" style={{ width: '70px' }} />
<div className="admin-skeleton-line" style={{ width: '90px' }} />
<div className="admin-skeleton-line" style={{ width: '90px' }} />
<div className="admin-skeleton-line" style={{ width: '100px' }} />
</div>
))}
</div>
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Faktury</h1>
<p className="admin-page-subtitle">
{invoices.length} {czechPlural(invoices.length, 'faktura', 'faktury', 'faktur')}
</p>
</div>
{hasPermission('invoices.create') && (
<div className="admin-page-actions">
{activeTab === 'received' ? (
<button className="admin-btn admin-btn-primary" onClick={() => setReceivedUploadOpen(true)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát faktury
</button>
) : (
<Link to="/invoices/new" className="admin-btn admin-btn-primary">
<svg width="18" height="18" 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>
Nová faktura
</Link>
)}
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="invoice-month-nav">
<button className="invoice-month-btn" onClick={prevMonth} aria-label="Předchozí měsíc">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6" /></svg>
</button>
<span>{monthLabel}</span>
<button className="invoice-month-btn" onClick={nextMonth} disabled={isCurrentMonth} aria-label="Následující měsíc">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
<div className="offers-tabs" style={{ marginBottom: '1rem', justifyContent: 'center' }}>
<button className={`offers-tab ${activeTab === 'issued' ? 'active' : ''}`} onClick={() => setActiveTab('issued')}>
Vydané
</button>
<button className={`offers-tab ${activeTab === 'received' ? 'active' : ''}`} onClick={() => setActiveTab('received')}>
Přijaté
</button>
</div>
</motion.div>
{activeTab === 'received' ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<Suspense fallback={
<div className="dash-kpi-grid dash-kpi-4" style={{ marginBottom: '1.5rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
}>
<ReceivedInvoices statsMonth={statsMonth} statsYear={statsYear} uploadOpen={receivedUploadOpen} setUploadOpen={setReceivedUploadOpen} />
</Suspense>
</motion.div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{!hasLoadedOnce.current && statsLoading ? (
<div className="dash-kpi-grid dash-kpi-4" style={{ marginBottom: '1.5rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
) : stats && (
<div style={{ overflow: 'hidden', marginBottom: '1.5rem' }}>
<AnimatePresence mode="popLayout" initial={false} custom={slideDirection.current}>
<motion.div
key={slideKey}
className="dash-kpi-grid dash-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (dir) => ({ x: `${(dir || 0) * -105}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
{(() => {
const paid = formatCzkWithDetail(stats.paid_month, stats.paid_month_czk)
const wait = formatCzkWithDetail(stats.awaiting, stats.awaiting_czk)
const over = formatCzkWithDetail(stats.overdue, stats.overdue_czk)
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk)
const countFooter = (count, zero) => count > 0
? `${count} ${czechPlural(count, 'faktura', 'faktury', 'faktur')}`
: zero
return (
<>
<div className="admin-stat-card success">
<div className="admin-stat-label">Uhrazeno ({MONTH_NAMES[statsMonth - 1]})</div>
<div className="admin-stat-value admin-mono">{paid.value}</div>
<div className="admin-stat-footer">
{[paid.detail, countFooter(stats.paid_month_count, 'žádné úhrady')].filter(Boolean).join(' · ')}
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">Čeká úhrada <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
<div className="admin-stat-value admin-mono">{wait.value}</div>
<div className="admin-stat-footer">
{[wait.detail, countFooter(stats.awaiting_count, 'vše uhrazeno')].filter(Boolean).join(' · ')}
</div>
</div>
<div className="admin-stat-card danger">
<div className="admin-stat-label">Po splatnosti <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
<div className="admin-stat-value admin-mono">{over.value}</div>
<div className="admin-stat-footer">
{[over.detail, stats.overdue_count === 0 ? 'vše v pořádku' : countFooter(stats.overdue_count, '')].filter(Boolean).join(' · ')}
</div>
</div>
<div className="admin-stat-card info">
<div className="admin-stat-label">DPH ({MONTH_NAMES[statsMonth - 1]})</div>
<div className="admin-stat-value admin-mono">{vat.value}</div>
<div className="admin-stat-footer">{vat.detail || 'z vydaných faktur'}</div>
</div>
</>
)
})()}
</motion.div>
</AnimatePresence>
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="offers-tabs" style={{ marginBottom: '1.5rem' }}>
{STATUS_FILTERS.map(f => (
<button
key={f.value}
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
onClick={() => setStatusFilter(f.value)}
>
{f.label}
</button>
))}
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
<div className="admin-search-bar" style={{ marginBottom: '1rem' }}>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..."
/>
</div>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<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>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission('invoices.create') && (
<p className="text-tertiary" style={{ fontSize: '0.875rem' }}>
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('invoice_number')}>
Číslo <SortIcon column="invoice_number" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('issue_date')}>
Vystaveno <SortIcon column="issue_date" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('due_date')}>
Splatnost <SortIcon column="due_date" sort={activeSort} order={order} />
</th>
<th style={{ textAlign: 'right' }}>Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{' · '}{new Date(draft.savedAt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</span>
</td>
<td>{draft.form.customer_name || '—'}</td>
<td></td>
<td className="admin-mono">
{draft.form.issue_date ? formatDate(draft.form.issue_date) : '—'}
</td>
<td className="admin-mono">
{draft.form.due_date ? formatDate(draft.form.due_date) : '—'}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link to="/invoices/new" className="admin-btn-icon" title="Pokračovat v konceptu" aria-label="Pokračovat v konceptu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<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>
)}
{invoices.map((inv) => {
const isOverdue = inv.status === 'overdue' || (inv.status === 'issued' && inv.due_date && new Date(inv.due_date) < new Date(new Date().toDateString()))
return (
<tr key={inv.id} className={isOverdue ? 'offers-expired-row' : ''}>
<td className="admin-mono">
<Link to={`/invoices/${inv.id}`} className="link-accent">
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || '—'}</td>
<td>
{inv.status === 'paid' ? (
<span className={`admin-badge ${STATUS_CLASSES[inv.status]}`}>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ''}`}
style={{ cursor: 'pointer' }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td className="admin-mono" style={inv.status === 'overdue' ? { color: 'var(--danger)', fontWeight: 600 } : undefined}>
{formatDate(inv.due_date)}
</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 500 }}>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/invoices/${inv.id}`} className="admin-btn-icon" title="Detail" aria-label="Detail">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</Link>
{hasPermission('invoices.export') && (
<button
onClick={() => setLangModal(inv)}
className="admin-btn-icon"
title="PDF"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div className="admin-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission('invoices.delete') && (
<button
onClick={() => setDeleteConfirm({ show: true, invoice: inv })}
className="admin-btn-icon danger"
title="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>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<AnimatePresence>
{langModal && (
<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={() => setLangModal(null)} />
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
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-info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h2 className="admin-confirm-title">Jazyk faktury</h2>
<p className="admin-confirm-message">V jakém jazyce chcete vygenerovat fakturu?</p>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => handlePdf(langModal, 'cs')} className="admin-btn admin-btn-primary">
Čeština
</button>
<button type="button" onClick={() => handlePdf(langModal, 'en')} className="admin-btn admin-btn-primary">
English
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,456 @@
import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { motion, AnimatePresence } from 'framer-motion'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import { czechPlural } from '../utils/formatters'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
const API_BASE = '/api/admin'
const leaveTypeLabels = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
const leaveTypeClasses = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
const statusLabels = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
const statusClasses = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
export default function LeaveApproval() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('pending')
const [pendingRequests, setPendingRequests] = useState([])
const [pendingCount, setPendingCount] = useState(0)
const [processedRequests, setProcessedRequests] = useState([])
const [approveModal, setApproveModal] = useState({ open: false, request: null })
const [rejectModal, setRejectModal] = useState({ open: false, request: null })
const [rejectNote, setRejectNote] = useState('')
const [processing, setProcessing] = useState(false)
useModalLock(rejectModal.open)
const fetchPending = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests.php?action=pending`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setPendingRequests(result.data.requests)
setPendingCount(result.data.count)
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
}
}, [alert])
const fetchProcessed = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests.php?action=all&status=approved`)
if (response.status === 401) return
const resultApproved = await response.json()
const response2 = await apiFetch(`${API_BASE}/leave-requests.php?action=all&status=rejected`)
if (response2.status === 401) return
const resultRejected = await response2.json()
const all = [
...(resultApproved.success ? resultApproved.data : []),
...(resultRejected.success ? resultRejected.data : [])
].sort((a, b) => new Date(b.reviewed_at) - new Date(a.reviewed_at))
setProcessedRequests(all)
} catch {
alert.error('Nepodařilo se načíst vyřízené žádosti')
}
}, [alert])
useEffect(() => {
const load = async () => {
setLoading(true)
await fetchPending()
setLoading(false)
}
load()
}, [fetchPending])
useEffect(() => {
if (activeTab === 'processed' && processedRequests.length === 0) {
fetchProcessed()
}
}, [activeTab, processedRequests.length, fetchProcessed])
if (!hasPermission('attendance.approve')) return <Forbidden />
const handleApprove = async () => {
setProcessing(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests.php?action=approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: approveModal.request.id })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setApproveModal({ open: false, request: null })
await fetchPending()
setProcessedRequests([])
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setProcessing(false)
}
}
const handleReject = async () => {
if (!rejectNote.trim()) {
alert.error('Důvod zamítnutí je povinný')
return
}
setProcessing(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests.php?action=reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: rejectModal.request.id, note: rejectNote })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setRejectModal({ open: false, request: null })
setRejectNote('')
await fetchPending()
setProcessedRequests([])
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setProcessing(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, 'žádost čeká', 'žádosti čekají', 'žádostí čeká')} na schválení`
: 'Žádné čekající žádosti'
}
</p>
</div>
</motion.div>
{/* Tabs */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
>
<div className="offers-tabs" style={{ marginBottom: '1.5rem' }}>
<button
className={`offers-tab ${activeTab === 'pending' ? 'active' : ''}`}
onClick={() => setActiveTab('pending')}
>
Ke schválení
{pendingCount > 0 && (
<span className="admin-badge badge-pending" style={{ marginLeft: '0.5rem', fontSize: '0.7rem', padding: '0.15rem 0.5rem' }}>
{pendingCount}
</span>
)}
</button>
<button
className={`offers-tab ${activeTab === 'processed' ? 'active' : ''}`}
onClick={() => setActiveTab('processed')}
>
Vyřízené
</button>
</div>
</motion.div>
{/* Pending Tab */}
{activeTab === 'pending' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{pendingRequests.length === 0 ? (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted" style={{ marginBottom: '1rem' }}>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p>Žádné čekající žádosti</p>
</div>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
<div className="admin-card-body" style={{ padding: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<strong style={{ fontSize: '1rem' }}>{req.employee_name}</strong>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</div>
<div className="text-secondary" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', fontSize: '0.875rem' }}>
<span>
<strong>{formatDate(req.date_from)}</strong> <strong>{formatDate(req.date_to)}</strong>
</span>
<span>{req.total_days} {czechPlural(req.total_days, 'den', 'dny', 'dnů')} ({req.total_hours}h)</span>
<span className="text-muted">Podáno: {formatDatetime(req.created_at)}</span>
</div>
{req.notes && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.875rem', fontStyle: 'italic' }}>
{req.notes}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
onClick={() => setApproveModal({ open: true, request: req })}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--success-light)', color: 'var(--success)', border: 'none' }}
>
Schválit
</button>
<button
onClick={() => setRejectModal({ open: true, request: req })}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--danger-light)', color: 'var(--danger)', border: 'none' }}
>
Zamítnout
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</motion.div>
)}
{/* Processed Tab */}
{activeTab === 'processed' && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
{processedRequests.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné vyřízené žádosti</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Stav</th>
<th>Schválil</th>
<th>Poznámka</th>
<th>Vyřízeno</th>
</tr>
</thead>
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td><strong>{req.employee_name}</strong></td>
<td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">{formatDate(req.date_from)}</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || '—'}</td>
<td style={{ maxWidth: '200px' }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40 ? `${req.reviewer_note.substring(0, 40) }...` : req.reviewer_note}
</span>
) : '—'}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
)}
{/* Approve Confirmation */}
<ConfirmModal
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, 'den', 'dny', 'dnů')} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ''} pro ${approveModal.request.employee_name}?`
: ''
}
confirmText="Schválit"
type="info"
loading={processing}
/>
{/* Reject Modal */}
<AnimatePresence>
{rejectModal.open && (
<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={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }} />
<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">Zamítnout žádost</h2>
</div>
<div className="admin-modal-body">
{rejectModal.request && (
<p className="text-secondary" style={{ marginBottom: '1rem' }}>
{rejectModal.request.employee_name} {leaveTypeLabels[rejectModal.request.leave_type]},{' '}
{formatDate(rejectModal.request.date_from)} {formatDate(rejectModal.request.date_to)} ({rejectModal.request.total_days} dnů)
</p>
)}
<div className="admin-form-group">
<label className="admin-form-label required">Důvod zamítnutí</label>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
Zrušit
</button>
<button
type="button"
onClick={handleReject}
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? 'Zpracování...' : 'Zamítnout'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,279 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import ConfirmModal from '../components/ConfirmModal'
const API_BASE = '/api/admin'
const leaveTypeLabels = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
const statusLabels = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
const statusClasses = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
const leaveTypeClasses = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
export default function LeaveRequests() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [requests, setRequests] = useState([])
const [cancelModal, setCancelModal] = useState({ open: false, id: null })
const [cancelling, setCancelling] = useState(false)
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests.php`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setRequests(result.data)
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => {
fetchRequests()
}, [fetchRequests])
if (!hasPermission('attendance.record')) return <Forbidden />
const handleCancel = async () => {
setCancelling(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests.php?action=cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: cancelModal.id })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setCancelModal({ open: false, id: null })
await fetchRequests()
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setCancelling(false)
}
}
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ 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 className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-3/4" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ 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 className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
</div>
</div>
</div>
</div>
)
}
function renderNoteCell(req) {
const truncate = (text) => text.length > 40 ? `${text.substring(0, 40)}...` : text
if (req.status === 'rejected' && req.reviewer_note) {
return (
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }} title={req.reviewer_note}>
{truncate(req.reviewer_note)}
</span>
)
}
if (req.notes) {
return (
<span className="text-secondary" style={{ fontSize: '0.875rem' }} title={req.notes}>
{truncate(req.notes)}
</span>
)
}
return <span className="text-muted"></span>
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Moje žádosti</h1>
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th></th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">{formatDate(req.date_from)}</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: '200px' }}>
{renderNoteCell(req)}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
{formatDatetime(req.created_at)}
</td>
<td>
{req.status === 'pending' && (
<button
onClick={() => setCancelModal({ open: true, id: req.id })}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelling}
/>
</div>
)
}

329
src/admin/pages/Login.jsx Normal file
View File

@@ -0,0 +1,329 @@
import { useState, useEffect, useRef } from 'react'
import { Navigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { useTheme } from '../../context/ThemeContext'
import { shouldShowSessionExpiredAlert, shouldShowLogoutAlert } from '../utils/api'
export default function Login() {
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth()
const alert = useAlert()
const { theme, toggleTheme } = useTheme()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false)
const [shake, setShake] = useState(false)
const [animatingOut, setAnimatingOut] = useState(false)
// 2FA state
const [show2FA, setShow2FA] = useState(false)
const [loginToken, setLoginToken] = useState(null)
const [totpCode, setTotpCode] = useState('')
const [useBackupCode, setUseBackupCode] = useState(false)
const totpInputRef = useRef(null)
useEffect(() => {
if (shouldShowSessionExpiredAlert()) {
alert.warning('Vaše relace vypršela. Přihlaste se prosím znovu.')
} else if (shouldShowLogoutAlert()) {
alert.success('Byli jste úspěšně odhlášeni.')
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-focus TOTP input
useEffect(() => {
if (show2FA && totpInputRef.current) {
totpInputRef.current.focus()
}
}, [show2FA, useBackupCode])
if (authLoading) {
return (
<div className="admin-login">
<div className="admin-loading">
<div className="admin-spinner" />
</div>
</div>
)
}
if (isAuthenticated && !animatingOut) {
return <Navigate to="/" replace />
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
const result = await login(username, password, remember)
if (result.requires2FA) {
setLoginToken(result.loginToken)
setShow2FA(true)
setTotpCode('')
setLoading(false)
} else if (!result.success) {
alert.error(result.error)
setShake(true)
setTimeout(() => setShake(false), 500)
setLoading(false)
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
}
}
const handle2FASubmit = async (e) => {
e.preventDefault()
if (!totpCode.trim()) return
setLoading(true)
const result = await verify2FA(loginToken, totpCode.trim(), remember, useBackupCode)
if (!result.success) {
alert.error(result.error)
setShake(true)
setTimeout(() => setShake(false), 500)
setTotpCode('')
if (totpInputRef.current) totpInputRef.current.focus()
setLoading(false)
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
}
}
const handleBack = () => {
setShow2FA(false)
setLoginToken(null)
setTotpCode('')
setUseBackupCode(false)
}
return (
<motion.div
className="admin-login"
initial={{ opacity: 0, scale: 0.97 }}
animate={animatingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
}
transition={animatingOut
? { duration: 0.5, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.35, ease: [0.4, 0, 0.2, 1] }
}
>
<div className="bg-orb bg-orb-1" />
<div className="bg-orb bg-orb-2" />
<button
onClick={toggleTheme}
className="admin-login-theme-btn"
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
>
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
<AnimatePresence mode="wait">
{!show2FA ? (
<motion.div
key="login"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<img
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
alt="BOHA Automation"
className="admin-login-logo"
/>
<h1 className="admin-login-title">Interní systém</h1>
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>
</div>
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-group">
<label htmlFor="username" className="admin-form-label">
Uživatelské jméno nebo e-mail
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
className="admin-form-input"
placeholder="Zadejte uživatelské jméno"
/>
</div>
<div className="admin-form-group">
<label htmlFor="password" className="admin-form-label">
Heslo
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="admin-form-input"
placeholder="Zadejte heslo"
/>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
<span>Zapamatovat si </span>
</label>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
Přihlašování...
</>
) : (
'Přihlásit se'
)}
</button>
</form>
</motion.div>
) : (
<motion.div
key="2fa"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<div className="admin-login-2fa-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<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>
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
<p className="admin-login-subtitle">
{useBackupCode
? 'Zadejte jeden ze záložních kódů'
: 'Zadejte 6místný kód z autentizační aplikace'
}
</p>
</div>
<form onSubmit={handle2FASubmit} className="admin-form">
<div className="admin-form-group">
<label htmlFor="totp-code" className="admin-form-label">
{useBackupCode ? 'Záložní kód' : 'Ověřovací kód'}
</label>
<input
ref={totpInputRef}
id="totp-code"
type="text"
inputMode={useBackupCode ? 'text' : 'numeric'}
pattern={useBackupCode ? undefined : '[0-9]*'}
maxLength={useBackupCode ? 8 : 6}
value={totpCode}
onChange={(e) => {
const val = useBackupCode ? e.target.value : e.target.value.replace(/\D/g, '')
setTotpCode(val)
}}
required
autoComplete="one-time-code"
className="admin-form-input"
placeholder={useBackupCode ? 'XXXXXXXX' : '000000'}
style={useBackupCode ? {} : {
textAlign: 'center',
fontSize: '1.5rem',
letterSpacing: '0.5rem',
fontFamily: 'monospace'
}}
/>
</div>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
Ověřování...
</>
) : (
'Ověřit'
)}
</button>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
<button
onClick={() => {
setUseBackupCode(!useBackupCode)
setTotpCode('')
}}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
>
{useBackupCode ? 'Použít autentizační aplikaci' : 'Použít záložní kód'}
</button>
<button
onClick={handleBack}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
>
&larr; Zpět na přihlášení
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}

File diff suppressed because it is too large Load Diff

633
src/admin/pages/Offers.jsx Normal file
View File

@@ -0,0 +1,633 @@
import { useState, useEffect } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import useModalLock from '../hooks/useModalLock'
const API_BASE = '/api/admin'
const DRAFT_KEY = 'boha_offer_draft'
export default function Offers() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const { sort, order, handleSort, activeSort } = useTableSort('quotation_number')
const [search, setSearch] = useState('')
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, quotation: null })
const [deleting, setDeleting] = useState(false)
const [invalidateConfirm, setInvalidateConfirm] = useState({ show: false, quotation: null })
const [invalidating, setInvalidating] = useState(false)
const [duplicating, setDuplicating] = useState(null)
const [pdfLoading, setPdfLoading] = useState(null)
const [creatingOrder, setCreatingOrder] = useState(null)
const [orderModal, setOrderModal] = useState({ show: false, quotation: null })
useModalLock(orderModal.show)
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
const [orderAttachment, setOrderAttachment] = useState(null)
const [draft, setDraft] = useState(null)
const { items: quotations, loading, refetch: fetchData } = useListData('offers.php', {
dataKey: 'quotations', search, sort, order,
errorMsg: 'Nepodařilo se načíst nabídky'
})
useEffect(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
if (parsed && parsed.form && Array.isArray(parsed.items)) {
setDraft(parsed)
}
} catch {
/* ignore corrupt data */
}
}, [])
const discardDraft = () => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraft(null)
}
const getRowClass = (invalidated, expired) => {
if (invalidated) return 'offers-invalidated-row'
if (expired) return 'offers-expired-row'
return ''
}
if (!hasPermission('offers.view')) return <Forbidden />
const handleDuplicate = async (quotation) => {
setDuplicating(quotation.id)
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=duplicate&id=${quotation.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Nabídka byla duplikována')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se duplikovat nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDuplicating(null)
}
}
const handleCreateOrder = async () => {
if (!customerOrderNumber.trim() || !orderModal.quotation) return
setCreatingOrder(orderModal.quotation.id)
try {
const formData = new FormData()
formData.append('quotationId', orderModal.quotation.id)
formData.append('customerOrderNumber', customerOrderNumber.trim())
if (orderAttachment) {
formData.append('attachment', orderAttachment)
}
const response = await apiFetch(`${API_BASE}/orders.php`, {
method: 'POST',
body: formData
})
const result = await response.json()
if (result.success) {
setOrderModal({ show: false, quotation: null })
alert.success(result.message || 'Objednávka byla vytvořena')
navigate(`/orders/${result.data.order_id}`)
} else {
alert.error(result.error || 'Nepodařilo se vytvořit objednávku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setCreatingOrder(null)
}
}
const handleDelete = async () => {
if (!deleteConfirm.quotation) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/offers.php?id=${deleteConfirm.quotation.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, quotation: null })
alert.success(result.message || 'Nabídka byla smazána')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const handleInvalidate = async () => {
if (!invalidateConfirm.quotation) return
setInvalidating(true)
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=invalidate&id=${invalidateConfirm.quotation.id}`, {
method: 'POST'
})
const result = await response.json()
if (result.success) {
setInvalidateConfirm({ show: false, quotation: null })
alert.success(result.message || 'Nabídka byla zneplatněna')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se zneplatnit nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setInvalidating(false)
}
}
const handlePdf = async (quotation) => {
if (pdfLoading) return
setPdfLoading(quotation.id)
try {
const response = await apiFetch(`${API_BASE}/offers-pdf.php?id=${quotation.id}`)
if (response.status === 401) return
if (!response.ok) {
alert.error('Nepodařilo se vygenerovat PDF')
return
}
const html = await response.text()
const w = window.open('', '_blank')
if (w) {
w.document.open()
w.document.write(html)
w.document.close()
w.onload = () => w.print()
} else {
alert.error('Prohlížeč zablokoval vyskakovací okno')
}
} catch {
alert.error('Chyba při generování PDF')
} finally {
setPdfLoading(null)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px', marginBottom: '0.5rem' }} />
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Nabídky</h1>
<p className="admin-page-subtitle">
{quotations.length} {czechPlural(quotations.length, 'nabídka', 'nabídky', 'nabídek')}
</p>
</div>
<div className="admin-page-actions">
{hasPermission('offers.settings') && (
<Link to="/offers/templates" className="admin-btn admin-btn-secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 21V9" />
</svg>
Šablony
</Link>
)}
{hasPermission('offers.create') && (
<Link to="/offers/new" className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nová nabídka
</Link>
)}
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<div className="admin-search-bar" style={{ marginBottom: '1rem' }}>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat podle čísla, projektu nebo zákazníka..."
/>
</div>
{quotations.length === 0 && !draft ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<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>
</div>
<p>Zatím nejsou žádné nabídky.</p>
{hasPermission('offers.create') && (
<Link to="/offers/new" className="admin-btn admin-btn-primary">
Vytvořit první nabídku
</Link>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('quotation_number')}>
Číslo <SortIcon column="quotation_number" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('project_code')}>
Projekt <SortIcon column="project_code" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('created_at')}>
Datum <SortIcon column="created_at" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('valid_until')}>
Platnost <SortIcon column="valid_until" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('currency')}>
Měna <SortIcon column="currency" sort={activeSort} order={order} />
</th>
<th style={{ textAlign: 'right' }}>Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{' · '}{new Date(draft.savedAt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</span>
</td>
<td>
{draft.form.project_code || '—'}
</td>
<td>{draft.form.customer_name || '—'}</td>
<td className="admin-mono">
{draft.form.created_at ? formatDate(draft.form.created_at) : '—'}
</td>
<td className="admin-mono">
{draft.form.valid_until ? formatDate(draft.form.valid_until) : '—'}
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{draft.form.currency || '—'}
</span>
</td>
<td />
<td>
<div className="admin-table-actions">
<Link to="/offers/new" className="admin-btn-icon" title="Pokračovat v konceptu" aria-label="Pokračovat v konceptu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<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>
)}
{quotations.map((q) => {
const isInvalidated = q.status === 'invalidated'
const isExpired = !isInvalidated && !q.order_id && q.valid_until && new Date(q.valid_until) < new Date(new Date().toDateString())
return (
<tr key={q.id} className={getRowClass(isInvalidated, isExpired)}>
<td>
<Link to={`/offers/${q.id}`} className="link-accent">
{q.quotation_number}
</Link>
</td>
<td>
{q.project_code || '—'}
</td>
<td>{q.customer_name || '—'}</td>
<td className="admin-mono">
{formatDate(q.created_at)}
</td>
<td className="admin-mono">
{formatDate(q.valid_until)}
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{q.currency}
</span>
</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 500 }}>
{formatCurrency(q.total, q.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/offers/${q.id}`} className="admin-btn-icon" title={isInvalidated ? 'Zobrazit' : 'Upravit'} aria-label={isInvalidated ? 'Zobrazit' : 'Upravit'}>
{isInvalidated ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link>
{!isInvalidated && hasPermission('offers.create') && (
<button
onClick={() => handleDuplicate(q)}
className="admin-btn-icon"
title="Duplikovat"
disabled={duplicating === q.id}
>
<svg width="18" height="18" 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>
)}
{!isInvalidated && q.order_id ? (
<Link to={`/orders/${q.order_id}`} className="admin-btn-icon accent" title="Zobrazit objednávku" aria-label="Zobrazit objednávku">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text x="12" y="16.5" textAnchor="middle" fill="currentColor" stroke="none" fontSize="9" fontWeight="700">O</text>
</svg>
</Link>
) : !isInvalidated && hasPermission('orders.create') && (
<button
onClick={() => { setCustomerOrderNumber(''); setOrderAttachment(null); setOrderModal({ show: true, quotation: q }) }}
className="admin-btn-icon"
title="Vytvořit objednávku"
disabled={creatingOrder === q.id}
>
{creatingOrder === q.id ? (
<div className="admin-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
)}
</button>
)}
{isExpired && !isInvalidated && hasPermission('offers.edit') && (
<button
onClick={() => setInvalidateConfirm({ show: true, quotation: q })}
className="admin-btn-icon"
title="Zneplatnit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
</button>
)}
{hasPermission('offers.export') && (
<button
onClick={() => handlePdf(q)}
className="admin-btn-icon"
title="PDF"
disabled={pdfLoading === q.id}
>
{pdfLoading === q.id ? (
<div className="admin-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission('offers.delete') && (
<button
onClick={() => setDeleteConfirm({ show: true, quotation: q })}
className="admin-btn-icon danger"
title="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>
)
})}
{quotations.length === 0 && draft && search && (
<tr>
<td colSpan={8} className="text-muted" style={{ textAlign: 'center', padding: '1.5rem' }}>
Žádné nabídky odpovídající hledání.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
onConfirm={handleDelete}
title="Smazat nabídku"
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<ConfirmModal
isOpen={invalidateConfirm.show}
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
onConfirm={handleInvalidate}
title="Zneplatnit nabídku"
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
confirmText="Zneplatnit"
cancelText="Zrušit"
type="danger"
loading={invalidating}
/>
<AnimatePresence>
{orderModal.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={() => !creatingOrder && setOrderModal({ show: false, quotation: null })} />
<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">Vytvořit objednávku</h2>
<p className="text-secondary" style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>
Nabídka: <strong>{orderModal.quotation?.quotation_number}</strong>
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label required">Číslo objednávky zákazníka</label>
<input
type="text"
value={customerOrderNumber}
onChange={e => setCustomerOrderNumber(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !creatingOrder && handleCreateOrder()}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příloha (PDF)</label>
{orderAttachment ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-color)" 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>
<span style={{ fontSize: '0.875rem' }}>
{orderAttachment.name} <span className="text-tertiary">({(orderAttachment.size / 1024).toFixed(0)} KB)</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: 'auto' }}
>
<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>
) : (
<label className="admin-btn admin-btn-secondary admin-btn-sm" style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={e => setOrderAttachment(e.target.files[0] || null)}
style={{ display: 'none' }}
/>
</label>
)}
<small className="admin-form-hint" style={{ marginTop: '0.25rem' }}>Max 10 MB</small>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button onClick={() => setOrderModal({ show: false, quotation: null })} className="admin-btn admin-btn-secondary" disabled={!!creatingOrder}>
Zrušit
</button>
<button onClick={handleCreateOrder} className="admin-btn admin-btn-primary" disabled={!!creatingOrder || !customerOrderNumber.trim()}>
{creatingOrder ? 'Vytváření...' : 'Vytvořit'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,643 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const DEFAULT_CUSTOMER_FIELD_ORDER = ['street', 'city_postal', 'country', 'company_id', 'vat_id']
const CUSTOMER_FIELD_LABELS = {
street: 'Ulice',
city_postal: 'Město + PSČ',
country: 'Země',
company_id: 'IČO',
vat_id: 'DIČ',
}
export default function OffersCustomers() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [customers, setCustomers] = useState([])
const [search, setSearch] = useState('')
const [showModal, setShowModal] = useState(false)
const [editingCustomer, setEditingCustomer] = useState(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
name: '',
street: '',
city: '',
postal_code: '',
country: '',
company_id: '',
vat_id: '',
})
const [customFields, setCustomFields] = useState([])
const customFieldKeyCounter = useRef(0)
const [fieldOrder, setFieldOrder] = useState([...DEFAULT_CUSTOMER_FIELD_ORDER])
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, customer: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
// Build the full field order list including custom fields
const getFullFieldOrder = useCallback(() => {
const allBuiltIn = [...DEFAULT_CUSTOMER_FIELD_ORDER]
const order = [...fieldOrder].filter(k => k !== 'name')
for (const f of allBuiltIn) {
if (!order.includes(f)) order.push(f)
}
for (let i = 0; i < customFields.length; i++) {
const key = `custom_${i}`
if (!order.includes(key)) order.push(key)
}
return order.filter(key => {
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
return idx < customFields.length
}
return true
})
}, [fieldOrder, customFields])
const moveField = (index, direction) => {
const order = getFullFieldOrder()
const newIndex = index + direction
if (newIndex < 0 || newIndex >= order.length) return
const updated = [...order]
;[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]]
setFieldOrder(updated)
}
const getFieldDisplayName = (key) => {
if (CUSTOMER_FIELD_LABELS[key]) return CUSTOMER_FIELD_LABELS[key]
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
const cf = customFields[idx]
if (cf) return cf.name ? `${cf.name}: ${cf.value || '...'}` : cf.value || `Vlastní pole ${idx + 1}`
}
return key
}
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/customers.php`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setCustomers(result.data.customers)
} else {
alert.error(result.error || 'Nepodařilo se načíst zákazníky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
const openCreateModal = () => {
setEditingCustomer(null)
setForm({
name: '', street: '', city: '', postal_code: '', country: '',
company_id: '', vat_id: ''
})
setCustomFields([])
setFieldOrder([...DEFAULT_CUSTOMER_FIELD_ORDER])
setShowModal(true)
}
const openEditModal = (customer) => {
setEditingCustomer(customer)
setForm({
name: customer.name || '',
street: customer.street || '',
city: customer.city || '',
postal_code: customer.postal_code || '',
country: customer.country || '',
company_id: customer.company_id || '',
vat_id: customer.vat_id || '',
})
// Load custom fields
const cf = Array.isArray(customer.custom_fields) && customer.custom_fields.length > 0
? customer.custom_fields.map(f => ({ ...f, _key: `cf-${++customFieldKeyCounter.current}` }))
: []
setCustomFields(cf)
// Load field order
if (Array.isArray(customer.customer_field_order) && customer.customer_field_order.length > 0) {
setFieldOrder(customer.customer_field_order)
} else {
setFieldOrder([...DEFAULT_CUSTOMER_FIELD_ORDER])
}
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingCustomer(null)
}
const handleSubmit = async () => {
if (!form.name.trim()) {
alert.error('Název zákazníka je povinný')
return
}
setSaving(true)
try {
const url = editingCustomer
? `${API_BASE}/customers.php?id=${editingCustomer.id}`
: `${API_BASE}/customers.php`
const payload = {
...form,
custom_fields: customFields.filter(f => f.name.trim() || f.value.trim()),
customer_field_order: getFullFieldOrder(),
}
const response = await apiFetch(url, {
method: editingCustomer ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message || (editingCustomer ? 'Zákazník byl aktualizován' : 'Zákazník byl vytvořen'))
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se uložit zákazníka')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.customer) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/customers.php?id=${deleteConfirm.customer.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, customer: null })
alert.success(result.message || 'Zákazník byl smazán')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat zákazníka')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (!hasPermission('offers.view')) return <Forbidden />
const filteredCustomers = search
? customers.filter(c =>
(c.name || '').toLowerCase().includes(search.toLowerCase()) ||
(c.company_id || '').includes(search) ||
(c.city || '').toLowerCase().includes(search.toLowerCase())
)
: customers
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '160px', borderRadius: '8px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px', marginBottom: '0.5rem' }} />
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
const fullFieldOrder = getFullFieldOrder()
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Zákazníci</h1>
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
</div>
{hasPermission('offers.create') && (
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat zákazníka
</button>
)}
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<div className="admin-search-bar" style={{ marginBottom: '1rem' }}>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat zákazníky..."
/>
</div>
{filteredCustomers.length === 0 ? (
<div className="admin-empty-state">
<p>{search ? 'Žádní zákazníci odpovídající hledání.' : 'Zatím nejsou žádní zákazníci.'}</p>
{!search && hasPermission('offers.create') && (
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
Přidat prvního zákazníka
</button>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Město</th>
<th>IČO</th>
<th>DIČ</th>
<th>Nabídky</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{filteredCustomers.map((customer) => (
<tr key={customer.id}>
<td>
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>
{customer.name}
</div>
{customer.street && (
<div className="text-tertiary" style={{ fontSize: '11px' }}>
{customer.street}
</div>
)}
</td>
<td>{customer.city || '—'}</td>
<td>{customer.company_id || '—'}</td>
<td>{customer.vat_id || '—'}</td>
<td>
<span className="admin-badge admin-badge-info">
{customer.quotation_count || 0}
</span>
</td>
<td>
<div className="admin-table-actions">
{hasPermission('offers.edit') && (
<button
onClick={() => openEditModal(customer)}
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>
)}
{hasPermission('offers.delete') && (
<button
onClick={() => setDeleteConfirm({ show: true, customer })}
className="admin-btn-icon danger"
title={customer.quotation_count > 0 ? 'Nelze smazat zákazníka s nabídkami' : 'Smazat'}
aria-label={customer.quotation_count > 0 ? 'Nelze smazat zákazníka s nabídkami' : 'Smazat'}
disabled={customer.quotation_count > 0}
>
<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>
)}
</div>
</motion.div>
{/* Create/Edit 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={closeModal} />
<motion.div
className="admin-modal"
style={{ maxWidth: 720 }}
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">
{editingCustomer ? 'Upravit zákazníka' : 'Nový zákazník'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label required">Název</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
className="admin-form-input"
placeholder="Název firmy / jméno"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Ulice</label>
<input
type="text"
value={form.street}
onChange={(e) => setForm(prev => ({ ...prev, street: e.target.value }))}
className="admin-form-input"
/>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Město</label>
<input
type="text"
value={form.city}
onChange={(e) => setForm(prev => ({ ...prev, city: e.target.value }))}
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">PSČ</label>
<input
type="text"
value={form.postal_code}
onChange={(e) => setForm(prev => ({ ...prev, postal_code: e.target.value }))}
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Země</label>
<input
type="text"
value={form.country}
onChange={(e) => setForm(prev => ({ ...prev, country: e.target.value }))}
className="admin-form-input"
/>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">IČO</label>
<input
type="text"
value={form.company_id}
onChange={(e) => setForm(prev => ({ ...prev, company_id: e.target.value }))}
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DIČ</label>
<input
type="text"
value={form.vat_id}
onChange={(e) => setForm(prev => ({ ...prev, vat_id: e.target.value }))}
className="admin-form-input"
/>
</div>
</div>
{/* Dynamic custom fields */}
<div style={{ marginTop: 4 }}>
<label className="admin-form-label" style={{ display: 'block', marginBottom: 4 }}>Vlastní pole</label>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div className="admin-form-row" style={{ marginBottom: 0, alignItems: 'flex-end' }}>
<div className="admin-form-group" style={{ flex: 1 }}>
{idx === 0 && <label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Název</label>}
<input
type="text"
value={field.name}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], name: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
placeholder="Např. Kontakt"
/>
</div>
<div className="admin-form-group" style={{ flex: 1 }}>
{idx === 0 && <label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Hodnota</label>}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="text"
value={field.value}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], value: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const key = `custom_${idx}`
setFieldOrder(prev => {
return prev
.filter(k => k !== key)
.map(k => {
if (k.startsWith('custom_')) {
const ki = parseInt(k.split('_')[1])
if (ki > idx) return `custom_${ki - 1}`
}
return k
})
})
setCustomFields(customFields.filter((_, i) => i !== idx))
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
</div>
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
<input
type="checkbox"
checked={field.showLabel !== false}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], showLabel: e.target.checked }
setCustomFields(updated)
}}
/>
<span style={{ fontSize: '0.8rem' }}>Zobrazit název v PDF</span>
</label>
</div>
))}
<button
type="button"
onClick={() => setCustomFields([...customFields, { name: '', value: '', showLabel: true, _key: `cf-${++customFieldKeyCounter.current}` }])}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: '0.85rem' }}
>
<svg width="14" height="14" 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>
Přidat pole
</button>
</div>
{/* Field order for PDF */}
<div style={{ marginTop: 16 }}>
<label className="admin-form-label">Pořadí polí v PDF</label>
<small className="admin-form-hint" style={{ display: 'block', marginBottom: 8 }}>
Určuje pořadí řádků v adresním bloku zákazníka na PDF nabídce.
</small>
<div className="admin-reorder-list">
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button
type="button"
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Nahoru"
aria-label="Nahoru"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button
type="button"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
className="admin-btn-icon"
title="Dolů"
aria-label="Dolů"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
</div>
<span className={`admin-reorder-label${key.startsWith('custom_') ? ' accent' : ''}`}>
{getFieldDisplayName(key)}
</span>
</div>
))}
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary" disabled={saving}>
Zrušit
</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving && (
<>
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
Ukládání...
</>
)}
{!saving && (editingCustomer ? 'Uložit změny' : 'Vytvořit zákazníka')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirm Modal */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, customer: null })}
onConfirm={handleDelete}
title="Smazat zákazníka"
message={`Opravdu chcete smazat zákazníka "${deleteConfirm.customer?.name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,602 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import RichEditor from '../components/RichEditor'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function OffersTemplates() {
const { hasPermission } = useAuth()
const [activeTab, setActiveTab] = useState('items')
if (!hasPermission('offers.settings')) return <Forbidden />
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Šablony</h1>
<p className="admin-page-subtitle">Šablony položek a rozsahu projektu</p>
</div>
</motion.div>
<div className="offers-tabs">
<button
className={`offers-tab ${activeTab === 'items' ? 'active' : ''}`}
onClick={() => setActiveTab('items')}
>
Šablony položek
</button>
<button
className={`offers-tab ${activeTab === 'scopes' ? 'active' : ''}`}
onClick={() => setActiveTab('scopes')}
>
Šablony rozsahu
</button>
</div>
{activeTab === 'items' ? <ItemTemplatesTab /> : <ScopeTemplatesTab />}
</div>
)
}
// --- Item Templates Tab ---
function ItemTemplatesTab() {
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [templates, setTemplates] = useState([])
const [showModal, setShowModal] = useState(false)
const [editingTemplate, setEditingTemplate] = useState(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({ name: '', description: '', default_price: 0, category: '' })
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, template: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=items`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setTemplates(result.data.templates)
}
} catch {
alert.error('Nepodařilo se načíst šablony')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => { fetchData() }, [fetchData])
const openCreate = () => {
setEditingTemplate(null)
setForm({ name: '', description: '', default_price: 0, category: '' })
setShowModal(true)
}
const openEdit = (t) => {
setEditingTemplate(t)
setForm({ name: t.name || '', description: t.description || '', default_price: t.default_price || 0, category: t.category || '' })
setShowModal(true)
}
const handleSubmit = async () => {
if (!form.name.trim()) {
alert.error('Název šablony je povinný')
return
}
setSaving(true)
try {
const body = editingTemplate ? { ...form, id: editingTemplate.id } : form
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=item`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const result = await response.json()
if (result.success) {
setShowModal(false)
await new Promise(r => setTimeout(r, 300))
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.template) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=item&id=${deleteConfirm.template.id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, template: null })
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="admin-card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className="admin-card-title">Šablony položek ({templates.length})</h3>
<button onClick={openCreate} className="admin-btn admin-btn-primary admin-btn-sm">
<svg width="16" height="16" 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>
Přidat
</button>
</div>
<div className="admin-card-body">
{templates.length === 0 ? (
<div className="admin-empty-state"><p>Zatím žádné šablony položek.</p></div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Popis</th>
<th>Cena</th>
<th>Kategorie</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td style={{ fontWeight: 500 }}>{t.name}</td>
<td style={{ color: 'var(--text-secondary)' }}>{t.description || '—'}</td>
<td>{Number(t.default_price).toFixed(2)}</td>
<td style={{ color: 'var(--text-secondary)' }}>{t.category || '—'}</td>
<td>
<div className="admin-table-actions">
<button onClick={() => openEdit(t)} 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={() => setDeleteConfirm({ show: true, template: t })} 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>
)}
</div>
</motion.div>
{/* Item Template 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">{editingTemplate ? 'Upravit šablonu' : 'Nová šablona položky'}</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label required">Název</label>
<input type="text" value={form.name} onChange={(e) => setForm(p => ({ ...p, name: e.target.value }))} className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Popis</label>
<textarea value={form.description} onChange={(e) => setForm(p => ({ ...p, description: e.target.value }))} className="admin-form-input" rows={2} />
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Výchozí cena</label>
<input type="number" value={form.default_price} onChange={(e) => setForm(p => ({ ...p, default_price: parseFloat(e.target.value) || 0 }))} className="admin-form-input" step="0.01" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Kategorie</label>
<input type="text" value={form.category} onChange={(e) => setForm(p => ({ ...p, category: e.target.value }))} className="admin-form-input" />
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary" disabled={saving}>Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving && (<><div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />Ukládání...</>)}
{!saving && (editingTemplate ? 'Uložit' : 'Vytvořit')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, template: null })}
onConfirm={handleDelete}
title="Smazat šablonu"
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</>
)
}
// --- Scope Templates Tab ---
function ScopeTemplatesTab() {
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [templates, setTemplates] = useState([])
const [showModal, setShowModal] = useState(false)
const [editingTemplate, setEditingTemplate] = useState(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({ name: '', sections: [] })
const sectionKeyCounter = useRef(0)
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, template: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scopes`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setTemplates(result.data.templates)
}
} catch {
alert.error('Nepodařilo se načíst šablony')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => { fetchData() }, [fetchData])
const openCreate = () => {
setEditingTemplate(null)
setForm({ name: '', sections: [{ _key: `sc-${++sectionKeyCounter.current}`, title: '', title_cz: '', content: '' }] })
setShowModal(true)
}
const openEdit = async (t) => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope_detail&id=${t.id}`)
const result = await response.json()
if (result.success) {
setEditingTemplate(result.data)
setForm({
name: result.data.name || '',
sections: result.data.sections?.length
? result.data.sections.map(s => ({ _key: `sc-${++sectionKeyCounter.current}`, title: s.title || '', title_cz: s.title_cz || '', content: s.content || '' }))
: [{ _key: `sc-${++sectionKeyCounter.current}`, title: '', title_cz: '', content: '' }]
})
setShowModal(true)
}
} catch {
alert.error('Nepodařilo se načíst detail šablony')
}
}
const addSection = () => {
setForm(prev => ({ ...prev, sections: [...prev.sections, { _key: `sc-${++sectionKeyCounter.current}`, title: '', title_cz: '', content: '' }] }))
}
const removeSection = (index) => {
setForm(prev => ({
...prev,
sections: prev.sections.filter((_, i) => i !== index)
}))
}
const updateSection = (index, field, value) => {
setForm(prev => ({
...prev,
sections: prev.sections.map((s, i) => i === index ? { ...s, [field]: value } : s)
}))
}
const moveSection = (index, direction) => {
setForm(prev => {
const newSections = [...prev.sections]
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= newSections.length) return prev
;[newSections[index], newSections[targetIndex]] = [newSections[targetIndex], newSections[index]]
return { ...prev, sections: newSections }
})
}
const handleSubmit = async () => {
if (!form.name.trim()) {
alert.error('Název šablony je povinný')
return
}
setSaving(true)
try {
const body = editingTemplate ? { ...form, id: editingTemplate.id } : form
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const result = await response.json()
if (result.success) {
setShowModal(false)
await new Promise(r => setTimeout(r, 300))
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.template) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope&id=${deleteConfirm.template.id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, template: null })
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="admin-card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className="admin-card-title">Šablony rozsahu ({templates.length})</h3>
<button onClick={openCreate} className="admin-btn admin-btn-primary admin-btn-sm">
<svg width="16" height="16" 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>
Přidat
</button>
</div>
<div className="admin-card-body">
{templates.length === 0 ? (
<div className="admin-empty-state"><p>Zatím žádné šablony rozsahu.</p></div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td style={{ fontWeight: 500 }}>{t.name}</td>
<td>
<div className="admin-table-actions">
<button onClick={() => openEdit(t)} 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={() => setDeleteConfirm({ show: true, template: t })} 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>
)}
</div>
</motion.div>
{/* Scope Template Modal (large) */}
<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 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">{editingTemplate ? 'Upravit šablonu rozsahu' : 'Nová šablona rozsahu'}</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label required">Název šablony</label>
<input type="text" value={form.name} onChange={(e) => setForm(p => ({ ...p, name: e.target.value }))} className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label" style={{ marginBottom: '0.5rem' }}>Sekce</label>
<div className="offers-scope-list">
{form.sections.map((section, index) => (
<div key={section._key} className="offers-scope-section">
<div className="offers-scope-section-header">
<span className="offers-scope-number">{index + 1}.</span>
<span className="offers-scope-title">{section.title || section.title_cz || `Sekce ${index + 1}`}</span>
<div className="offers-scope-actions">
<button type="button" onClick={() => moveSection(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Posunout nahoru" aria-label="Posunout nahoru">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button type="button" onClick={() => moveSection(index, 1)} disabled={index === form.sections.length - 1} className="admin-btn-icon" title="Posunout dolů" aria-label="Posunout dolů">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
{form.sections.length > 1 && (
<button type="button" onClick={() => removeSection(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
)}
</div>
</div>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
<span className="offers-lang-badge">EN</span>
Název sekce
</label>
<input type="text" value={section.title} onChange={(e) => updateSection(index, 'title', e.target.value)} className="admin-form-input" placeholder="Název sekce (anglicky)" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">
<span className="offers-lang-badge offers-lang-badge-cz">CZ</span>
Název sekce
</label>
<input type="text" value={section.title_cz} onChange={(e) => updateSection(index, 'title_cz', e.target.value)} className="admin-form-input" placeholder="Název sekce (česky)" />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Obsah</label>
<RichEditor
value={section.content}
onChange={(val) => updateSection(index, 'content', val)}
placeholder="Obsah sekce..."
minHeight="150px"
/>
</div>
</div>
</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="button" onClick={addSection} className="admin-btn admin-btn-secondary admin-btn-sm">
+ Přidat sekci
</button>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary" disabled={saving}>Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving && (<><div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />Ukládání...</>)}
{!saving && (editingTemplate ? 'Uložit' : 'Vytvořit')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, template: null })}
onConfirm={handleDelete}
title="Smazat šablonu"
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,609 @@
import { useState, useEffect, useMemo } from 'react'
import DOMPurify from 'dompurify'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate } from '../utils/formatters'
const API_BASE = '/api/admin'
const STATUS_LABELS = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
const STATUS_CLASSES = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
const TRANSITION_LABELS = {
v_realizaci: 'Zahájit realizaci',
dokoncena: 'Dokončit'
}
const TRANSITION_CLASSES = {
v_realizaci: 'admin-btn admin-btn-primary',
dokoncena: 'admin-btn admin-btn-primary'
}
export default function OrderDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [order, setOrder] = useState(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState(null)
const [statusConfirm, setStatusConfirm] = useState({ show: false, status: null })
const [editingNumber, setEditingNumber] = useState(false)
const [orderNumber, setOrderNumber] = useState('')
const [savingNumber, setSavingNumber] = useState(false)
const [attachmentLoading, setAttachmentLoading] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/orders.php?action=detail&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setOrder(result.data)
setNotes(result.data.notes || '')
} else {
alert.error(result.error || 'Nepodařilo se načíst objednávku')
navigate('/orders')
}
} catch {
alert.error('Chyba připojení')
navigate('/orders')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchDetail()
}, [id]) // eslint-disable-line react-hooks/exhaustive-deps
const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 }
const subtotal = order.items.reduce((sum, item) => {
if (Number(item.is_included_in_total)) {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
}
return sum
}, 0)
const vatAmount = Number(order.apply_vat) ? subtotal * ((Number(order.vat_rate) || 0) / 100) : 0
return { subtotal, vatAmount, total: subtotal + vatAmount }
}, [order])
if (!hasPermission('orders.view')) return <Forbidden />
const handleStatusChange = async () => {
if (!statusConfirm.status) return
setStatusChanging(statusConfirm.status)
setStatusConfirm({ show: false, status: null })
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: statusConfirm.status })
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Stav byl změněn')
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
} finally {
setStatusChanging(null)
}
}
const handleStartEditNumber = () => {
setOrderNumber(order.order_number)
setEditingNumber(true)
}
const handleSaveNumber = async () => {
const trimmed = orderNumber.trim()
if (!trimmed) return
if (trimmed === order.order_number) {
setEditingNumber(false)
return
}
setSavingNumber(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_number: trimmed })
})
const result = await response.json()
if (result.success) {
alert.success('Číslo objednávky bylo změněno')
setEditingNumber(false)
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit číslo')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSavingNumber(false)
}
}
const handleSaveNotes = async () => {
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: notes })
})
const result = await response.json()
if (result.success) {
alert.success('Poznámky byly uloženy')
} else {
alert.error(result.error || 'Nepodařilo se uložit poznámky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleViewAttachment = async () => {
const newWindow = window.open('', '_blank')
setAttachmentLoading(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?action=attachment&id=${id}`)
if (!response.ok) {
newWindow.close()
alert.error('Nepodařilo se stáhnout přílohu')
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
newWindow.location.href = url
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
newWindow.close()
alert.error('Chyba připojení')
} finally {
setAttachmentLoading(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Objednávka byla smazána')
navigate('/orders')
} else {
alert.error(result.error || 'Nepodařilo se smazat objednávku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
setDeleteConfirm(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-full" /></div>
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-3/4" /></div>
<div style={{ flex: 1 }}><div className="admin-skeleton-line w-1/2" /></div>
</div>
))}
</div>
</div>
</div>
)
}
if (!order) return null
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/orders" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{editingNumber ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
Objednávka
<input
type="text"
value={orderNumber}
onChange={(e) => setOrderNumber(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveNumber()
if (e.key === 'Escape') setEditingNumber(false)
}}
className="admin-form-input"
style={{ width: '10rem', fontSize: '1rem', padding: '0.25rem 0.5rem', height: 'auto' }}
autoFocus
disabled={savingNumber}
/>
<button onClick={handleSaveNumber} className="admin-btn-icon" title="Uložit" aria-label="Uložit" disabled={savingNumber}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent-color)" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
<button onClick={() => setEditingNumber(false)} className="admin-btn-icon" title="Zrušit" aria-label="Zrušit" disabled={savingNumber}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
Objednávka {order.order_number}
{hasPermission('orders.edit') && (
<button onClick={handleStartEditNumber} className="admin-btn-icon" title="Změnit číslo" aria-label="Změnit číslo" style={{ opacity: 0.5 }}>
<svg width="16" height="16" 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>
)}
</span>
)}
<span className={`admin-badge ${STATUS_CLASSES[order.status] || ''}`}>
{STATUS_LABELS[order.status] || order.status}
</span>
</h1>
</div>
</div>
<div className="admin-page-actions">
{order.invoice ? (
<Link to={`/invoices/${order.invoice.id}`} className="admin-btn admin-btn-secondary">
<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>
Faktura {order.invoice.invoice_number}
</Link>
) : (
hasPermission('invoices.create') && order.status === 'dokoncena' && (
<Link to={`/invoices/new?fromOrder=${order.id}`} className="admin-btn admin-btn-secondary">
<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>
Vytvořit fakturu
</Link>
)
)}
{hasPermission('orders.edit') && order.valid_transitions?.filter(s => s !== 'stornovana').length > 0 && (
order.valid_transitions.filter(s => s !== 'stornovana').map(status => (
<button
key={status}
onClick={() => setStatusConfirm({ show: true, status })}
className={TRANSITION_CLASSES[status] || 'admin-btn admin-btn-secondary'}
disabled={statusChanging === status}
>
{statusChanging === status ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
) : (
TRANSITION_LABELS[status] || status
)}
</button>
))
)}
{hasPermission('orders.delete') && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
</motion.div>
{/* Info card */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form-row" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Nabídka</label>
<div>
<Link to={`/offers/${order.quotation_id}`} className="link-accent">
{order.quotation_number}
</Link>
{order.project_code && (
<span className="text-tertiary" style={{ marginLeft: '0.5rem' }}>({order.project_code})</span>
)}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Projekt</label>
<div>
{order.project ? (
<Link to={`/projects/${order.project.id}`} className="link-accent">
{order.project.project_number} {order.project.name}
</Link>
) : '—'}
</div>
</div>
</div>
<div className="admin-form-row admin-form-row-3" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Zákazník</label>
<div style={{ fontWeight: 500 }}>{order.customer_name || '—'}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Číslo obj. zákazníka</label>
<div>{order.customer_order_number || '—'}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Měna</label>
<div>{order.currency}</div>
</div>
</div>
<div className="admin-form-row admin-form-row-3" style={{ marginBottom: '0.5rem' }}>
<div className="admin-form-group">
<label className="admin-form-label">Datum vytvoření</label>
<div>{formatDate(order.created_at)}</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příloha</label>
<div>
{order.attachment_name ? (
<button
onClick={handleViewAttachment}
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}
disabled={attachmentLoading}
>
{attachmentLoading ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
)}
{order.attachment_name}
</button>
) : '—'}
</div>
</div>
</div>
</div>
</motion.div>
{/* Items (read-only) */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
{order.items?.length > 0 ? (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '8rem', textAlign: 'right', whiteSpace: 'nowrap' }}>Jedn. cena</th>
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
<th style={{ width: '9rem', textAlign: 'right', whiteSpace: 'nowrap' }}>Celkem</th>
</tr>
</thead>
<tbody>
{order.items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item.id || index}>
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<div style={{ fontWeight: 500 }}>{item.description || '—'}</div>
{item.item_description && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-tertiary)', marginTop: '0.25rem' }}>{item.item_description}</div>
)}
</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'center' }}>{item.unit || '—'}</td>
<td className="admin-mono" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>{formatCurrency(item.unit_price, order.currency)}</td>
<td style={{ textAlign: 'center' }}>{Number(item.is_included_in_total) ? 'Ano' : 'Ne'}</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>{formatCurrency(lineTotal, order.currency)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p style={{ color: 'var(--text-tertiary)' }}>Žádné položky.</p>
)}
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, order.currency)}</span>
</div>
{Number(order.apply_vat) > 0 && (
<div className="offers-totals-row">
<span>DPH ({order.vat_rate}%):</span>
<span>{formatCurrency(totals.vatAmount, order.currency)}</span>
</div>
)}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, order.currency)}</span>
</div>
</div>
</div>
</motion.div>
{/* Sections (read-only) */}
{order.sections?.length > 0 && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Rozsah projektu</h3>
{order.scope_title && (
<div style={{ fontWeight: 500, marginBottom: '0.5rem' }}>{order.scope_title}</div>
)}
{order.scope_description && (
<div style={{ color: 'var(--text-secondary)', marginBottom: '1rem' }}>{order.scope_description}</div>
)}
<div className="offers-scope-list">
{order.sections.map((section, index) => (
<div key={section.id || index} className="offers-scope-section" style={{ cursor: 'default' }}>
<div className="offers-scope-section-header">
<span className="offers-scope-number">{index + 1}.</span>
<span className="offers-scope-title">{(order.language === 'CZ' ? (section.title_cz || section.title) : (section.title || section.title_cz)) || `Sekce ${index + 1}`}</span>
</div>
{section.content && (
<div
className="offers-scope-content rich-text-view"
style={{ padding: '1rem' }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(section.content) }}
/>
)}
</div>
))}
</div>
</div>
</motion.div>
)}
{/* Notes (editable) */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
<div className="admin-form-group">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="admin-form-input"
rows={4}
placeholder="Interní poznámky k objednávce..."
disabled={!hasPermission('orders.edit')}
/>
</div>
{hasPermission('orders.edit') && (
<div style={{ marginTop: '0.5rem' }}>
<button
onClick={handleSaveNotes}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={saving}
>
{saving ? 'Ukládání...' : 'Uložit poznámky'}
</button>
</div>
)}
</div>
</motion.div>
{/* Status change confirmation */}
<ConfirmModal
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${STATUS_LABELS[statusConfirm.status]}"?${statusConfirm.status === 'dokoncena' ? ' Projekt bude automaticky dokončen.' : ''}`}
confirmText={TRANSITION_LABELS[statusConfirm.status] || 'Potvrdit'}
cancelText="Zrušit"
type="default"
/>
{/* Delete confirmation */}
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat objednávku"
message={`Opravdu chcete smazat objednávku "${order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

283
src/admin/pages/Orders.jsx Normal file
View File

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

View File

@@ -0,0 +1,277 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function ProjectCreate() {
const navigate = useNavigate()
const alert = useAlert()
const { hasPermission } = useAuth()
const [form, setForm] = useState({
project_number: '',
name: '',
customer_id: null,
customer_name: '',
start_date: new Date().toISOString().split('T')[0]
})
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState({})
const [loadingNumber, setLoadingNumber] = useState(true)
// Customer selector state
const [customers, setCustomers] = useState([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
// Load initial data
useEffect(() => {
const load = async () => {
try {
const [numRes, custRes] = await Promise.all([
apiFetch(`${API_BASE}/projects.php?action=next_number`),
apiFetch(`${API_BASE}/customers.php`)
])
const numData = await numRes.json()
if (numData.success) {
setForm(prev => ({ ...prev, project_number: numData.data.number }))
}
const custData = await custRes.json()
if (custData.success) {
setCustomers(custData.data.customers)
}
} catch {
alert.error('Chyba při načítání dat')
} finally {
setLoadingNumber(false)
}
}
load()
}, [alert])
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [showCustomerDropdown])
if (!hasPermission('projects.create')) return <Forbidden />
const selectCustomer = (customer) => {
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
setErrors(prev => ({ ...prev, customer_id: undefined }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
const clearCustomer = () => {
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
}
const updateForm = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
setErrors(prev => ({ ...prev, [field]: undefined }))
}
const handleSave = async () => {
const newErrors = {}
if (!form.name.trim()) newErrors.name = 'Název projektu je povinný'
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const body = {
name: form.name.trim(),
customer_id: form.customer_id,
start_date: form.start_date,
project_number: form.project_number.trim()
}
const res = await apiFetch(`${API_BASE}/projects.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
if (data.success) {
navigate(`/projects/${data.data.project_id}`, { state: { created: true } })
} else {
alert.error(data.error || 'Nepodařilo se vytvořit projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
if (loadingNumber) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">Nový projekt</h1>
<p className="admin-page-subtitle">Ruční vytvoření projektu</p>
</div>
</div>
<div className="admin-page-actions">
<button
onClick={handleSave}
disabled={saving}
className="admin-btn admin-btn-primary"
>
{saving ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
style={{ overflow: 'visible' }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Číslo projektu</label>
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm('project_number', e.target.value)}
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
/>
</div>
<div className={`admin-form-group${errors.name ? ' has-error' : ''}`}>
<label className="admin-form-label required">Název</label>
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
/>
{errors.name && <span className="admin-form-error">{errors.name}</span>}
</div>
</div>
<div className="admin-form-row">
<div className={`admin-form-group${errors.customer_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Zákazník</label>
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
) : (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
/>
{showCustomerDropdown && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map(c => (
<div
key={c.id}
className="offers-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>
{c.city && <div>{c.city}</div>}
</div>
))
)}
</div>
)}
</div>
)}
{errors.customer_id && <span className="admin-form-error">{errors.customer_id}</span>}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum zahájení</label>
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val) => updateForm('start_date', val)}
/>
</div>
</div>
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,550 @@
import { useState, useEffect } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const STATUS_LABELS = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
function formatNoteDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
const day = d.getDate()
const month = d.getMonth() + 1
const year = d.getFullYear()
const hours = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
return `${day}. ${month}. ${year} ${hours}:${mins}`
}
export default function ProjectDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [project, setProject] = useState(null)
const [form, setForm] = useState({
name: '',
status: 'aktivni',
start_date: '',
end_date: ''
})
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
// Dynamic notes
const [notes, setNotes] = useState([])
const [notesLoading, setNotesLoading] = useState(true)
const [newNote, setNewNote] = useState('')
const [addingNote, setAddingNote] = useState(false)
const [deletingNoteId, setDeletingNoteId] = useState(null)
useEffect(() => {
if (location.state?.created) {
alert.success('Projekt byl vytvořen')
// Clear state so it doesn't re-show on refresh
navigate(location.pathname, { replace: true, state: {} })
}
}, [location.state]) // eslint-disable-line react-hooks/exhaustive-deps
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects.php?action=notes&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setNotes(result.data.notes || [])
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false)
}
}
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects.php?action=detail&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const p = result.data
setProject(p)
setForm({
name: p.name || '',
status: p.status || 'aktivni',
start_date: (p.start_date || '').substring(0, 10),
end_date: (p.end_date || '').substring(0, 10)
})
} else {
alert.error(result.error || 'Nepodařilo se načíst projekt')
navigate('/projects')
}
} catch {
alert.error('Chyba připojení')
navigate('/projects')
} finally {
setLoading(false)
}
}
fetchDetail()
fetchNotes()
}, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission('projects.view')) return <Forbidden />
const updateForm = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const handleSave = async () => {
if (!form.name.trim()) {
alert.error('Název projektu je povinný')
return
}
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/projects.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null
})
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Projekt byl aktualizován')
} else {
alert.error(result.error || 'Nepodařilo se uložit projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/projects.php?id=${id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
navigate('/projects')
setTimeout(() => alert.success('Projekt byl smazán'), 300)
} else {
alert.error(result.error || 'Nepodařilo se smazat projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const handleAddNote = async () => {
if (!newNote.trim()) return
setAddingNote(true)
try {
const response = await apiFetch(`${API_BASE}/projects.php?action=add_note&id=${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newNote.trim() })
})
const result = await response.json()
if (result.success) {
setNotes(prev => [result.data.note, ...prev])
setNewNote('')
alert.success('Poznámka byla přidána')
} else {
alert.error(result.error || 'Nepodařilo se přidat poznámku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setAddingNote(false)
}
}
const handleDeleteNote = async (noteId) => {
setDeletingNoteId(noteId)
try {
const response = await apiFetch(`${API_BASE}/projects.php?action=delete_note&noteId=${noteId}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setNotes(prev => prev.filter(n => n.id !== noteId))
alert.success('Poznámka byla smazána')
} else {
alert.error(result.error || 'Nepodařilo se smazat poznámku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeletingNoteId(null)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-full" />
</div>
))}
</div>
</div>
</div>
)
}
if (!project) return null
const canEdit = hasPermission('projects.edit')
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
Projekt {project.project_number}
</h1>
</div>
</div>
{canEdit && (
<div className="admin-page-actions">
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (
<>
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
Ukládání...
</>
) : 'Uložit'}
</button>
{!project.order_id && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
)}
</motion.div>
{/* Form */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Číslo projektu</label>
<input
type="text"
value={project.project_number}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Název</label>
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
disabled={!canEdit}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Zákazník</label>
<input
type="text"
value={project.customer_name || '—'}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Stav</label>
<select
value={form.status}
onChange={(e) => updateForm('status', e.target.value)}
className="admin-form-select"
disabled={!canEdit}
>
<option value="aktivni">Aktivní</option>
<option value="dokonceny">Dokončený</option>
<option value="zruseny">Zrušený</option>
</select>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Datum zahájení</label>
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val) => updateForm('start_date', val)}
disabled={!canEdit}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum ukončení</label>
<AdminDatePicker
mode="date"
value={form.end_date}
onChange={(val) => updateForm('end_date', val)}
disabled={!canEdit}
/>
</div>
</div>
</div>
</div>
</motion.div>
{/* Notes */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
{/* Add note */}
<div style={{ marginBottom: '1rem' }}>
<textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
className="admin-form-input"
rows={2}
placeholder="Napište poznámku..."
style={{ resize: 'vertical', width: '100%' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey && newNote.trim()) {
handleAddNote()
}
}}
/>
<div style={{ marginTop: '0.5rem' }}>
<button
onClick={handleAddNote}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={addingNote || !newNote.trim()}
>
{addingNote ? (
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
) : (
'Přidat poznámku'
)}
</button>
</div>
</div>
{/* Legacy notes (read-only) */}
{project.notes && (
<div style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
marginBottom: '0.5rem',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.25rem' }}>
Starší poznámka (před zavedením systému)
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{project.notes}</div>
</div>
)}
{/* Notes list */}
{notesLoading && (
<div style={{ textAlign: 'center', padding: '1rem' }}>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
</div>
)}
{!notesLoading && notes.length === 0 && !project.notes && (
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem', textAlign: 'center', padding: '1rem 0' }}>
Zatím žádné poznámky
</div>
)}
{!notesLoading && (notes.length > 0 || project.notes) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{notes.map(note => (
<div
key={note.id}
style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
position: 'relative'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>
{note.user_name}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
{formatNoteDate(note.created_at)}
</span>
</div>
<div style={{ whiteSpace: 'pre-wrap', fontSize: '0.875rem', lineHeight: 1.5 }}>
{note.content}
</div>
</div>
{isAdmin && (
<button
onClick={() => handleDeleteNote(note.id)}
className="admin-btn-icon"
title="Smazat poznámku"
disabled={deletingNoteId === note.id}
style={{ flexShrink: 0, opacity: deletingNoteId === note.id ? 0.5 : 1 }}
>
{deletingNoteId === note.id ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
)}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
{/* Links */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Propojení</h3>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Objednávka</label>
<div>
{project.order_id ? (
<Link to={`/orders/${project.order_id}`} className="link-accent">
{project.order_number}
{project.order_status && (
<span className="text-tertiary" style={{ fontWeight: 400, marginLeft: '0.5rem' }}>
({STATUS_LABELS[project.order_status] || project.order_status})
</span>
)}
</Link>
) : '—'}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Nabídka</label>
<div>
{project.quotation_id ? (
<Link to={`/offers/${project.quotation_id}`} className="link-accent">
{project.quotation_number}
</Link>
) : '—'}
</div>
</div>
</div>
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat projekt"
message={`Opravdu chcete smazat projekt "${project.project_number} ${project.name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,949 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useModalLock from '../hooks/useModalLock'
import AdminDatePicker from '../components/AdminDatePicker'
const API_BASE = '/api/admin'
const STATUS_LABELS = { unpaid: 'Neuhrazena', paid: 'Uhrazena' }
const STATUS_CLASSES = { unpaid: 'admin-badge-invoice-overdue', paid: 'admin-badge-invoice-paid' }
const CURRENCY_OPTIONS = ['CZK', 'EUR', 'USD', 'GBP']
const VAT_RATE_OPTIONS = [0, 10, 12, 15, 21]
const MONTH_NAMES = [
'leden', 'únor', 'březen', 'duben', 'květen', 'červen',
'červenec', 'srpen', 'září', 'říjen', 'listopad', 'prosinec'
]
function formatMultiCurrency(amounts) {
if (!amounts || amounts.length === 0) { return '0 Kč' }
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
}
function formatCzkWithDetail(amounts, totalCzk) {
if (!amounts || amounts.length === 0) { return { value: '0 Kč', detail: null } }
const hasForeign = amounts.some(a => a.currency !== 'CZK')
if (hasForeign && totalCzk !== null && totalCzk !== undefined) {
return { value: formatCurrency(totalCzk, 'CZK'), detail: formatMultiCurrency(amounts) }
}
return { value: formatMultiCurrency(amounts), detail: null }
}
function emptyMeta() {
return {
supplier_name: '',
invoice_number: '',
amount: '',
currency: 'CZK',
vat_rate: '21',
issue_date: '',
due_date: '',
notes: '',
}
}
ReceivedInvoicesProps.displayName = 'ReceivedInvoices'
export default function ReceivedInvoicesProps({ statsMonth, statsYear, uploadOpen, setUploadOpen }) {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('created_at')
const [search, setSearch] = useState('')
// Data
const [invoices, setInvoices] = useState([])
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState(null)
const [statsLoading, setStatsLoading] = useState(true)
const hasLoadedOnce = useRef(false)
const slideDirection = useRef(0)
const [slideKey, setSlideKey] = useState(0)
const prevMonth = useRef(statsMonth)
const prevYear = useRef(statsYear)
// Modals
const [editOpen, setEditOpen] = useState(false)
const [editInvoice, setEditInvoice] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, invoice: null })
const [deleting, setDeleting] = useState(false)
const [saving, setSaving] = useState(false)
// Upload state
const [uploadFiles, setUploadFiles] = useState([])
const [uploadMeta, setUploadMeta] = useState([])
const [uploadErrors, setUploadErrors] = useState({})
const fileInputRef = useRef(null)
useModalLock(uploadOpen || editOpen)
// Slide direction detection
useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current
const curr = statsYear * 12 + statsMonth
if (curr > prev) { slideDirection.current = 1 }
if (curr < prev) { slideDirection.current = -1 }
prevMonth.current = statsMonth
prevYear.current = statsYear
}, [statsMonth, statsYear])
// Fetch list
const fetchList = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
month: String(statsMonth),
year: String(statsYear),
})
if (search) { params.set('search', search) }
if (sort) { params.set('sort', sort) }
if (order) { params.set('order', order) }
const res = await apiFetch(`${API_BASE}/received-invoices.php?${params}`)
const data = await res.json()
if (data.success) {
setInvoices(data.data.invoices || [])
}
} catch { /* ignore */ } finally {
setLoading(false)
}
}, [statsMonth, statsYear, search, sort, order])
useEffect(() => { fetchList() }, [fetchList])
// Fetch stats (tiché obnovení bez animace)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices.php?action=stats&month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
}
} catch { /* ignore */ }
}, [statsMonth, statsYear])
// Fetch stats při změně měsíce (se slide animací)
useEffect(() => {
setStatsLoading(true)
const load = async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices.php?action=stats&month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
setSlideKey(k => k + 1)
}
} catch { /* ignore */ } finally {
setStatsLoading(false)
}
}
load()
}, [statsMonth, statsYear])
// Upload handlers
const handleFileSelect = (e) => {
const selected = Array.from(e.target.files || [])
if (selected.length === 0) { return }
if (uploadFiles.length + selected.length > 20) {
alert.error('Maximálně 20 souborů najednou')
return
}
const valid = selected.filter(f => {
if (f.size > 10 * 1024 * 1024) {
alert.error(`Soubor "${f.name}" je větší než 10 MB`)
return false
}
const allowed = ['application/pdf', 'image/jpeg', 'image/png']
if (!allowed.includes(f.type)) {
alert.error(`Soubor "${f.name}": nepodporovaný formát`)
return false
}
return true
})
setUploadFiles(prev => [...prev, ...valid])
setUploadMeta(prev => [...prev, ...valid.map(() => emptyMeta())])
e.target.value = ''
}
const removeUploadFile = (idx) => {
setUploadFiles(prev => prev.filter((_, i) => i !== idx))
setUploadMeta(prev => prev.filter((_, i) => i !== idx))
const newErrors = { ...uploadErrors }
delete newErrors[idx]
setUploadErrors(newErrors)
}
const updateMeta = (idx, field, value) => {
setUploadMeta(prev => prev.map((m, i) => i === idx ? { ...m, [field]: value } : m))
if (uploadErrors[idx]) {
const newErrors = { ...uploadErrors }
if (newErrors[idx]?.[field]) {
delete newErrors[idx][field]
if (Object.keys(newErrors[idx]).length === 0) { delete newErrors[idx] }
}
setUploadErrors(newErrors)
}
}
const validateUpload = () => {
const errors = {}
uploadMeta.forEach((m, i) => {
const e = {}
if (!m.supplier_name.trim()) { e.supplier_name = 'Povinné pole' }
if (!m.amount || parseFloat(m.amount) <= 0) { e.amount = 'Částka musí být větší než 0' }
if (Object.keys(e).length > 0) { errors[i] = e }
})
setUploadErrors(errors)
return Object.keys(errors).length === 0
}
const handleUploadSave = async () => {
if (uploadFiles.length === 0) {
alert.error('Vyberte alespoň jeden soubor')
return
}
if (!validateUpload()) { return }
setSaving(true)
try {
const formData = new FormData()
uploadFiles.forEach(f => formData.append('files[]', f))
formData.append('invoices', JSON.stringify(uploadMeta))
const res = await apiFetch(`${API_BASE}/received-invoices.php`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktury byly nahrány')
setUploadOpen(false)
setUploadFiles([])
setUploadMeta([])
setUploadErrors({})
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při nahrávání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
// Edit handlers
const openEdit = (inv) => {
setEditInvoice({
...inv,
amount: String(inv.amount),
vat_rate: String(inv.vat_rate),
_originalStatus: inv.status,
})
setEditOpen(true)
}
const handleEditSave = async () => {
if (!editInvoice) { return }
if (!editInvoice.supplier_name?.trim()) {
alert.error('Dodavatel je povinný')
return
}
if (!editInvoice.amount || parseFloat(editInvoice.amount) <= 0) {
alert.error('Částka musí být větší než 0')
return
}
setSaving(true)
try {
const payload = {
supplier_name: editInvoice.supplier_name,
invoice_number: editInvoice.invoice_number || '',
amount: parseFloat(editInvoice.amount),
currency: editInvoice.currency,
vat_rate: parseFloat(editInvoice.vat_rate),
issue_date: editInvoice.issue_date || '',
due_date: editInvoice.due_date || '',
notes: editInvoice.notes || '',
status: editInvoice.status,
}
const res = await apiFetch(`${API_BASE}/received-invoices.php?id=${editInvoice.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktura byla aktualizována')
setEditOpen(false)
setEditInvoice(null)
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při ukládání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
// Delete
const handleDelete = async () => {
if (!deleteConfirm.invoice) { return }
setDeleting(true)
try {
const res = await apiFetch(`${API_BASE}/received-invoices.php?id=${deleteConfirm.invoice.id}`, {
method: 'DELETE',
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktura byla smazána')
setDeleteConfirm({ show: false, invoice: null })
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při mazání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
// View file
const openFile = async (inv) => {
const newWindow = window.open('', '_blank')
try {
const response = await apiFetch(`${API_BASE}/received-invoices.php?action=file&id=${inv.id}`)
if (!response.ok) {
newWindow.close()
alert.error('Nepodařilo se načíst soubor')
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
newWindow.location.href = url
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
newWindow.close()
alert.error('Chyba připojení')
}
}
const toggleStatus = async (inv) => {
if (inv.status === 'paid') return
try {
const res = await apiFetch(`${API_BASE}/received-invoices.php?id=${inv.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'paid' }),
})
const data = await res.json()
if (data.success) {
alert.success('Faktura označena jako uhrazená')
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
}
}
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`
// KPI
const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) {
return (
<div className="dash-kpi-grid dash-kpi-4" style={{ marginBottom: '1.5rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
)
}
if (!stats) { return null }
const total = formatCzkWithDetail(stats.total_month, stats.total_month_czk)
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk)
const unpaid = formatCzkWithDetail(stats.unpaid, stats.unpaid_czk)
return (
<div style={{ overflow: 'hidden', marginBottom: '1.5rem' }}>
<AnimatePresence mode="popLayout" initial={false} custom={slideDirection.current}>
<motion.div
key={slideKey}
className="dash-kpi-grid dash-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (dir) => ({ x: `${(dir || 0) * -105}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<div className="admin-stat-card success">
<div className="admin-stat-label">Celkem ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{total.value}</div>
<div className="admin-stat-footer">
{total.detail || `${stats.month_count} ${czechPlural(stats.month_count, 'faktura', 'faktury', 'faktur')}`}
</div>
</div>
<div className="admin-stat-card info">
<div className="admin-stat-label">DPH k odpočtu ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{vat.value}</div>
<div className="admin-stat-footer">{vat.detail || 'z přijatých faktur'}</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">Neuhrazeno <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
<div className="admin-stat-value admin-mono">{unpaid.value}</div>
<div className="admin-stat-footer">
{unpaid.detail || (stats.unpaid_count === 0
? 'vše uhrazeno'
: `${stats.unpaid_count} ${czechPlural(stats.unpaid_count, 'faktura', 'faktury', 'faktur')}`
)}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Počet ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{stats.month_count}</div>
<div className="admin-stat-footer">
{stats.month_count === 0 ? 'žádné faktury' : `přijatých faktur`}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
)
}
return (
<>
{renderKpi()}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="admin-card-body">
<div className="admin-search-bar" style={{ marginBottom: '1rem' }}>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat podle dodavatele nebo čísla faktury..."
/>
</div>
{loading && (
<div className="admin-skeleton" style={{ gap: '1rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && invoices.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<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" />
</svg>
</div>
<p>Žádné přijaté faktury v tomto měsíci.</p>
{hasPermission('invoices.create') && (
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
Nahrajte faktury tlačítkem výše.
</p>
)}
</div>
)}
{!loading && invoices.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('supplier_name')}>
Dodavatel <SortIcon column="supplier_name" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('invoice_number')}>
Č. faktury <SortIcon column="invoice_number" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('issue_date')}>
Vystaveno <SortIcon column="issue_date" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('due_date')}>
Splatnost <SortIcon column="due_date" sort={activeSort} order={order} />
</th>
<th style={{ textAlign: 'right', cursor: 'pointer' }} onClick={() => handleSort('amount')}>
Částka <SortIcon column="amount" sort={activeSort} order={order} />
</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id}>
<td>{inv.supplier_name}</td>
<td className="admin-mono">
{inv.invoice_number ? (
<span className="link-accent" style={{ cursor: 'pointer' }} onClick={() => openFile(inv)}>
{inv.invoice_number}
</span>
) : '—'}
</td>
<td>
{inv.status === 'paid' ? (
<span className={`admin-badge ${STATUS_CLASSES[inv.status]}`}>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ''}`}
style={{ cursor: 'pointer' }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">{formatDate(inv.issue_date)}</td>
<td className="admin-mono">{formatDate(inv.due_date)}</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 500 }}>
{formatCurrency(inv.amount, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
{inv.file_name && (
<button className="admin-btn-icon" title="Zobrazit soubor" onClick={() => openFile(inv)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
)}
{hasPermission('invoices.edit') && (
<button className="admin-btn-icon" title="Upravit" onClick={() => openEdit(inv)}>
<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>
)}
{hasPermission('invoices.delete') && (
<button
className="admin-btn-icon danger"
title="Smazat"
onClick={() => setDeleteConfirm({ show: true, invoice: inv })}
>
<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>
)}
</div>
</motion.div>
{/* Upload Modal */}
<AnimatePresence>
{uploadOpen && (
<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={() => !saving && setUploadOpen(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">Nahrát přijaté faktury</h2>
</div>
<div className="admin-modal-body">
<div style={{ marginBottom: '1rem' }}>
<input
ref={fileInputRef}
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<button
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => fileInputRef.current?.click()}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubory
</button>
<span style={{ marginLeft: '0.75rem', fontSize: '0.8125rem', color: 'var(--text-tertiary)' }}>
PDF, JPEG, PNG · max 10 MB · max 20 souborů
</span>
</div>
{uploadFiles.length === 0 && (
<div className="admin-empty-state" style={{ padding: '2rem 0' }}>
<p style={{ color: 'var(--text-tertiary)' }}>Zatím nebyly vybrány žádné soubory.</p>
</div>
)}
<div className="received-upload-list">
{uploadFiles.map((file, idx) => (
<div key={`${file.name}-${idx}`} className="received-upload-card">
<div className="received-upload-card-header">
<div className="received-upload-file-info">
<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>
<span className="received-upload-file-name">{file.name}</span>
<span className="received-upload-file-size">{Math.round(file.size / 1024)} KB</span>
</div>
<button className="admin-btn-icon danger" style={{ width: '24px', height: '24px' }} onClick={() => removeUploadFile(idx)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="received-upload-card-fields">
<div className="admin-form-group">
<label className="admin-form-label required">Dodavatel</label>
<input
type="text"
className={`admin-form-input${uploadErrors[idx]?.supplier_name ? ' has-error' : ''}`}
value={uploadMeta[idx]?.supplier_name || ''}
onChange={(e) => updateMeta(idx, 'supplier_name', e.target.value)}
/>
{uploadErrors[idx]?.supplier_name && (
<div className="admin-form-error">{uploadErrors[idx].supplier_name}</div>
)}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Č. faktury</label>
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.invoice_number || ''}
onChange={(e) => updateMeta(idx, 'invoice_number', e.target.value)}
/>
</div>
<div className="received-upload-row">
<div className="admin-form-group" style={{ flex: 1 }}>
<label className="admin-form-label required">Částka</label>
<input
type="number"
step="0.01"
min="0"
className={`admin-form-input${uploadErrors[idx]?.amount ? ' has-error' : ''}`}
value={uploadMeta[idx]?.amount || ''}
onChange={(e) => updateMeta(idx, 'amount', e.target.value)}
/>
{uploadErrors[idx]?.amount && (
<div className="admin-form-error">{uploadErrors[idx].amount}</div>
)}
</div>
<div className="admin-form-group" style={{ width: '90px' }}>
<label className="admin-form-label">Měna</label>
<select
className="admin-form-select"
value={uploadMeta[idx]?.currency || 'CZK'}
onChange={(e) => updateMeta(idx, 'currency', e.target.value)}
>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="admin-form-group" style={{ width: '90px' }}>
<label className="admin-form-label">DPH %</label>
<select
className="admin-form-select"
value={uploadMeta[idx]?.vat_rate || '21'}
onChange={(e) => updateMeta(idx, 'vat_rate', e.target.value)}
>
{VAT_RATE_OPTIONS.map(r => <option key={r} value={String(r)}>{r}%</option>)}
</select>
</div>
</div>
{uploadMeta[idx]?.amount && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginTop: '-0.25rem', marginBottom: '0.5rem' }}>
DPH: {formatCurrency(
parseFloat(uploadMeta[idx].amount || 0) * parseFloat(uploadMeta[idx].vat_rate || 21) / 100,
uploadMeta[idx].currency || 'CZK'
)}
</div>
)}
<div className="received-upload-row">
<div className="admin-form-group" style={{ flex: 1 }}>
<label className="admin-form-label">Datum vystavení</label>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.issue_date || ''}
onChange={(val) => updateMeta(idx, 'issue_date', val)}
/>
</div>
<div className="admin-form-group" style={{ flex: 1 }}>
<label className="admin-form-label">Datum splatnosti</label>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.due_date || ''}
onChange={(val) => updateMeta(idx, 'due_date', val)}
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.notes || ''}
onChange={(e) => updateMeta(idx, 'notes', e.target.value)}
/>
</div>
</div>
</div>
))}
</div>
</div>
<div className="admin-modal-footer">
<button className="admin-btn admin-btn-secondary" onClick={() => !saving && setUploadOpen(false)} disabled={saving}>
Zrušit
</button>
<button className="admin-btn admin-btn-primary" onClick={handleUploadSave} disabled={saving || uploadFiles.length === 0}>
{saving ? 'Nahrávání...' : 'Uložit vše'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Edit Modal */}
<AnimatePresence>
{editOpen && editInvoice && (
<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={() => !saving && setEditOpen(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 }}
>
{(() => {
const ro = editInvoice._originalStatus === 'paid'
return (
<>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{ro ? 'Detail přijaté faktury' : 'Upravit přijatou fakturu'}</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label required">Dodavatel</label>
<input
type="text"
className="admin-form-input"
value={editInvoice.supplier_name}
onChange={(e) => setEditInvoice(prev => ({ ...prev, supplier_name: e.target.value }))}
readOnly={ro}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Č. faktury</label>
<input
type="text"
className="admin-form-input"
value={editInvoice.invoice_number || ''}
onChange={(e) => setEditInvoice(prev => ({ ...prev, invoice_number: e.target.value }))}
readOnly={ro}
/>
</div>
<div className="admin-form-row admin-form-row-3">
<div className="admin-form-group">
<label className="admin-form-label required">Částka</label>
<input
type="number"
step="0.01"
min="0"
className="admin-form-input"
value={editInvoice.amount}
onChange={(e) => setEditInvoice(prev => ({ ...prev, amount: e.target.value }))}
readOnly={ro}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Měna</label>
<select
className="admin-form-select"
value={editInvoice.currency}
onChange={(e) => setEditInvoice(prev => ({ ...prev, currency: e.target.value }))}
disabled={ro}
>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DPH %</label>
<select
className="admin-form-select"
value={editInvoice.vat_rate}
onChange={(e) => setEditInvoice(prev => ({ ...prev, vat_rate: e.target.value }))}
disabled={ro}
>
{VAT_RATE_OPTIONS.map(r => <option key={r} value={String(r)}>{r}%</option>)}
</select>
</div>
</div>
{editInvoice.amount && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.75rem' }}>
DPH: {formatCurrency(
parseFloat(editInvoice.amount || 0) * parseFloat(editInvoice.vat_rate || 21) / 100,
editInvoice.currency || 'CZK'
)}
</div>
)}
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Datum vystavení</label>
<AdminDatePicker
mode="date"
value={editInvoice.issue_date || ''}
onChange={(val) => setEditInvoice(prev => ({ ...prev, issue_date: val }))}
disabled={ro}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum splatnosti</label>
<AdminDatePicker
mode="date"
value={editInvoice.due_date || ''}
onChange={(val) => setEditInvoice(prev => ({ ...prev, due_date: val }))}
disabled={ro}
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Stav</label>
<select
className="admin-form-select"
value={editInvoice.status}
onChange={(e) => setEditInvoice(prev => ({ ...prev, status: e.target.value }))}
disabled={ro}
>
<option value="unpaid">Neuhrazena</option>
<option value="paid">Uhrazena</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
className="admin-form-input"
rows={3}
value={editInvoice.notes || ''}
onChange={(e) => setEditInvoice(prev => ({ ...prev, notes: e.target.value }))}
readOnly={ro}
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
{ro ? (
<button className="admin-btn admin-btn-secondary" onClick={() => setEditOpen(false)}>
Zavřít
</button>
) : (
<>
<button className="admin-btn admin-btn-secondary" onClick={() => !saving && setEditOpen(false)} disabled={saving}>
Zrušit
</button>
<button className="admin-btn admin-btn-primary" onClick={handleEditSave} disabled={saving}>
{saving ? 'Ukládání...' : 'Uložit'}
</button>
</>
)}
</div>
</>
)
})()}
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat přijatou fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.supplier_name || ''}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,630 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const MODULE_LABELS = {
attendance: 'Docházka',
trips: 'Kniha jízd',
offers: 'Nabídky',
orders: 'Objednávky',
projects: 'Projekty',
invoices: 'Faktury',
users: 'Uživatelé',
settings: 'Nastavení'
}
export default function Settings() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [roles, setRoles] = useState([])
const [, setAllPermissions] = useState([])
const [permissionGroups, setPermissionGroups] = useState({})
// 2FA requirement
const [require2FA, setRequire2FA] = useState(false)
const [require2FALoading, setRequire2FALoading] = useState(true)
const [require2FASaving, setRequire2FASaving] = useState(false)
const [showModal, setShowModal] = useState(false)
const [editingRole, setEditingRole] = useState(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
name: '',
display_name: '',
description: '',
permissions: []
})
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, role: null })
const [deleting, setDeleting] = useState(false)
const canRoles = hasPermission('settings.roles')
const canSecurity = hasPermission('settings.security')
useEffect(() => {
if (!canRoles && !canSecurity) {
navigate('/')
}
}, [canRoles, canSecurity, navigate])
useModalLock(showModal)
const fetchData = useCallback(async () => {
if (!canRoles) {
setLoading(false)
return
}
try {
const response = await apiFetch(`${API_BASE}/roles.php`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setRoles(result.data.roles)
setAllPermissions(result.data.permissions)
setPermissionGroups(result.data.permission_groups)
} else {
alert.error(result.error || 'Nepodařilo se načíst role')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert, canRoles])
useEffect(() => {
fetchData()
}, [fetchData])
const fetch2FARequired = useCallback(async () => {
if (!canSecurity) {
setRequire2FALoading(false)
return
}
try {
const response = await apiFetch(`${API_BASE}/totp.php?action=get_required`)
const result = await response.json()
if (result.success) {
setRequire2FA(result.data.require_2fa)
}
} catch {
// ignore
} finally {
setRequire2FALoading(false)
}
}, [canSecurity])
useEffect(() => {
fetch2FARequired()
}, [fetch2FARequired])
const handleToggle2FARequired = async () => {
setRequire2FASaving(true)
try {
const response = await apiFetch(`${API_BASE}/totp.php?action=set_required`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ required: !require2FA })
})
const result = await response.json()
if (result.success) {
setRequire2FA(result.data.require_2fa)
alert.success(result.message)
} else {
alert.error(result.error || 'Nepodařilo se uložit nastavení')
}
} catch {
alert.error('Chyba připojení')
} finally {
setRequire2FASaving(false)
}
}
const generateSlug = (text) => {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const openCreateModal = () => {
setEditingRole(null)
setForm({
name: '',
display_name: '',
description: '',
permissions: []
})
setShowModal(true)
}
const openEditModal = (role) => {
setEditingRole(role)
setForm({
name: role.name,
display_name: role.display_name,
description: role.description || '',
permissions: role.permissions || []
})
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingRole(null)
}
const handleDisplayNameChange = (value) => {
const updates = { display_name: value }
if (!editingRole) {
updates.name = generateSlug(value)
}
setForm(prev => ({ ...prev, ...updates }))
}
const togglePermission = (permName) => {
setForm(prev => ({
...prev,
permissions: prev.permissions.includes(permName)
? prev.permissions.filter(p => p !== permName)
: [...prev.permissions, permName]
}))
}
const toggleModulePermissions = (moduleName) => {
const modulePerms = (permissionGroups[moduleName] || []).map(p => p.name)
const allChecked = modulePerms.every(p => form.permissions.includes(p))
setForm(prev => ({
...prev,
permissions: allChecked
? prev.permissions.filter(p => !modulePerms.includes(p))
: [...new Set([...prev.permissions, ...modulePerms])]
}))
}
const handleSubmit = async (e) => {
e?.preventDefault()
if (!form.display_name.trim()) {
alert.error('Zobrazovaný název je povinný')
return
}
if (!editingRole && !form.name.trim()) {
alert.error('Název role je povinný')
return
}
setSaving(true)
try {
const url = editingRole
? `${API_BASE}/roles.php?id=${editingRole.id}`
: `${API_BASE}/roles.php`
const response = await apiFetch(url, {
method: editingRole ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
const result = await response.json()
if (result.success) {
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message || (editingRole ? 'Role byla aktualizována' : 'Role byla vytvořena'))
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se uložit roli')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.role) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/roles.php?id=${deleteConfirm.role.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, role: null })
alert.success(result.message || 'Role byla smazána')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat roli')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
const isAdminRole = (role) => role.name === 'admin'
function get2FADescription() {
if (require2FALoading) return 'Načítání...'
if (require2FA) return 'Všichni uživatelé musí mít aktivní 2FA pro přístup do systému'
return '2FA je volitelná - uživatelé si ji mohou aktivovat v profilu'
}
function get2FAButtonLabel() {
if (require2FASaving) return 'Ukládání...'
return require2FA ? 'Vypnout' : 'Zapnout'
}
function renderRoleButtonContent() {
if (saving) {
return <><div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />Ukládání...</>
}
return editingRole ? 'Uložit změny' : 'Vytvořit roli'
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Nastavení</h1>
<p className="admin-page-subtitle">Zabezpečení a správa rolí</p>
</div>
{canRoles && (
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat roli
</button>
)}
</motion.div>
{/* Security Settings */}
{canSecurity && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Zabezpečení</h2>
</div>
<div className="admin-card-body">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: require2FA ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
color: require2FA ? 'var(--success)' : 'var(--text-secondary)',
flexShrink: 0
}}>
<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, color: 'var(--text-primary)', fontSize: '0.875rem' }}>
Povinné dvoufaktorové ověření (2FA)
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{get2FADescription()}
</div>
</div>
</div>
{!require2FALoading && (
<button
onClick={handleToggle2FARequired}
disabled={require2FASaving}
className={`admin-btn admin-btn-sm ${require2FA ? 'admin-btn-secondary' : 'admin-btn-primary'}`}
style={require2FA ? { color: 'var(--danger)' } : {}}
>
{get2FAButtonLabel()}
</button>
)}
</div>
</div>
</motion.div>
)}
{/* Roles Table */}
{canRoles && <motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-table-wrapper">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Popis</th>
<th>Oprávnění</th>
<th>Uživatelé</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{roles.map((role) => (
<tr key={role.id}>
<td>
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>
{role.display_name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{role.name}
</div>
</td>
<td style={{ color: 'var(--text-secondary)' }}>
{role.description || '—'}
</td>
<td>
<span className="admin-badge admin-badge-info">
{isAdminRole(role) ? 'Vše' : role.permission_count}
</span>
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{role.user_count}
</span>
</td>
<td>
{!isAdminRole(role) && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => openEditModal(role)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="16" height="16" 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={() => setDeleteConfirm({ show: true, role })}
className="admin-btn-icon danger"
title={role.user_count > 0 ? 'Nelze smazat roli s přiřazenými uživateli' : 'Smazat'}
aria-label={role.user_count > 0 ? 'Nelze smazat roli s přiřazenými uživateli' : 'Smazat'}
disabled={role.user_count > 0}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 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>
</motion.div>}
{/* Create/Edit 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={closeModal} />
<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">
{editingRole ? 'Upravit roli' : 'Nová role'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
{editingRole && isAdminRole(editingRole) && (
<div className="admin-role-locked-notice">
<svg width="16" height="16" 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>
Administrátor vždy plný přístup ke všem funkcím
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Zobrazovaný název</label>
<input
type="text"
value={form.display_name}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="admin-form-input"
placeholder="např. Manažer"
disabled={editingRole && isAdminRole(editingRole)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Systémový název (slug)</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
className="admin-form-input"
placeholder="např. manager"
disabled={!!editingRole}
/>
{!editingRole && (
<small style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
Pouze malá písmena, čísla a pomlčky. Nelze později změnit.
</small>
)}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Popis</label>
<textarea
value={form.description}
onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}
className="admin-form-input"
rows={2}
placeholder="Volitelný popis role"
disabled={editingRole && isAdminRole(editingRole)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label" style={{ marginBottom: '0.75rem' }}>Oprávnění</label>
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
if (a === 'settings') return 1
if (b === 'settings') return -1
const aMin = Math.min(...aPerms.map(p => p.id))
const bMin = Math.min(...bPerms.map(p => p.id))
return aMin - bMin
})
.map(([module, perms], index) => {
const modulePerms = perms.map(p => p.name)
const allChecked = modulePerms.every(p => form.permissions.includes(p))
const someChecked = modulePerms.some(p => form.permissions.includes(p))
const disabled = editingRole && isAdminRole(editingRole)
return (
<div key={module}>
{index > 0 && <hr style={{ border: 'none', borderTop: '1px solid var(--border-color, #e0e0e0)', margin: '0.75rem 0' }} />}
<div className="admin-permission-group">
<div className="admin-permission-group-title">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el) el.indeterminate = someChecked && !allChecked
}}
onChange={() => toggleModulePermissions(module)}
disabled={disabled}
/>
<span>{MODULE_LABELS[module] || module}</span>
</label>
</div>
<div className="admin-permission-list">
{perms.map((perm) => (
<div key={perm.id} className="admin-permission-item">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.permissions.includes(perm.name)}
onChange={() => togglePermission(perm.name)}
disabled={disabled}
/>
<span>{perm.display_name}</span>
</label>
{perm.description && (
<div className="admin-permission-desc">{perm.description}</div>
)}
</div>
))}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary" disabled={saving}>
Zrušit
</button>
{!(editingRole && isAdminRole(editingRole)) && (
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{renderRoleButtonContent()}
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirm Modal */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, role: null })}
onConfirm={handleDelete}
title="Smazat roli"
message={`Opravdu chcete smazat roli "${deleteConfirm.role?.display_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

653
src/admin/pages/Trips.jsx Normal file
View File

@@ -0,0 +1,653 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function Trips() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [data, setData] = useState({
trips: [],
vehicles: [],
month: '',
totals: { total: 0, business: 0, private: 0, count: 0 }
})
const [showModal, setShowModal] = useState(false)
const [editingTrip, setEditingTrip] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, tripId: null })
const [form, setForm] = useState({
vehicle_id: '',
trip_date: new Date().toISOString().split('T')[0],
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
const [errors, setErrors] = useState({})
const [, setLastKm] = useState(0)
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/trips.php`, {
})
const result = await response.json()
if (result.success) {
setData(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showModal)
if (!hasPermission('trips.record')) return <Forbidden />
const fetchLastKm = async (vehicleId) => {
if (!vehicleId) {
setLastKm(0)
return
}
try {
const response = await apiFetch(`${API_BASE}/trips.php?action=last_km&vehicle_id=${vehicleId}`, {
})
const result = await response.json()
if (result.success) {
setLastKm(result.data.last_km)
if (!editingTrip) {
setForm(prev => ({ ...prev, start_km: result.data.last_km }))
}
}
} catch {
if (import.meta.env.DEV) console.error('Failed to fetch last km')
}
}
const openCreateModal = () => {
setEditingTrip(null)
const today = new Date().toISOString().split('T')[0]
setForm({
vehicle_id: '',
trip_date: today,
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
setLastKm(0)
setErrors({})
setShowModal(true)
}
const openEditModal = (trip) => {
setEditingTrip(trip)
setForm({
vehicle_id: trip.vehicle_id,
trip_date: trip.trip_date,
start_km: trip.start_km,
end_km: trip.end_km,
route_from: trip.route_from,
route_to: trip.route_to,
is_business: trip.is_business,
notes: trip.notes || ''
})
setLastKm(trip.start_km)
setErrors({})
setShowModal(true)
}
const handleVehicleChange = (vehicleId) => {
setForm(prev => ({ ...prev, vehicle_id: vehicleId }))
fetchLastKm(vehicleId)
}
const validateForm = () => {
const newErrors = {}
if (!form.vehicle_id) newErrors.vehicle_id = 'Vyberte vozidlo'
if (!form.trip_date) newErrors.trip_date = 'Zadejte datum'
if (!form.start_km) newErrors.start_km = 'Zadejte počáteční km'
if (!form.end_km) newErrors.end_km = 'Zadejte konečný km'
if (form.start_km && form.end_km && parseInt(form.end_km) <= parseInt(form.start_km)) {
newErrors.end_km = 'Musí být větší než počáteční'
}
if (!form.route_from) newErrors.route_from = 'Zadejte místo odjezdu'
if (!form.route_to) newErrors.route_to = 'Zadejte místo příjezdu'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async () => {
if (!validateForm()) return
setSubmitting(true)
try {
const url = editingTrip
? `${API_BASE}/trips.php?id=${editingTrip.id}`
: `${API_BASE}/trips.php`
const response = await apiFetch(url, {
method: editingTrip ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
const result = await response.json()
if (result.success) {
setShowModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (tripId) => {
try {
const response = await apiFetch(`${API_BASE}/trips.php?id=${tripId}`, {
method: 'DELETE',
})
const result = await response.json()
if (result.success) {
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleteConfirm({ show: false, tripId: null })
}
}
const calculateDistance = () => {
const start = parseInt(form.start_km) || 0
const end = parseInt(form.end_km) || 0
return end > start ? end - start : 0
}
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ 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 className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-3/4" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ 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 className="admin-skeleton-line w-1/4" />
</div>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
</div>
</div>
</div>
</div>
)
}
const { trips, vehicles, totals } = data
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Kniha jízd</h1>
<p className="admin-page-subtitle">
{new Date().toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })}
</p>
</div>
<div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat jízdu
</button>
</div>
</motion.div>
{/* Stats Cards */}
<motion.div
className="admin-grid admin-grid-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-label">Služební</span>
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-icon warning">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.private)} km</span>
<span className="admin-stat-label">Soukromé</span>
</div>
</div>
</motion.div>
{/* Recent Trips */}
<motion.div
className="admin-card"
style={{ marginTop: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 className="admin-card-title">Poslední jízdy</h2>
<Link to="/trips/history" className="admin-btn admin-btn-secondary admin-btn-sm">
Zobrazit historii
</Link>
</div>
<div className="admin-card-body">
{trips.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<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" />
</svg>
</div>
<p>Zatím nemáte žádné záznamy jízd.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
Přidat první jízdu
</button>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.slice(0, 10).map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>{trip.driver_name}</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
{trip.route_from} {trip.route_to}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
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" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
)}
</div>
</motion.div>
{/* Add/Edit 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 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">
{editingTrip ? 'Upravit jízdu' : '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${errors.vehicle_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Vozidlo</label>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value)
setErrors(prev => ({ ...prev, vehicle_id: undefined }))
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
{errors.vehicle_id && <span className="admin-form-error">{errors.vehicle_id}</span>}
</div>
<div className={`admin-form-group${errors.trip_date ? ' has-error' : ''}`}>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={form.trip_date}
onChange={(val) => {
setForm({ ...form, trip_date: val })
setErrors(prev => ({ ...prev, trip_date: undefined }))
}}
/>
{errors.trip_date && <span className="admin-form-error">{errors.trip_date}</span>}
</div>
</div>
<div className="admin-form-row admin-form-row-3">
<div className={`admin-form-group${errors.start_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value })
setErrors(prev => ({ ...prev, start_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{errors.start_km && <span className="admin-form-error">{errors.start_km}</span>}
</div>
<div className={`admin-form-group${errors.end_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Konečný stav km</label>
<input
type="number"
inputMode="numeric"
value={form.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value })
setErrors(prev => ({ ...prev, end_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{errors.end_km && <span className="admin-form-error">{errors.end_km}</span>}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label>
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</div>
</div>
<div className="admin-form-row">
<div className={`admin-form-group${errors.route_from ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
value={form.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value })
setErrors(prev => ({ ...prev, route_from: undefined }))
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
{errors.route_from && <span className="admin-form-error">{errors.route_from}</span>}
</div>
<div className={`admin-form-group${errors.route_to ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo příjezdu</label>
<input
type="text"
value={form.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value })
setErrors(prev => ({ ...prev, route_to: undefined }))
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
{errors.route_to && <span className="admin-form-error">{errors.route_to}</span>}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label>
<select
value={form.is_business}
onChange={(e) => setForm({ ...form, 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={form.notes}
onChange={(e) => setForm({ ...form, 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={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting}
>
{submitting ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
onConfirm={() => handleDelete(deleteConfirm.tripId)}
title="Smazat jízdu"
message="Opravdu chcete smazat tento záznam?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
/>
</div>
)
}

View File

@@ -0,0 +1,755 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import DOMPurify from 'dompurify'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import useModalLock from '../hooks/useModalLock'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function TripsAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`
})
const [dateTo, setDateTo] = useState(() => {
const now = new Date()
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
})
const [filterVehicleId, setFilterVehicleId] = useState('')
const [filterUserId, setFilterUserId] = useState('')
const [data, setData] = useState({
trips: [],
vehicles: [],
users: [],
totals: { total: 0, business: 0, count: 0 }
})
const [printData, setPrintData] = useState(null)
const printRef = useRef(null)
const [showEditModal, setShowEditModal] = useState(false)
const [editingTrip, setEditingTrip] = useState(null)
const [editForm, setEditForm] = useState({
vehicle_id: '',
trip_date: '',
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, trip: null })
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
let url = `${API_BASE}/trips.php?action=admin&date_from=${dateFrom}&date_to=${dateTo}`
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`
if (filterUserId) url += `&user_id=${filterUserId}`
const response = await apiFetch(url)
const result = await response.json()
if (result.success) {
setData(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [dateFrom, dateTo, filterVehicleId, filterUserId, alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showEditModal)
if (!hasPermission('trips.admin')) return <Forbidden />
const openEditModal = (trip) => {
setEditingTrip(trip)
setEditForm({
vehicle_id: trip.vehicle_id,
trip_date: trip.trip_date,
start_km: trip.start_km,
end_km: trip.end_km,
route_from: trip.route_from,
route_to: trip.route_to,
is_business: trip.is_business,
notes: trip.notes || ''
})
setShowEditModal(true)
}
const handleEditSubmit = async () => {
if (parseInt(editForm.end_km) <= parseInt(editForm.start_km)) {
alert.error('Konečný stav km musí být větší než počáteční')
return
}
try {
const response = await apiFetch(`${API_BASE}/trips.php?id=${editingTrip.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
})
const result = await response.json()
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const handleDelete = async () => {
if (!deleteConfirm.trip) return
try {
const response = await apiFetch(`${API_BASE}/trips.php?id=${deleteConfirm.trip.id}`, {
method: 'DELETE',
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, trip: null })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const handlePrint = async () => {
try {
let url = `${API_BASE}/trips.php?action=print&date_from=${dateFrom}&date_to=${dateTo}`
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`
if (filterUserId) url += `&user_id=${filterUserId}`
const response = await apiFetch(url)
const result = await response.json()
if (result.success) {
setPrintData(result.data)
setTimeout(() => {
if (printRef.current) {
const printWindow = window.open('', '_blank')
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${result.data.period_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.print-logo {
height: 40px;
width: auto;
}
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
</style>
</head>
<body>
${DOMPurify.sanitize(printRef.current.innerHTML)}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
}, 100)
}
} catch {
alert.error('Nepodařilo se připravit tisk')
}
}
const calculateDistance = () => {
const start = parseInt(editForm.start_km) || 0
const end = parseInt(editForm.end_km) || 0
return end > start ? end - start : 0
}
const { trips, vehicles, users, totals } = data
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Správa knihy jízd</h1>
</div>
<div className="admin-page-actions">
{trips.length > 0 && (
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk knihy jízd"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
)}
<Link to="/vehicles" className="admin-btn admin-btn-secondary">
Vozidla
</Link>
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<div className="admin-form-row admin-form-row-4">
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Od</label>
<AdminDatePicker
mode="date"
value={dateFrom}
onChange={(val) => setDateFrom(val)}
/>
</div>
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Do</label>
<AdminDatePicker
mode="date"
value={dateTo}
onChange={(val) => setDateTo(val)}
/>
</div>
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Vozidlo</label>
<select
value={filterVehicleId}
onChange={(e) => setFilterVehicleId(e.target.value)}
className="admin-form-select"
>
<option value="">Všechna vozidla</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</div>
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Řidič</label>
<select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni řidiči</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
</div>
</div>
</motion.div>
<motion.div
className="admin-grid admin-grid-3"
style={{ marginTop: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
</motion.div>
{/* Trips Table */}
<motion.div
className="admin-card"
style={{ marginTop: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Řidič</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
{trip.route_from} {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: 'nowrap' }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
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" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, trip })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
)}
</div>
</motion.div>
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingTrip && (
<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={() => setShowEditModal(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">Upravit jízdu</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
{editingTrip.driver_name}
</p>
</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">Vozidlo</label>
<select
value={editForm.vehicle_id}
onChange={(e) => setEditForm({ ...editForm, vehicle_id: e.target.value })}
className="admin-form-select"
>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val) => setEditForm({ ...editForm, trip_date: val })}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) => setEditForm({ ...editForm, start_km: e.target.value })}
className="admin-form-input"
min="0"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konečný stav km</label>
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) => setEditForm({ ...editForm, end_km: e.target.value })}
className="admin-form-input"
min="0"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label>
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Místo odjezdu</label>
<input
type="text"
value={editForm.route_from}
onChange={(e) => setEditForm({ ...editForm, route_from: e.target.value })}
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Místo příjezdu</label>
<input
type="text"
value={editForm.route_to}
onChange={(e) => setEditForm({ ...editForm, route_to: e.target.value })}
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label>
<select
value={editForm.is_business}
onChange={(e) => setEditForm({ ...editForm, 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={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, trip: null })}
onConfirm={handleDelete}
title="Smazat záznam"
message={deleteConfirm.trip ? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?` : ''}
confirmText="Smazat"
confirmVariant="danger"
/>
{/* Hidden Print Content */}
{printData && (
<div ref={printRef} style={{ display: 'none' }}>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{printData.period_name}</div>
{printData.selected_vehicle_name && <div className="filters">Vozidlo: {printData.selected_vehicle_name}</div>}
{printData.selected_user_name && <div className="filters">Řidič: {printData.selected_user_name}</div>}
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
<div className="summary">
<div className="summary-item">
<div className="summary-value">{printData.totals.count}</div>
<div className="summary-label">Počet jízd</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(printData.totals.total)} km</div>
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(printData.totals.business)} km</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(printData.totals.private)} km</div>
<div className="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '80px' }}>Řidič</th>
<th style={{ width: '70px' }}>Vozidlo</th>
<th>Trasa</th>
<th style={{ width: '70px' }} className="text-right">Stav km</th>
<th style={{ width: '60px' }} className="text-right">Vzdálenost</th>
<th style={{ width: '55px' }} className="text-center">Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{printData.trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>{trip.spz}</td>
<td>{trip.route_from} {trip.route_to}</td>
<td className="text-right">{formatKm(trip.start_km)} - {formatKm(trip.end_km)}</td>
<td className="text-right"><strong>{formatKm(trip.distance)} km</strong></td>
<td className="text-center">
<span className={`badge ${trip.is_business ? 'badge-success' : 'badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td>{trip.notes || ''}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">Celkem:</td>
<td className="text-right"><strong>{formatKm(printData.totals.total)} km</strong></td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
{printData.trips.length === 0 && (
<p style={{ textAlign: 'center', padding: '20px' }}>Za vybrané období nejsou žádné záznamy.</p>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,237 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function TripsHistory() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [vehicleId, setVehicleId] = useState('')
const [data, setData] = useState({
trips: [],
vehicles: [],
totals: { total: 0, business: 0, count: 0 }
})
const fetchData = useCallback(async () => {
setLoading(true)
try {
let url = `${API_BASE}/trips.php?action=history&month=${month}`
if (vehicleId) {
url += `&vehicle_id=${vehicleId}`
}
const response = await apiFetch(url)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setData(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
setLoading(false)
}
}, [month, vehicleId, alert])
useEffect(() => {
fetchData()
}, [fetchData])
if (!hasPermission('trips.history')) return <Forbidden />
const getMonthName = (monthStr) => {
const [year, month] = monthStr.split('-')
const date = new Date(year, parseInt(month) - 1)
return date.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
}
const { trips, vehicles, totals } = data
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Historie jízd</h1>
<p className="admin-page-subtitle">{getMonthName(month)}</p>
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
value={month}
onChange={(val) => setMonth(val)}
/>
</div>
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Vozidlo</label>
<select
value={vehicleId}
onChange={(e) => setVehicleId(e.target.value)}
className="admin-form-select"
>
<option value="">Všechna vozidla</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</div>
</div>
</div>
</motion.div>
<motion.div
className="admin-grid admin-grid-3"
style={{ marginTop: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
</motion.div>
{/* Trips Table */}
<motion.div
className="admin-card"
style={{ marginTop: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: 'var(--text-secondary)' }}>{trip.driver_name}</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
{trip.route_from} {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td style={{ color: 'var(--text-secondary)', maxWidth: '200px' }}>
{trip.notes || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
</div>
)
}

470
src/admin/pages/Users.jsx Normal file
View File

@@ -0,0 +1,470 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth()
const alert = useAlert()
const [users, setUsers] = useState([])
const [roles, setRoles] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [deleteModal, setDeleteModal] = useState({ isOpen: false, user: null })
const [deleting, setDeleting] = useState(false)
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: '',
is_active: true
})
useModalLock(showModal)
const fetchUsers = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/users.php`, {
})
const data = await response.json()
if (data.success) {
setUsers(data.data.users || [])
setRoles(data.data.roles || [])
} else {
alert.error(data.error || 'Nepodařilo se načíst uživatele')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers()
}, [fetchUsers])
if (!hasPermission('users.view')) return <Forbidden />
const openCreateModal = () => {
setEditingUser(null)
setFormData({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: roles[0]?.id || '',
is_active: true
})
setShowModal(true)
}
const openEditModal = (user) => {
setEditingUser(user)
setFormData({
username: user.username,
email: user.email,
password: '',
first_name: user.first_name,
last_name: user.last_name,
role_id: user.role_id,
is_active: user.is_active
})
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingUser(null)
}
const handleSubmit = async (e) => {
e?.preventDefault()
const dataToSave = { ...formData }
const wasEditing = editingUser
const editingId = editingUser?.id
try {
const url = wasEditing
? `${API_BASE}/users.php?id=${editingId}`
: `${API_BASE}/users.php`
const method = wasEditing ? 'PUT' : 'POST'
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
const data = await response.json()
if (data.success) {
if (wasEditing && currentUser && Number(editingId) === Number(currentUser.id)) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
}
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(wasEditing ? 'Uživatel byl upraven' : 'Uživatel byl vytvořen')
fetchUsers()
} else {
alert.error(data.error || 'Nepodařilo se uložit uživatele')
}
} catch {
alert.error('Chyba připojení')
}
}
const openDeleteModal = (user) => {
setDeleteModal({ isOpen: true, user })
}
const closeDeleteModal = () => {
setDeleteModal({ isOpen: false, user: null })
}
const handleDelete = async () => {
if (!deleteModal.user) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/users.php?id=${deleteModal.user.id}`, {
method: 'DELETE',
})
const data = await response.json()
if (data.success) {
closeDeleteModal()
fetchUsers()
alert.success('Uživatel byl smazán')
} else {
alert.error(data.error || 'Nepodařilo se smazat uživatele')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const toggleActive = async (user) => {
try {
const response = await apiFetch(`${API_BASE}/users.php?id=${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_active: !user.is_active
})
})
const data = await response.json()
if (data.success) {
fetchUsers()
alert.success(user.is_active ? 'Uživatel byl deaktivován' : 'Uživatel byl aktivován')
} else {
alert.error(data.error || 'Nepodařilo se změnit stav uživatele')
}
} catch {
alert.error('Chyba připojení')
}
}
const getRoleBadgeClass = (roleName) => {
switch (roleName) {
case 'admin': return 'admin-badge admin-badge-admin'
default: return 'admin-badge admin-badge-viewer'
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '140px' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '160px', borderRadius: '8px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">Správa uživatelských účtů a oprávnění</p>
</div>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat uživatele
</button>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-table-wrapper">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username).charAt(0).toUpperCase()}
</div>
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
</div>
<div className="admin-table-username">@{user.username}</div>
</div>
</div>
</td>
<td>{user.email}</td>
<td>
<span className={getRoleBadgeClass(user.role_name)}>
{user.role_display_name || user.role_name}
</span>
</td>
<td>
<button
onClick={() => user.id !== currentUser?.id && toggleActive(user)}
disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
style={{ cursor: user.id === currentUser?.id ? 'not-allowed' : 'pointer' }}
>
{user.is_active ? 'Aktivní' : 'Neaktivní'}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(user)}
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>
{user.id !== currentUser?.id && (
<button
onClick={() => openDeleteModal(user)}
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>
</motion.div>
<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={closeModal} />
<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">
{editingUser ? 'Upravit uživatele' : 'Přidat nového uživatele'}
</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">
Heslo {editingUser && '(ponechte prázdné pro zachování stávajícího)'}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required={!editingUser}
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Role</label>
<select
value={formData.role_id}
onChange={(e) => setFormData({ ...formData, role_id: e.target.value })}
required
className="admin-form-select"
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
<span>Účet je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary">
Zrušit
</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">
{editingUser ? 'Uložit změny' : 'Vytvořit uživatele'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={handleDelete}
title="Smazat uživatele"
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,455 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
export default function Vehicles() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [vehicles, setVehicles] = useState([])
const [showModal, setShowModal] = useState(false)
const [editingVehicle, setEditingVehicle] = useState(null)
const [form, setForm] = useState({
spz: '',
name: '',
brand: '',
model: '',
initial_km: 0,
is_active: true
})
const [errors, setErrors] = useState({})
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, vehicle: null })
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/trips.php?action=vehicles`, {
})
const result = await response.json()
if (result.success) {
setVehicles(result.data.vehicles)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showModal)
if (!hasPermission('trips.vehicles')) return <Forbidden />
const openCreateModal = () => {
setEditingVehicle(null)
setForm({
spz: '',
name: '',
brand: '',
model: '',
initial_km: 0,
is_active: true
})
setErrors({})
setShowModal(true)
}
const openEditModal = (vehicle) => {
setEditingVehicle(vehicle)
setForm({
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
initial_km: vehicle.initial_km,
is_active: Boolean(vehicle.is_active)
})
setErrors({})
setShowModal(true)
}
const handleSubmit = async () => {
const newErrors = {}
if (!form.spz) newErrors.spz = 'Zadejte SPZ'
if (!form.name) newErrors.name = 'Zadejte název'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
try {
const payload = editingVehicle ? { ...form, id: editingVehicle.id } : form
const response = await apiFetch(`${API_BASE}/trips.php?action=vehicle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
setShowModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const handleDelete = async () => {
if (!deleteConfirm.vehicle) return
try {
const response = await apiFetch(`${API_BASE}/trips.php?action=vehicle&id=${deleteConfirm.vehicle.id}`, {
method: 'DELETE',
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const toggleActive = async (vehicle) => {
try {
const response = await apiFetch(`${API_BASE}/trips.php?action=vehicle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: vehicle.id,
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
initial_km: vehicle.initial_km,
is_active: !vehicle.is_active
})
})
const result = await response.json()
if (result.success) {
fetchData(false)
alert.success(vehicle.is_active ? 'Vozidlo bylo deaktivováno' : 'Vozidlo bylo aktivováno')
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '150px', borderRadius: '8px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Správa vozidel</h1>
</div>
<div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat vozidlo
</button>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
<p>Zatím nejsou žádná vozidla.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
Přidat první vozidlo
</button>
</div>
)}
{vehicles.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>SPZ</th>
<th>Název</th>
<th>Značka / Model</th>
<th>Počáteční km</th>
<th>Aktuální km</th>
<th>Počet jízd</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr key={vehicle.id} className={!vehicle.is_active ? 'admin-table-row-inactive' : ''}>
<td className="admin-mono" style={{ fontWeight: 500 }}>{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ''} ${vehicle.model || ''}`.trim()
: '—'}
</td>
<td className="admin-mono">{formatKm(vehicle.initial_km)} km</td>
<td className="admin-mono" style={{ fontWeight: 500 }}>{formatKm(vehicle.current_km)} km</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
>
{vehicle.is_active ? 'Aktivní' : 'Neaktivní'}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(vehicle)}
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" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, vehicle })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
)}
</div>
</motion.div>
{/* Add/Edit 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">
{editingVehicle ? 'Upravit vozidlo' : 'Přidat vozidlo'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className={`admin-form-group${errors.spz ? ' has-error' : ''}`}>
<label className="admin-form-label required">SPZ</label>
<input
type="text"
value={form.spz}
onChange={(e) => {
setForm({ ...form, spz: e.target.value.toUpperCase() })
setErrors(prev => ({ ...prev, spz: undefined }))
}}
className="admin-form-input"
placeholder="1AB 2345"
/>
{errors.spz && <span className="admin-form-error">{errors.spz}</span>}
</div>
<div className={`admin-form-group${errors.name ? ' has-error' : ''}`}>
<label className="admin-form-label required">Název</label>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value })
setErrors(prev => ({ ...prev, name: undefined }))
}}
className="admin-form-input"
placeholder="Služební #1"
/>
{errors.name && <span className="admin-form-error">{errors.name}</span>}
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Značka</label>
<input
type="text"
value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })}
className="admin-form-input"
placeholder="Škoda"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Model</label>
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
className="admin-form-input"
placeholder="Octavia Combi"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={form.initial_km}
onChange={(e) => setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Stav tachometru při přidání vozidla
</small>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
<span>Vozidlo je aktivní</span>
</label>
</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
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={deleteConfirm.vehicle ? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?` : ''}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
)
}

18
src/admin/projects.css Normal file
View File

@@ -0,0 +1,18 @@
/* ============================================================================
Project Status Badges
============================================================================ */
.admin-badge-project-aktivni {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.admin-badge-project-dokonceny {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-badge-project-zruseny {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}

54
src/admin/settings.css Normal file
View File

@@ -0,0 +1,54 @@
/* ============================================================================
Settings / Permissions
============================================================================ */
.admin-sidebar-settings {
border-top: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
margin-top: auto;
}
.admin-permission-group {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
}
.admin-permission-group-title {
padding: 0.625rem 0.75rem;
background: var(--bg-secondary);
}
.admin-permission-group-title .admin-form-checkbox span {
font-weight: 600;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.025em;
color: var(--text-secondary);
}
.admin-permission-list {
display: flex;
flex-direction: column;
}
.admin-permission-item {
padding: 0.5rem 0.75rem;
transition: background-color 0.15s;
}
.admin-permission-item:hover {
background: var(--bg-secondary);
}
.admin-permission-item + .admin-permission-item {
border-top: 1px solid var(--border-color);
}
.admin-permission-desc {
font-size: 0.75rem;
color: var(--text-tertiary);
line-height: 1.4;
padding-left: 2.75rem;
}

103
src/admin/utils/api.js Normal file
View File

@@ -0,0 +1,103 @@
let showSessionExpiredAlert = false
let showLogoutAlert = false
let getTokenFn = null
let refreshFn = null
let refreshPromise = null
export const shouldShowSessionExpiredAlert = () => {
if (showSessionExpiredAlert) {
showSessionExpiredAlert = false
return true
}
return false
}
export const setSessionExpired = () => {
showSessionExpiredAlert = true
}
export const shouldShowLogoutAlert = () => {
if (showLogoutAlert) {
showLogoutAlert = false
return true
}
return false
}
export const setLogoutAlert = () => {
showLogoutAlert = true
}
export const setTokenGetter = (fn) => {
getTokenFn = fn
}
export const setRefreshFn = (fn) => {
refreshFn = fn
}
// Fetch wrapper - adds JWT header, auto-retries on 401 via refresh
export const apiFetch = async (url, options = {}) => {
let token = null
try {
token = getTokenFn ? getTokenFn() : null
} catch {
// token retrieval failed
}
const headers = {
...options.headers
}
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let response = await fetch(url, {
...options,
headers,
credentials: 'include'
})
if (response.status === 401 && refreshFn) {
try {
// Sdileny refresh promise - zabrani paralelnim refresh volanim
if (!refreshPromise) {
refreshPromise = refreshFn().finally(() => { refreshPromise = null })
}
const refreshed = await refreshPromise
if (refreshed) {
token = getTokenFn ? getTokenFn() : null
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
response = await fetch(url, {
...options,
headers,
credentials: 'include'
})
} else {
setSessionExpired()
}
} catch {
// refresh failed
setSessionExpired()
}
}
return response
}
export const getAccessToken = () => {
try {
return getTokenFn ? getTokenFn() : null
} catch {
return null
}
}
export default apiFetch

View File

@@ -0,0 +1,121 @@
export const formatDate = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('cs-CZ')
}
export const formatDatetime = (datetime) => {
if (!datetime) return '—'
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}. ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`
}
export const formatTime = (datetime) => {
if (!datetime) return '—'
return new Date(datetime).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
export const calculateWorkMinutes = (record) => {
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime()
const breakEnd = new Date(record.break_end).getTime()
minutes -= (breakEnd - breakStart) / 60000
}
return Math.max(0, Math.floor(minutes))
}
export const formatMinutes = (minutes, withUnit = false) => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${h}:${String(m).padStart(2, '0')}${withUnit ? ' h' : ''}`
}
export const getLeaveTypeName = (type) => {
const types = {
work: 'Práce',
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno'
}
return types[type] || 'Práce'
}
export const getLeaveTypeBadgeClass = (type) => {
const classes = {
vacation: 'badge-vacation',
sick: 'badge-sick',
holiday: 'badge-holiday',
unpaid: 'badge-unpaid'
}
return classes[type] || ''
}
export const getDatePart = (datetime) => {
if (!datetime) return ''
if (datetime.includes('T')) {
return datetime.split('T')[0]
}
return datetime.split(' ')[0]
}
export const getTimePart = (datetime) => {
if (!datetime) return ''
const d = new Date(datetime)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export const calcProjectMinutesTotal = (logs) => {
return logs.filter(l => l.project_id).reduce((sum, l) => {
return sum + (parseInt(l.hours) || 0) * 60 + (parseInt(l.minutes) || 0)
}, 0)
}
export const calcFormWorkMinutes = (form) => {
if (!form.arrival_time || !form.departure_time) return 0
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`
const departureStr = `${form.departure_date}T${form.departure_time}`
let mins = (new Date(departureStr) - new Date(arrivalStr)) / 60000
if (form.break_start_time && form.break_end_time) {
const bsStr = `${form.break_start_date}T${form.break_start_time}`
const beStr = `${form.break_end_date}T${form.break_end_time}`
mins -= (new Date(beStr) - new Date(bsStr)) / 60000
}
return Math.max(0, Math.floor(mins))
}
// Print-specific helpers
export const formatTimeOrDatetimePrint = (datetime, shiftDate) => {
if (!datetime) return '—'
const timeDate = new Date(datetime).toISOString().split('T')[0]
if (timeDate !== shiftDate) {
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}. ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`
}
return new Date(datetime).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
export const calculateWorkMinutesPrint = (record) => {
const leaveType = record.leave_type || 'work'
if (leaveType !== 'work') {
return (record.leave_hours || 8) * 60
}
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime()
const breakEnd = new Date(record.break_end).getTime()
minutes -= (breakEnd - breakStart) / 60000
}
return Math.max(0, Math.floor(minutes))
}

View File

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

View File

@@ -0,0 +1,48 @@
import { createContext, useContext, useState, useEffect } from 'react'
const ThemeContext = createContext()
const COOKIE_NAME = 'boha_cookie_consent'
function hasAcceptedCookies() {
return document.cookie.split(';').some(cookie =>
cookie.trim() === `${COOKIE_NAME}=accepted`
)
}
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
if (typeof window !== 'undefined' && hasAcceptedCookies()) {
return localStorage.getItem('boha-theme') || 'dark'
}
return 'dark'
})
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
if (hasAcceptedCookies()) {
localStorage.setItem('boha-theme', theme)
}
const themeColor = theme === 'dark' ? '#12121a' : '#ffffff'
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor)
}, [theme])
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}

15
src/main.jsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { ThemeProvider } from './context/ThemeContext'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
)

23
src/utils/qrcode.js Normal file
View File

@@ -0,0 +1,23 @@
/**
* Minimalni QR code generator pro TOTP URI.
* Generuje QR client-side pres Canvas - zadny externi API call.
* Pouziva qrcode npm package (nutno nainstalovat).
*
* Fallback: pokud canvas neni k dispozici, nic se nevykresli.
*/
import QRCode from 'qrcode'
export async function renderQR(canvas, data) {
try {
await QRCode.toCanvas(canvas, data, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
})
} catch {
// QR render failed silently
}
}