initial commit

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

27
src/App.tsx 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.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { AlertProvider } from './context/AlertContext'
import ErrorBoundary from './components/ErrorBoundary'
import AdminLayout from './components/AdminLayout'
import AlertContainer from './components/AlertContainer'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import './admin.css'
import './login.css'
import './dashboard.css'
import './attendance.css'
import './settings.css'
import './offers.css'
import './invoices.css'
const Users = lazy(() => import('./pages/Users'))
const Attendance = lazy(() => import('./pages/Attendance'))
const AttendanceHistory = lazy(() => import('./pages/AttendanceHistory'))
const AttendanceAdmin = lazy(() => import('./pages/AttendanceAdmin'))
const AttendanceBalances = lazy(() => import('./pages/AttendanceBalances'))
const AttendanceCreate = lazy(() => import('./pages/AttendanceCreate'))
const LeaveRequests = lazy(() => import('./pages/LeaveRequests'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
const AttendanceLocation = lazy(() => import('./pages/AttendanceLocation'))
const Trips = lazy(() => import('./pages/Trips'))
const TripsHistory = lazy(() => import('./pages/TripsHistory'))
const TripsAdmin = lazy(() => import('./pages/TripsAdmin'))
const Vehicles = lazy(() => import('./pages/Vehicles'))
const Offers = lazy(() => import('./pages/Offers'))
const OfferDetail = lazy(() => import('./pages/OfferDetail'))
const OffersCustomers = lazy(() => import('./pages/OffersCustomers'))
const OffersTemplates = lazy(() => import('./pages/OffersTemplates'))
const CompanySettings = lazy(() => import('./pages/CompanySettings'))
const Orders = lazy(() => import('./pages/Orders'))
const OrderDetail = lazy(() => import('./pages/OrderDetail'))
const Projects = lazy(() => import('./pages/Projects'))
const ProjectCreate = lazy(() => import('./pages/ProjectCreate'))
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
const Invoices = lazy(() => import('./pages/Invoices'))
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
const Settings = lazy(() => import('./pages/Settings'))
const AuditLog = lazy(() => import('./pages/AuditLog'))
const NotFound = lazy(() => import('./pages/NotFound'))
export default function AdminApp() {
return (
<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 path="audit-log" element={<AuditLog />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AlertProvider>
</AuthProvider>
)
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,766 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import apiFetch from '../utils/api'
import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
calculateWorkMinutes,
getDatePart,
getTimePart,
} from '../utils/attendanceHelpers'
import type { ShiftFormData, ProjectLog, Project, User } from '../components/ShiftFormModal'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void }
}
interface AttendanceRecord {
id: number
user_id: number
shift_date: string
leave_type?: string
leave_hours?: number
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
notes?: string
project_id?: number | null
project_name?: string
project_logs?: Array<{
id?: number
project_id: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
}>
user_name?: string
users?: {
id: number
first_name: string
last_name: string
username: string
}
}
interface ApiUser {
id: number
first_name: string
last_name: string
username: string
}
interface UserTotal {
name: string
minutes: number
working: boolean
vacation_hours: number
sick_hours: number
holiday_hours: number
unpaid_hours: number
overtime: number
missing: number
fund: number | null
business_days: number
worked_hours: number
covered: number
}
interface LeaveBalance {
vacation_total: number
vacation_remaining: number
}
interface BulkForm {
month: string
user_ids: string[]
arrival_time: string
departure_time: string
break_start_time: string
break_end_time: string
}
interface AttendanceData {
records: AttendanceRecord[]
users: User[]
user_totals: Record<string, UserTotal>
leave_balances: Record<string, LeaveBalance>
}
interface DeleteConfirmState {
show: boolean
record: AttendanceRecord | null
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const API_BASE = '/api/admin'
const combineDatetime = (date: string, time: string): string | null =>
date && time ? `${date}T${time}:00` : null
/**
* Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned.
*/
function computeUserTotals(
records: AttendanceRecord[],
userMap: Map<number, string>,
month: string,
): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {}
for (const rec of records) {
const uid = String(rec.user_id)
if (!totals[uid]) {
const name =
userMap.get(rec.user_id) ??
(rec.users
? `${rec.users.first_name} ${rec.users.last_name}`.trim() || rec.users.username
: `User #${rec.user_id}`)
totals[uid] = {
name,
minutes: 0,
working: false,
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
overtime: 0,
missing: 0,
fund: null,
business_days: 0,
worked_hours: 0,
covered: 0,
}
}
const t = totals[uid]
const leaveType = rec.leave_type || 'work'
if (leaveType === 'work') {
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
t.minutes += calculateWorkMinutes(rec)
} else {
const leaveHours = Number(rec.leave_hours) || 8
switch (leaveType) {
case 'vacation': t.vacation_hours += leaveHours; break
case 'sick': t.sick_hours += leaveHours; break
case 'holiday': t.holiday_hours += leaveHours; break
case 'unpaid': t.unpaid_hours += leaveHours; break
}
}
// Track if user is currently working (has arrival but no departure)
if (rec.arrival_time && !rec.departure_time) {
t.working = true
}
}
// Add fund data per user (matching PHP addFundDataToUserTotals)
const [yearStr, monthStr] = month.split('-')
const yr = parseInt(yearStr, 10)
const mo = parseInt(monthStr, 10) - 1
// Count business days in month (Mon-Fri)
let rawBizDays = 0
const cur = new Date(yr, mo, 1)
while (cur.getMonth() === mo) {
const dow = cur.getDay()
if (dow !== 0 && dow !== 6) rawBizDays++
cur.setDate(cur.getDate() + 1)
}
for (const uid of Object.keys(totals)) {
const t = totals[uid]
// Subtract holiday days from business days for this user
const holidayDays = Math.round(t.holiday_hours / 8)
const bizDays = Math.max(0, rawBizDays - holidayDays)
const fund = bizDays * 8
const workedHours = Math.round((t.minutes / 60) * 10) / 10
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
const leaveHours = t.vacation_hours + t.sick_hours
const covered = Math.round((workedHours + leaveHours) * 10) / 10
t.fund = fund
t.business_days = bizDays
t.worked_hours = workedHours
t.covered = covered
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10)
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10)
}
return totals
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export default function useAttendanceAdmin({ alert }: AlertContext) {
// ---- Core state ----
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [filterUserId, setFilterUserId] = useState('')
const [data, setData] = useState<AttendanceData>({
records: [],
users: [],
user_totals: {},
leave_balances: {},
})
// ---- Bulk modal ----
const [showBulkModal, setShowBulkModal] = useState(false)
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkForm, setBulkForm] = useState<BulkForm>({
month: '',
user_ids: [],
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30',
})
// ---- Create modal ----
const [showCreateModal, setShowCreateModal] = useState(false)
const today = new Date().toISOString().split('T')[0]
const [createForm, setCreateForm] = useState<ShiftFormData>({
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: '',
})
// ---- Edit modal ----
const [showEditModal, setShowEditModal] = useState(false)
const [editingRecord, setEditingRecord] = useState<AttendanceRecord | null>(null)
const [editForm, setEditForm] = useState<ShiftFormData>({
user_id: '',
shift_date: '',
leave_type: 'work',
leave_hours: 8,
arrival_date: '',
arrival_time: '',
break_start_date: '',
break_start_time: '',
break_end_date: '',
break_end_time: '',
departure_date: '',
departure_time: '',
notes: '',
})
// ---- Delete ----
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>({
show: false,
record: null,
})
// ---- Projects ----
const [projectList, setProjectList] = useState<Project[]>([])
const [createProjectLogs, setCreateProjectLogs] = useState<ProjectLog[]>([])
const [editProjectLogs, setEditProjectLogs] = useState<ProjectLog[]>([])
// ---- Print ref (kept for API compat) ----
const printRef = useRef<HTMLDivElement | null>(null)
// ---- Ref to hold full user list for user_totals computation ----
const usersRef = useRef<Map<number, string>>(new Map())
// =========================================================================
// Load projects once
// =========================================================================
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance?action=projects`)
const result = await response.json()
if (result.success) setProjectList(result.data?.projects ?? result.data ?? [])
} catch {
/* silent */
}
}
loadProjects()
}, [])
// =========================================================================
// Load users once
// =========================================================================
useEffect(() => {
const loadUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users?limit=1000`)
const result = await response.json()
if (result.success) {
const apiUsers: ApiUser[] = result.data
const mapped: User[] = apiUsers.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
}))
const nameMap = new Map<number, string>()
for (const u of mapped) nameMap.set(u.id as number, u.name)
usersRef.current = nameMap
setData((prev) => ({ ...prev, users: mapped }))
}
} catch {
/* silent */
}
}
loadUsers()
}, [])
// =========================================================================
// Fetch attendance records + leave balances
// =========================================================================
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const [yearStr, monthStr] = month.split('-')
// Build records URL
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`
// Fetch records and balances in parallel
const [recordsResponse, balancesResponse] = await Promise.all([
apiFetch(recordsUrl),
apiFetch(`${API_BASE}/attendance?action=balances&year=${yearStr}`),
])
if (recordsResponse.status === 401) return
const recordsResult = await recordsResponse.json()
const balancesResult = await balancesResponse.json()
const records: AttendanceRecord[] = recordsResult.success
? (Array.isArray(recordsResult.data) ? recordsResult.data : [])
: []
// balancesResult.data is { users: [...], balances: { uid: {...} } }
const balancesObj = balancesResult.success ? balancesResult.data : {}
const leaveBalances: Record<string, LeaveBalance> = balancesObj?.balances ?? balancesObj ?? {}
// Compute user_totals client-side
const userTotals = computeUserTotals(records, usersRef.current, month)
setData((prev) => ({
...prev,
records,
user_totals: userTotals,
leave_balances: leaveBalances,
}))
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[month, filterUserId],
)
// Initial load with skeleton, filter changes without skeleton
const initialLoadDone = useRef(false)
useEffect(() => {
if (!initialLoadDone.current) {
initialLoadDone.current = true
fetchData(true)
} else {
fetchData(false)
}
}, [fetchData])
// =========================================================================
// Validation helper
// =========================================================================
const validateProjectLogs = (logs: ProjectLog[], formData: ShiftFormData): boolean => {
const totalWork = calcFormWorkMinutes(formData)
const totalProject = calcProjectMinutesTotal(logs)
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60)
const wM = totalWork % 60
const pH = Math.floor(totalProject / 60)
const pM = totalProject % 60
alert.error(
`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`,
)
return false
}
return true
}
// =========================================================================
// Create modal
// =========================================================================
const openCreateModal = () => {
const todayDate = new Date().toISOString().split('T')[0]
setCreateForm({
user_id: '',
shift_date: todayDate,
leave_type: 'work',
leave_hours: 8,
arrival_date: todayDate,
arrival_time: '',
break_start_date: todayDate,
break_start_time: '',
break_end_date: todayDate,
break_end_time: '',
departure_date: todayDate,
departure_time: '',
notes: '',
})
setCreateProjectLogs([])
setShowCreateModal(true)
}
const handleCreateShiftDateChange = (newDate: string) => {
setCreateForm((prev) => ({
...prev,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate,
}))
}
const handleCreateSubmit = async () => {
if (!createForm.user_id || !createForm.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
}
const filteredCreateLogs = createProjectLogs.filter((l) => l.project_id)
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
if (!validateProjectLogs(filteredCreateLogs, createForm)) return
}
try {
const isLeave = createForm.leave_type !== 'work'
const payload: Record<string, unknown> = {
user_id: Number(createForm.user_id),
shift_date: createForm.shift_date,
leave_type: createForm.leave_type,
notes: createForm.notes || null,
}
if (isLeave) {
payload.leave_hours = createForm.leave_hours || 8
payload.arrival_time = null
payload.departure_time = null
payload.break_start = null
payload.break_end = null
} else {
payload.arrival_time = combineDatetime(createForm.arrival_date, createForm.arrival_time)
payload.departure_time = combineDatetime(createForm.departure_date, createForm.departure_time)
payload.break_start = combineDatetime(createForm.break_start_date, createForm.break_start_time)
payload.break_end = combineDatetime(createForm.break_end_date, createForm.break_end_time)
}
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
payload.project_logs = filteredCreateLogs
}
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (result.success) {
setShowCreateModal(false)
await fetchData(false)
await new Promise((resolve) => setTimeout(resolve, 300))
alert.success(result.message || result.data?.message || 'Záznam vytvořen')
} else {
alert.error(result.error || 'Nepodařilo se vytvořit záznam')
}
} catch {
alert.error('Chyba připojení')
}
}
// =========================================================================
// Bulk modal
// =========================================================================
const openBulkModal = () => {
setBulkForm({
month,
user_ids: data.users.map((u) => String(u.id)),
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30',
})
setShowBulkModal(true)
}
const toggleBulkUser = (userId: number | string) => {
const uid = String(userId)
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.includes(uid)
? prev.user_ids.filter((u) => u !== uid)
: [...prev.user_ids, uid],
}))
}
const toggleAllBulkUsers = () => {
const allIds = data.users.map((u) => String(u.id))
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
}))
}
const handleBulkSubmit = async () => {
if (!bulkForm.month) {
alert.error('Vyberte měsíc')
return
}
if (bulkForm.user_ids.length === 0) {
alert.error('Vyberte alespoň jednoho zaměstnance')
return
}
setBulkSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance?action=bulk_attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bulkForm),
})
const result = await response.json()
if (result.success) {
setShowBulkModal(false)
await fetchData(false)
await new Promise((resolve) => setTimeout(resolve, 300))
alert.success(result.message || result.data?.message || 'Záznamy vytvořeny')
} else {
alert.error(result.error || 'Nepodařilo se vytvořit záznamy')
}
} catch {
alert.error('Chyba připojení')
} finally {
setBulkSubmitting(false)
}
}
// =========================================================================
// Edit modal
// =========================================================================
const openEditModal = (record: AttendanceRecord) => {
// Enrich record with user_name for the modal subtitle
const userName = record.users
? `${record.users.first_name} ${record.users.last_name}`.trim() || record.users.username
: (record as Record<string, unknown>).user_name as string || `User #${record.user_id}`
const enriched = { ...record, user_name: userName }
setEditingRecord(enriched)
const shiftDate = getDatePart(record.shift_date) || record.shift_date
setEditForm({
user_id: String(record.user_id),
shift_date: shiftDate,
leave_type: record.leave_type || 'work',
leave_hours: Number(record.leave_hours) || 8,
arrival_date: getDatePart(record.arrival_time) || shiftDate,
arrival_time: getTimePart(record.arrival_time),
break_start_date: getDatePart(record.break_start) || shiftDate,
break_start_time: getTimePart(record.break_start),
break_end_date: getDatePart(record.break_end) || shiftDate,
break_end_time: getTimePart(record.break_end),
departure_date: getDatePart(record.departure_time) || shiftDate,
departure_time: getTimePart(record.departure_time),
notes: record.notes || '',
})
const logs: ProjectLog[] = (record.project_logs || []).map((l) => {
if (l.hours !== null && l.hours !== undefined) {
return {
project_id: String(l.project_id),
hours: String(l.hours),
minutes: String(l.minutes || 0),
}
}
if (l.started_at && l.ended_at) {
const mins = Math.max(
0,
Math.floor(
(new Date(l.ended_at).getTime() - new Date(l.started_at).getTime()) / 60000,
),
)
return {
project_id: String(l.project_id),
hours: String(Math.floor(mins / 60)),
minutes: String(mins % 60),
}
}
return { project_id: String(l.project_id), hours: '', minutes: '' }
})
setEditProjectLogs(logs)
setShowEditModal(true)
}
const handleEditSubmit = async () => {
if (!editingRecord) return
const isWork = (editForm.leave_type || 'work') === 'work'
const filteredEditLogs = isWork ? editProjectLogs.filter((l) => l.project_id) : []
if (filteredEditLogs.length > 0) {
if (!validateProjectLogs(filteredEditLogs, editForm)) return
}
try {
const isLeave = editForm.leave_type !== 'work'
const payload: Record<string, unknown> = {
leave_type: editForm.leave_type,
notes: editForm.notes || null,
}
if (isLeave) {
payload.leave_hours = editForm.leave_hours || 8
payload.arrival_time = null
payload.departure_time = null
payload.break_start = null
payload.break_end = null
} else {
payload.arrival_time = combineDatetime(editForm.arrival_date, editForm.arrival_time)
payload.departure_time = combineDatetime(editForm.departure_date, editForm.departure_time)
payload.break_start = combineDatetime(editForm.break_start_date, editForm.break_start_time)
payload.break_end = combineDatetime(editForm.break_end_date, editForm.break_end_time)
}
if (filteredEditLogs.length > 0) {
payload.project_logs = filteredEditLogs
}
const response = await apiFetch(`${API_BASE}/attendance/${editingRecord.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise((resolve) => setTimeout(resolve, 300))
alert.success(result.message || result.data?.message || 'Záznam aktualizován')
} else {
alert.error(result.error || 'Nepodařilo se uložit')
}
} catch {
alert.error('Chyba připojení')
}
}
// =========================================================================
// Delete
// =========================================================================
const handleDelete = async () => {
if (!deleteConfirm.record) return
try {
const response = await apiFetch(
`${API_BASE}/attendance/${deleteConfirm.record.id}`,
{ method: 'DELETE' },
)
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, record: null })
await fetchData(false)
alert.success(result.message || result.data?.message || 'Záznam smazán')
} else {
alert.error(result.error || 'Nepodařilo se smazat')
}
} catch {
alert.error('Chyba připojení')
}
}
// =========================================================================
// Print (stub)
// =========================================================================
const handlePrint = async () => {
// TODO: implement print functionality
alert.success('Funkce tisku bude brzy dostupná')
}
// =========================================================================
// Derived
// =========================================================================
const hasData = Object.keys(data.user_totals).length > 0
// =========================================================================
// Public API
// =========================================================================
return {
loading,
month,
setMonth,
filterUserId,
setFilterUserId,
data,
hasData,
showBulkModal,
setShowBulkModal,
bulkSubmitting,
bulkForm,
setBulkForm,
showCreateModal,
setShowCreateModal,
createForm,
setCreateForm,
showEditModal,
setShowEditModal,
editingRecord,
editForm,
setEditForm,
deleteConfirm,
setDeleteConfirm,
projectList,
createProjectLogs,
setCreateProjectLogs,
editProjectLogs,
setEditProjectLogs,
printRef,
openCreateModal,
handleCreateShiftDateChange,
handleCreateSubmit,
openBulkModal,
toggleBulkUser,
toggleAllBulkUsers,
handleBulkSubmit,
openEditModal,
handleEditSubmit,
handleDelete,
handlePrint,
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import { useState, useCallback } from 'react'
export default function useTableSort(defaultSort = 'id') {
const [sort, setSort] = useState(defaultSort)
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const handleSort = useCallback((column: string) => {
setSort(prev => {
if (prev === column) {
setOrder(o => (o === 'asc' ? 'desc' : 'asc'))
return column
}
setOrder('desc')
return column
})
}, [])
return { sort, order, handleSort, activeSort: sort }
}

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

@@ -0,0 +1,141 @@
/* ============================================================================
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;
}
@media (max-width: 640px) {
.invoice-month-btn {
width: 44px;
height: 44px;
}
.received-upload-row {
flex-direction: column;
}
.received-upload-file-name {
max-width: 200px;
}
}

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;
}

View File

@@ -0,0 +1,929 @@
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 FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface ShiftRecord {
id: number
user_id: number
shift_date: string
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
notes?: string | null
project_id?: number | null
project_logs?: ProjectLog[]
}
interface ProjectLog {
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
}
interface Project {
id: number
name: string
project_number: string
}
interface LeaveBalance {
vacation_total: number
vacation_used: number
vacation_remaining: number
sick_used: number
}
interface MonthlyFund {
month_name: string
fund: number
worked: number
covered: number
remaining: number
overtime: number
leave_hours: number
vacation_hours: number
sick_hours: number
holiday_hours: number
unpaid_hours: number
}
interface AttendanceData {
ongoing_shift: ShiftRecord | null
today_shifts: ShiftRecord[]
date: string
leave_balance: LeaveBalance
monthly_fund: MonthlyFund | null
project_logs: ProjectLog[]
active_project_id: number | null
}
function pluralizeDays(n: number) {
if (n === 1) return 'den'
if (n >= 2 && n <= 4) return 'dny'
return 'dnů'
}
function getFundBarBackground(fund: MonthlyFund) {
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<AttendanceData>({
ongoing_shift: null,
today_shifts: [],
date: '',
leave_balance: { vacation_total: 160, vacation_used: 0, vacation_remaining: 160, sick_used: 0 },
monthly_fund: null,
project_logs: [],
active_project_id: 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<Project[]>([])
const [switchingProject, setSwitchingProject] = useState(false)
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([])
const [activeProjectId, setActiveProjectId] = useState<number | null>(null)
const [gpsConfirm, setGpsConfirm] = useState<{ show: boolean; action: string | null }>({ show: false, action: null })
const geoAbortRef = useRef<AbortController | null>(null)
useEffect(() => {
return () => {
if (geoAbortRef.current) geoAbortRef.current.abort()
}
}, [])
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/status`)
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?action=projects`)
const result = await response.json()
if (result.success) {
const items = Array.isArray(result.data) ? result.data : []
setProjects(items)
}
} catch {
// silent - projects are supplementary
}
}
loadProjects()
}, [])
useModalLock(showLeaveModal)
if (!hasPermission('attendance.record')) return <Forbidden />
const handlePunch = (action: string) => {
setSubmitting(true)
if (!navigator.geolocation) {
alert.warning('GPS není dostupná')
submitPunch(action, {})
return
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords
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(geoData => {
if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude, longitude, address: geoData.display_name, punch_action: action }),
}).catch(() => {})
}
})
.catch(() => {})
},
(geoError) => {
let errorMsg = 'Nepodařilo se získat polohu'
if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = 'Přístup k poloze byl zamítnut'
} else if (geoError.code === geoError.TIMEOUT) {
errorMsg = 'Vypršel časový limit'
}
alert.error(errorMsg)
setGpsConfirm({ show: true, action })
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
)
}
const submitPunch = async (action: string, gpsData: Record<string, unknown> = {}) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ punch_action: action, ...gpsData }),
})
if (response.status === 401) return
const result = await response.json()
setSubmitting(false)
if (result.success) {
await fetchData()
setTimeout(() => {
alert.success(result.data?.message || result.message || 'Uloženo')
}, 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`, {
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.data?.message || result.message || 'Přestávka zaznamenána')
} 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/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: string | null) => {
setSwitchingProject(true)
try {
const response = await apiFetch(`${API_BASE}/attendance/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.data?.message || result.message || 'Projekt přepnut')
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSwitchingProject(false)
}
}
const calculateBusinessDays = (from: string, to: string) => {
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`, {
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.data?.message || result.message || 'Žádost odeslána')
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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.getTime() - start.getTime()) / 60000)
const h = Math.floor(mins / 60)
const mm = 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(mm).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 className="mt-2">
<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 mt-6">
<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 as any), 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).getTime() - new Date(log.started_at!).getTime()) / 60000) : 0
const h = Math.floor(mins / 60)
const mm = mins % 60
return (
<span key={log.id || i} style={{ fontSize: '12px' }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(mm).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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
{/* 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">
<FormField label="Typ nepřítomnosti">
<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>
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<FormField label="Od">
<AdminDatePicker
mode="date"
value={leaveForm.date_from}
onChange={(val: string) => {
setLeaveForm(prev => ({
...prev,
date_from: val,
date_to: prev.date_to < val ? val : prev.date_to,
}))
}}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={leaveForm.date_to}
minDate={leaveForm.date_from}
onChange={(val: string) => setLeaveForm({ ...leaveForm, date_to: val })}
/>
</FormField>
</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>
)}
<FormField label="Poznámka">
<textarea
value={leaveForm.notes}
onChange={(e) => setLeaveForm({ ...leaveForm, notes: e.target.value })}
placeholder="Volitelná poznámka..."
className="admin-form-textarea"
rows={2}
/>
</FormField>
</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>
)
}

View File

@@ -0,0 +1,341 @@
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import BulkAttendanceModal from '../components/BulkAttendanceModal'
import ShiftFormModal from '../components/ShiftFormModal'
import AttendanceShiftTable from '../components/AttendanceShiftTable'
import useModalLock from '../hooks/useModalLock'
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
import FormField from '../components/FormField'
import { formatMinutes } from '../utils/attendanceHelpers'
interface UserTotalData {
name: string
minutes: number
working: boolean
vacation_hours: number
sick_hours: number
holiday_hours: number
unpaid_hours: number
fund: number | null
worked_hours: number
covered: number
missing: number
overtime: number
}
function getFundBarBackground(data: UserTotalData) {
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
if (data.covered >= (data.fund ?? 0)) return 'linear-gradient(135deg, var(--success), #059669)'
return 'var(--gradient)'
}
export default function AttendanceAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const {
loading, month, setMonth,
filterUserId, setFilterUserId,
data, hasData,
showBulkModal, setShowBulkModal,
bulkSubmitting, bulkForm, setBulkForm,
showCreateModal, setShowCreateModal,
createForm, setCreateForm,
showEditModal, setShowEditModal,
editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
projectList,
createProjectLogs, setCreateProjectLogs,
editProjectLogs, setEditProjectLogs,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
openEditModal, handleEditSubmit,
handleDelete, handlePrint
} = useAttendanceAdmin({ alert })
useModalLock(showBulkModal)
useModalLock(showEditModal)
useModalLock(showCreateModal)
if (!hasPermission('attendance.admin')) return <Forbidden />
// Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad = loading && data.records.length === 0 && Object.keys(data.user_totals).length === 0
if (isInitialLoad) {
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>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
<div className="admin-skeleton-row">
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} />
</div>
</div>
</div>
<div className="admin-grid admin-grid-3">
{[0, 1, 2].map(i => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
<div className="admin-skeleton-line w-1/2" />
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
<div className="admin-skeleton-line w-full" style={{ height: '4px' }} />
</div>
</div>
</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 w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa docházky</h1>
</div>
<div className="admin-page-actions">
{hasData && (
<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>
)}
<button
onClick={openBulkModal}
className="admin-btn admin-btn-secondary"
>
Vyplnit měsíc
</button>
<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áznam
</button>
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
<FormField label="Zaměstnanec">
<select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni</option>
{data.users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</FormField>
</div>
</div>
</motion.div>
{/* User Totals */}
{Object.keys(data.user_totals).length > 0 && (
<motion.div
className="admin-grid admin-grid-3 mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.09 }}
>
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData
return (
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span className={`attendance-working-badge ${ut.working ? 'working' : 'finished'}`}>
{ut.working ? '\u2713' : '\u2717'}
</span>
</div>
<div className="admin-stat-value">{formatMinutes(ut.minutes)}</div>
<div className="admin-stat-label">odpracováno</div>
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">Dov: {ut.vacation_hours}h</span>
)}
{ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">Nem: {ut.sick_hours}h</span>
)}
{ut.holiday_hours > 0 && (
<span className="attendance-leave-badge badge-holiday">Sv: {ut.holiday_hours}h</span>
)}
{ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">Nep: {ut.unpaid_hours}h</span>
)}
</div>
{ut.fund !== null && (
<div className="mt-2">
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem' }}>
<span>Fond: {ut.worked_hours}h / {ut.fund}h</span>
{ut.overtime > 0 && (
<span className="text-warning fw-600">+{ut.overtime}h</span>
)}
{ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">-{ut.missing}h</span>
)}
</div>
<div style={{
marginTop: '0.375rem',
height: '4px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)}
{data.leave_balances[uid] && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
Zbývá dovolené: {data.leave_balances[uid].vacation_remaining.toFixed(1)}h / {data.leave_balances[uid].vacation_total}h
</div>
)}
</div>
</div>
)
})}
</motion.div>
)}
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
</div>
</motion.div>
{/* Modals */}
<BulkAttendanceModal
show={showBulkModal}
onClose={() => setShowBulkModal(false)}
form={bulkForm}
setForm={setBulkForm}
users={data.users}
onSubmit={handleBulkSubmit}
submitting={bulkSubmitting}
toggleUser={toggleBulkUser}
toggleAllUsers={toggleAllBulkUsers}
/>
<ShiftFormModal
mode="create"
show={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateSubmit}
form={createForm}
setForm={setCreateForm}
projectLogs={createProjectLogs}
setProjectLogs={setCreateProjectLogs}
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={null}
/>
<ShiftFormModal
mode="edit"
show={showEditModal && !!editingRecord}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
form={editForm}
setForm={setEditForm}
projectLogs={editProjectLogs}
setProjectLogs={setEditProjectLogs}
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={editingRecord}
/>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, record: null })}
onConfirm={handleDelete}
title="Smazat záznam"
message="Opravdu chcete smazat tento záznam docházky?"
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
)
}

View File

@@ -0,0 +1,728 @@
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 FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface BalanceEntry {
name: string
vacation_total: number
vacation_used: number
vacation_remaining: number
sick_used: number
}
interface UserShort {
id: number | string
name: string
}
interface FundUserData {
name: string
worked: number
covered: number
overtime: number
missing: number
}
interface MonthFundData {
month_name: string
fund: number
business_days: number
users?: Record<string, FundUserData>
}
interface ProjectUser {
user_id: number
user_name: string
hours: number
}
interface ProjectEntry {
project_id: number | null
project_number?: string
project_name?: string
hours: number
users: ProjectUser[]
}
interface MonthProjectData {
month_name: string
projects: ProjectEntry[]
}
interface BalancesData {
users: UserShort[]
balances: Record<string, BalanceEntry>
}
interface FundData {
months: Record<string, MonthFundData>
holidays: unknown[]
users: UserShort[]
balances: Record<string, unknown>
}
interface ProjectData {
months: Record<string, MonthProjectData>
}
const getVacationClass = (remaining: number): string => {
if (remaining <= 0) return 'text-danger'
if (remaining < 20) return 'text-warning'
return ''
}
const renderFundDiff = (data: { overtime: number; missing: number }) => {
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: FundUserData, isFulfilled: boolean, isCurrentMonth: boolean) => {
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: FundUserData, isFulfilled: boolean, isCurrentMonth: boolean): string => {
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<BalancesData>({
users: [],
balances: {}
})
const [fundLoading, setFundLoading] = useState(true)
const [fundData, setFundData] = useState<FundData>({
months: {},
holidays: [],
users: [],
balances: {}
})
const [projectLoading, setProjectLoading] = useState(true)
const [projectData, setProjectData] = useState<ProjectData>({ months: {} })
const [showEditModal, setShowEditModal] = useState(false)
const [editingUser, setEditingUser] = useState<{ id: string; name: string } | null>(null)
const [editForm, setEditForm] = useState({
vacation_total: 160,
vacation_used: 0,
sick_used: 0
})
const [resetConfirm, setResetConfirm] = useState<{ show: boolean; userId: string | null; userName: string }>({ show: false, userId: null, userName: '' })
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/attendance?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?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?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: string, balance: BalanceEntry) => {
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 () => {
if (!editingUser) return
try {
const response = await apiFetch(`${API_BASE}/attendance?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?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: number[] = []
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
years.push(y)
}
const getYearFundTotals = (userId: string) => {
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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 className="fw-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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
className="mt-6"
>
<h2 className="admin-page-title mb-4" style={{ fontSize: '1.25rem' }}>
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?.[String(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 className="mt-6">
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
className="mt-6"
>
<h2 className="admin-page-title mb-4" style={{ fontSize: '1.25rem' }}>
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 className="mt-6">
<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">
<FormField label="Nárok na dovolenou (hodiny)">
<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"
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)">
<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"
/>
</FormField>
<FormField label="Čerpáno nemocenské (hodiny)">
<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"
/>
</FormField>
</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,324 @@
import { useState, useEffect } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface User {
id: number | string
name: string
}
interface CreateForm {
user_id: string
shift_date: string
leave_type: string
leave_hours: number
arrival_date: string
arrival_time: string
break_start_date: string
break_start_time: string
break_end_date: string
break_end_time: string
departure_date: string
departure_time: string
notes: string
}
export default function AttendanceCreate() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [users, setUsers] = useState<User[]>([])
const today = new Date().toISOString().split('T')[0]
const [form, setForm] = useState<CreateForm>({
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}/users`)
const result = await response.json()
if (result.success) {
setUsers(Array.isArray(result.data) ? result.data : result.data?.items || [])
}
} catch {
alert.error('Nepodařilo se načíst uživatele')
} finally {
setLoading(false)
}
}
fetchUsers()
}, [alert])
const handleSubmit = async (e: React.FormEvent) => {
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`, {
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: string) => {
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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">
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
style={{ maxWidth: '600px' }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row">
<FormField label="Zaměstnanec" required>
<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>
</FormField>
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
/>
</FormField>
</div>
<FormField label="Typ záznamu" required>
<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>
</FormField>
{!isWorkType && (
<FormField label="Počet hodin">
<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>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) => setForm({ ...form, arrival_date: val })}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) => setForm({ ...form, arrival_time: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) => setForm({ ...form, break_start_date: val })}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) => setForm({ ...form, break_start_time: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) => setForm({ ...form, break_end_date: val })}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) => setForm({ ...form, break_end_time: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) => setForm({ ...form, departure_date: val })}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) => setForm({ ...form, departure_time: val })}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</FormField>
<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,586 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } from '../utils/attendanceHelpers'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface ProjectLog {
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
}
interface AttendanceRecord {
id: number
shift_date: string
leave_type?: string
leave_hours?: number
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
notes?: string
project_name?: string
project_logs?: ProjectLog[]
}
const MONTH_NAMES = [
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec'
]
const formatBreakRange = (record: AttendanceRecord): string => {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
}
return '—'
}
const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{record.project_logs.map((log, i) => {
let h: number, m: number, isActive = false
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0
m = parseInt(String(log.minutes)) || 0
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
</span>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '—'
}
export default function AttendanceHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const printRef = useRef<HTMLDivElement>(null)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [records, setRecords] = useState<AttendanceRecord[]>([])
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [yearStr, monthStr] = month.split('-')
const response = await apiFetch(`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ''}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setRecords(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
setLoading(false)
}
}, [month, alert, user?.id])
useEffect(() => {
fetchData()
}, [fetchData])
// Compute totals client-side from raw records
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split('-')
const monthIndex = parseInt(monthStr, 10) - 1
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`
let totalMinutes = 0
let vacationHours = 0
let sickHours = 0
let holidayHours = 0
let unpaidHours = 0
for (const record of records) {
const leaveType = record.leave_type || 'work'
if (leaveType === 'work') {
totalMinutes += calculateWorkMinutes(record)
} else {
const hours = Number(record.leave_hours) || 8
if (leaveType === 'vacation') vacationHours += hours
else if (leaveType === 'sick') sickHours += hours
else if (leaveType === 'holiday') holidayHours += hours
else if (leaveType === 'unpaid') unpaidHours += hours
}
}
// Compute monthly fund (working days * 8h)
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10)
const mo = parseInt(monthStr, 10) - 1
// Count holiday records to subtract from business days
const holidayDays = records.filter(r => (r.leave_type || 'work') === 'holiday').length
let businessDays = 0
const cur = new Date(yr, mo, 1)
while (cur.getMonth() === mo) {
const dow = cur.getDay()
if (dow !== 0 && dow !== 6) businessDays++
cur.setDate(cur.getDate() + 1)
}
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
businessDays = Math.max(0, businessDays - holidayDays)
const fund = businessDays * 8
const worked = Math.round((totalMinutes / 60) * 100) / 100
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
const leaveHours = vacationHours + sickHours
const covered = Math.round((worked + leaveHours) * 100) / 100
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100)
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100)
const monthlyFund = {
fund,
business_days: businessDays,
worked,
covered,
remaining,
overtime,
}
return { monthName, totalMinutes, vacationHours, sickHours, holidayHours, unpaidHours, monthlyFund }
}, [records, month])
if (!hasPermission('attendance.history')) return <Forbidden />
const handlePrint = () => {
if (!printRef.current) return
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
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 - ${computed.monthName}</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>
${content}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Historie docházky</h1>
<p className="admin-page-subtitle">{computed.monthName}</p>
</div>
<div className="admin-page-actions">
{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 mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
</div>
</div>
</motion.div>
{/* Monthly Fund Card */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-row" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line" style={{ width: '48px', height: '48px', borderRadius: '12px', flexShrink: 0 }} />
<div className="flex-1">
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-full" style={{ height: '6px', borderRadius: '3px' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px', marginTop: '0.5rem' }} />
</div>
</div>
</div>
)}
{!loading && computed.monthlyFund && (
<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: {computed.monthlyFund.worked}h / {computed.monthlyFund.fund}h
</span>
<span className="text-secondary" style={{ fontSize: '0.8125rem' }}>
{computed.monthlyFund.business_days} prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
background: computed.monthlyFund.covered >= computed.monthlyFund.fund
? 'linear-gradient(135deg, var(--success), #059669)'
: 'var(--gradient)'
}}
/>
</div>
<div className="text-muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginTop: '0.375rem' }}>
<span>
{'Pokryto: '}{computed.monthlyFund.covered}h (práce {computed.monthlyFund.worked}h
{computed.vacationHours > 0 && ` + dovolená ${computed.vacationHours}h`}
{computed.sickHours > 0 && ` + nemoc ${computed.sickHours}h`}
{computed.holidayHours > 0 && ` + svátek ${computed.holidayHours}h`}
{computed.unpaidHours > 0 && ` + neplacené ${computed.unpaidHours}h`}
)
</span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">Přesčas: +{computed.monthlyFund.overtime}h</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
</div>
</div>
</div>
)}
{!loading && !computed.monthlyFund && (
<div className="text-muted" style={{ fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem 0' }}>
Fond měsíce není k dispozici
</div>
)}
</div>
</motion.div>
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<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 && records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{!loading && 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>
{records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = isLeave
? (Number(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 */}
{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">{computed.monthName}</div>
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div>
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ''}</h3>
<span className="total">Odpracováno: {formatMinutes(computed.totalMinutes, true)}</span>
</div>
{(computed.vacationHours > 0 || computed.sickHours > 0 || computed.holidayHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && <><span className="leave-badge badge-vacation">Dovolená: {computed.vacationHours}h</span> </>}
{computed.sickHours > 0 && <><span className="leave-badge badge-sick">Nemoc: {computed.sickHours}h</span> </>}
{computed.holidayHours > 0 && <><span className="leave-badge badge-holiday">Svátek: {computed.holidayHours}h</span> </>}
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{[...records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td className="text-center">
{isLeave || !record.break_start || !record.break_end
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
}
</td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style={{ fontSize: '8px' }}>
{(record.project_logs && record.project_logs.length > 0)
? record.project_logs.map((log, i) => {
let h: number, m: number
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0
} else if (log.started_at && log.ended_at) {
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at).getTime()) / 60000))
h = Math.floor(mins2 / 60); m = mins2 % 60
} else { h = 0; m = 0 }
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
})
: record.project_name || '—'}
</td>
<td>{record.notes || ''}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">Odpracováno:</td>
<td className="text-center">{formatMinutes(computed.totalMinutes, true)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
</td></tr>
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,335 @@
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'
declare const L: any
interface LocationRecord {
user_name: string
shift_date: string
arrival_time?: string | null
departure_time?: string | null
arrival_lat?: string | number | null
arrival_lng?: string | number | null
arrival_accuracy?: number | null
arrival_address?: string | null
departure_lat?: string | number | null
departure_lng?: string | number | null
departure_accuracy?: number | null
departure_address?: string | null
}
export default function AttendanceLocation() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [loading, setLoading] = useState(true)
const [record, setRecord] = useState<LocationRecord | null>(null)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstanceRef = useRef<unknown>(null)
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance?action=location&id=${id}`)
const result = await response.json()
if (result.success) {
const raw = result.data.record || result.data
// Enrich with user_name from nested users relation
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || ''
setRecord({ ...raw, user_name: userName })
} 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 as unknown as Record<string, unknown>).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 as { remove: () => void }).remove()
}
const map = L.map(mapRef.current!)
mapInstanceRef.current = map
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map)
const bounds: [number, number][] = []
interface LocationPoint {
lat: number
lng: number
type: string
label: string
time: string
accuracy: number
}
const locations: LocationPoint[] = []
if (hasArrivalLocation) {
locations.push({
lat: parseFloat(String(record.arrival_lat)),
lng: parseFloat(String(record.arrival_lng)),
type: 'arrival',
label: 'Příchod',
time: formatTime(record.arrival_time),
accuracy: Number(record.arrival_accuracy) || 0
})
}
if (hasDepartureLocation) {
locations.push({
lat: parseFloat(String(record.departure_lat)),
lng: parseFloat(String(record.departure_lng)),
type: 'departure',
label: 'Odchod',
time: formatTime(record.departure_time),
accuracy: Number(record.departure_accuracy) || 0
})
}
locations.forEach(loc => {
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 as { remove: () => void }).remove()
mapInstanceRef.current = null
}
}
}, [record, loading])
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`
}
if (!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 shiftDateStr = record.shift_date.includes('T') ? record.shift_date.split('T')[0] : record.shift_date
const month = shiftDateStr.substring(0, 7)
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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">
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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 ? formatDatetimeLocal(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(Number(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 mt-2"
>
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 ? formatDatetimeLocal(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(Number(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 mt-2"
>
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,437 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import Forbidden from '../components/Forbidden'
import Pagination from '../components/Pagination'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import { czechPlural } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const ACTION_LABELS: Record<string, string> = {
create: 'Vytvoření',
update: 'Úprava',
delete: 'Smazání',
login: 'Přihlášení',
login_failed: 'Neúspěšné přihlášení',
logout: 'Odhlášení',
view: 'Zobrazení',
activate: 'Aktivace',
deactivate: 'Deaktivace',
password_change: 'Změna hesla',
permission_change: 'Změna oprávnění',
access_denied: 'Přístup odepřen',
}
const ACTION_BADGE_CLASS: Record<string, string> = {
create: 'admin-badge-success',
update: 'admin-badge-info',
delete: 'admin-badge-danger',
login: 'admin-badge-secondary',
login_failed: 'admin-badge-danger',
logout: 'admin-badge-secondary',
view: 'admin-badge-info',
activate: 'admin-badge-success',
deactivate: 'admin-badge-warning',
password_change: 'admin-badge-info',
permission_change: 'admin-badge-warning',
access_denied: 'admin-badge-danger',
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
interface AuditLogEntry {
id: number
created_at: string
username: string | null
action: string
entity_type: string | null
description: string | null
user_ip: string | null
}
interface PaginationData {
total: number
page: number
per_page: number
total_pages: number
}
interface Filters {
search: string
action: string
entity_type: string
date_from: string
date_to: string
}
export default function AuditLog() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [logs, setLogs] = useState<AuditLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState<PaginationData | null>(null)
const [filters, setFilters] = useState<Filters>({
search: '',
action: '',
entity_type: '',
date_from: '',
date_to: '',
})
const [showCleanup, setShowCleanup] = useState(false)
const [cleanupDays, setCleanupDays] = useState(90)
const [cleaning, setCleaning] = useState(false)
const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (filters.search) params.set('search', filters.search)
if (filters.action) params.set('action', filters.action)
if (filters.entity_type) params.set('entity_type', filters.entity_type)
if (filters.date_from) params.set('date_from', filters.date_from)
if (filters.date_to) params.set('date_to', filters.date_to)
const response = await apiFetch(`${API_BASE}/audit-log?${params.toString()}`)
const data = await response.json()
if (data.success) {
setLogs(Array.isArray(data.data) ? data.data : [])
setPagination({
total: data.pagination?.total ?? 0,
page: data.pagination?.page ?? 1,
per_page: data.pagination?.limit ?? 50,
total_pages: data.pagination?.total_pages ?? 1,
})
} else {
alert.error(data.error || 'Nepodařilo se načíst audit log')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [filters, alert])
useEffect(() => {
fetchLogs()
}, [fetchLogs])
if (!hasPermission('settings.audit')) {
return <Forbidden />
}
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
const handlePageChange = (newPage: number) => {
fetchLogs(newPage, pagination?.per_page || 50)
}
const handlePerPageChange = (newPerPage: number) => {
fetchLogs(1, newPerPage)
}
const handleCleanup = async () => {
setCleaning(true)
try {
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: cleanupDays }),
})
const data = await response.json()
if (data.success) {
alert.success(data.message)
setShowCleanup(false)
fetchLogs()
} else {
alert.error(data.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setCleaning(false)
}
}
const formatDatetime = (dateString: string | null): string => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('cs-CZ')
}
if (loading && logs.length === 0) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<div className="admin-skeleton-line h-8" style={{ width: '160px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '100px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '4px' }} />
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line" style={{ width: '120px' }} />
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div className="admin-skeleton-line" style={{ width: '70px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div className="admin-skeleton-line flex-1" />
<div className="admin-skeleton-line" style={{ width: '90px' }} />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Audit log</h1>
{pagination && (
<p className="admin-page-subtitle">
{pagination.total} {czechPlural(pagination.total, 'záznam', 'záznamy', 'záznamů')}
</p>
)}
</div>
<button
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => setShowCleanup(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
Vyčistit
</button>
</motion.div>
{showCleanup && (
<div className="admin-modal-overlay" style={{ opacity: 1 }}>
<div className="admin-modal-backdrop" onClick={() => !cleaning && setShowCleanup(false)} />
<motion.div
className="admin-modal admin-confirm-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-danger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</div>
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
<div style={{ margin: '0.75rem auto', maxWidth: '200px' }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
<p className="admin-confirm-message" style={{ fontSize: '12px', opacity: 0.6 }}>Tato akce je nevratná.</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowCleanup(false)}
className="admin-btn admin-btn-secondary"
disabled={cleaning}
>
Zrušit
</button>
<button
type="button"
onClick={handleCleanup}
className="admin-btn admin-btn-primary"
disabled={cleaning}
>
{cleaning ? 'Mažu...' : 'Smazat'}
</button>
</div>
</motion.div>
</div>
)}
<motion.div
className="admin-card mb-4"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row admin-form-row-5">
<FormField label="Hledat">
<input
type="text"
className="admin-form-input"
placeholder="Popis, uživatel..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
</FormField>
<FormField label="Akce">
<select
className="admin-form-select"
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
>
<option value="">Všechny</option>
{ACTION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
<FormField label="Typ entity">
<select
className="admin-form-select"
value={filters.entity_type}
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
>
<option value="">Všechny</option>
{ENTITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
<FormField label="Od">
<AdminDatePicker
mode="date"
value={filters.date_from}
onChange={(val: string) => handleFilterChange('date_from', val)}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={filters.date_to}
onChange={(val: string) => handleFilterChange('date_to', val)}
/>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{loading && Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td><div className="admin-skeleton-line" style={{ width: '110px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '70px', height: '22px', borderRadius: '10px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '60%', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '90px', height: '14px' }} /></td>
</tr>
))}
{!loading && logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<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é záznamy k zobrazení</p>
</div>
</td>
</tr>
)}
{!loading && logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">{formatDatetime(log.created_at)}</td>
<td className="fw-500">{log.username || '-'}</td>
<td>
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>{ENTITY_TYPE_LABELS[log.entity_type || ''] || log.entity_type || '-'}</td>
<td>{log.description || '-'}</td>
<td className="admin-mono">{log.user_ip || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
/>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,788 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import FormField from '../components/FormField'
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: Record<string, string> = {
street: 'Ulice',
city_postal: 'Město + PSČ',
country: 'Země',
company_id: 'IČO',
vat_id: 'DIČ',
}
const currentYear = new Date().getFullYear().toString().slice(-2)
interface CustomField {
name: string
value: string
showLabel: boolean
_key: string
}
interface CompanyForm {
company_name: string
street: string
city: string
postal_code: string
country: string
company_id: string
vat_id: string
quotation_prefix: string
default_currency: string
default_vat_rate: number
order_type_code: string
invoice_type_code: string
}
interface BankAccount {
id: number
account_name: string
bank_name: string
account_number: string
iban: string
bic: string
currency: string
is_default: boolean
}
interface BankForm {
account_name: string
bank_name: string
account_number: string
iban: string
bic: string
currency: string
is_default: boolean
}
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<string | null>(null)
const logoUrlRef = useRef<string | null>(null)
const [form, setForm] = useState<CompanyForm>({
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<CustomField[]>([])
const customFieldKeyCounter = useRef(0)
const [fieldOrder, setFieldOrder] = useState<string[]>([...DEFAULT_FIELD_ORDER])
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([])
const [bankLoading, setBankLoading] = useState(true)
const [bankSaving, setBankSaving] = useState(false)
const [editingBank, setEditingBank] = useState<number | null>(null)
const [bankForm, setBankForm] = useState<BankForm>({ account_name: '', bank_name: '', account_number: '', iban: '', bic: '', currency: 'CZK', is_default: false })
const getFullFieldOrder = useCallback((): string[] => {
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: number, direction: number) => {
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: string): string => {
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/logo`)
if (resp.ok) {
const blob = await resp.blob()
setLogoUrl(prev => {
if (prev) URL.revokeObjectURL(prev)
const url = URL.createObjectURL(blob)
logoUrlRef.current = url
return url
})
}
} catch {
// ignore - no logo
}
}, [])
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings`)
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: { name: string; value: string; showLabel?: boolean }) => ({ ...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`)
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/${editingBank}` : `${API_BASE}/bank-accounts`
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: number) => {
if (!confirm('Opravdu smazat tento bankovní účet?')) return
try {
const response = await apiFetch(`${API_BASE}/bank-accounts/${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: BankAccount) => {
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])
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (logoUrlRef.current) URL.revokeObjectURL(logoUrlRef.current)
}
}, [])
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`, {
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: React.ChangeEvent<HTMLInputElement>) => {
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/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: keyof CompanyForm, value: string | number) => {
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()
const renderBankButtonContent = (): React.ReactNode => {
if (bankSaving) {
return <><div className="admin-spinner admin-spinner-sm" />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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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 admin-spinner-sm" />
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Firemní údaje</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<FormField label="Název firmy">
<input type="text" value={form.company_name} onChange={(e) => updateField('company_name', e.target.value)} className="admin-form-input" />
</FormField>
<div className="admin-form-row">
<FormField label="Ulice">
<input type="text" value={form.street} onChange={(e) => updateField('street', e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="Město">
<input type="text" value={form.city} onChange={(e) => updateField('city', e.target.value)} className="admin-form-input" />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="PSČ">
<input type="text" value={form.postal_code} onChange={(e) => updateField('postal_code', e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="Země">
<input type="text" value={form.country} onChange={(e) => updateField('country', e.target.value)} className="admin-form-input" />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="IČO">
<input type="text" value={form.company_id} onChange={(e) => updateField('company_id', e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="DIČ">
<input type="text" value={form.vat_id} onChange={(e) => updateField('vat_id', e.target.value)} className="admin-form-input" />
</FormField>
</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' }}>
<FormField label={idx === 0 ? 'Název' : '\u00A0'} style={{ flex: 1 }}>
<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."
/>
</FormField>
<FormField label={idx === 0 ? 'Hodnota' : '\u00A0'} style={{ flex: 1 }}>
<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 =>
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>
</FormField>
</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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<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-responsive mb-4">
<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 className="text-center">
{acc.is_default ? <span className="text-accent fw-600"></span> : '\u2013'}
</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">
<FormField label="Název účtu" required>
<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" />
</FormField>
<FormField label="Název banky">
<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." />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Číslo účtu">
<input type="text" value={bankForm.account_number} onChange={e => setBankForm(f => ({ ...f, account_number: e.target.value }))} className="admin-form-input" placeholder="123456789/0600" />
</FormField>
<FormField label="Měna">
<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>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="IBAN">
<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" />
</FormField>
<FormField label="BIC / SWIFT">
<input type="text" value={bankForm.bic} onChange={e => setBankForm(f => ({ ...f, bic: e.target.value }))} className="admin-form-input" placeholder="GIBACZPX" />
</FormField>
</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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.08 }}>
<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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<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 admin-spinner-sm" />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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<div className="admin-card-header">
<h3 className="admin-card-title">Číslování dokladů</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<FormField label="Nabídky — prefix">
<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>
</FormField>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
<FormField label="Objednávky a projekty — typový kód">
<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>
</FormField>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
<FormField label="Faktury — typový kód">
<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>
</FormField>
</div>
</div>
</motion.div>
{/* Default values */}
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<div className="admin-card-header">
<h3 className="admin-card-title">Výchozí hodnoty</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<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>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<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" />
</FormField>
</div>
</div>
</div>
</motion.div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,597 @@
import { useState, useEffect, useMemo, useCallback, useRef } 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 FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import { motion } from 'framer-motion'
import apiFetch from '../utils/api'
import { formatCurrency } from '../utils/formatters'
const API_BASE = '/api/admin'
const VAT_OPTIONS = [
{ value: 21, label: '21%' },
{ value: 12, label: '12%' },
{ value: 0, label: '0%' }
]
interface InvoiceItem {
_key: string
description: string
quantity: number
unit: string
unit_price: number
vat_rate: number
}
interface Customer {
id: number
name: string
company_id?: string
city?: string
}
interface BankAccount {
id: number
account_name: string
account_number?: string
bank_name?: string
bic?: string
iban?: string
is_default?: boolean
}
interface InvoiceForm {
customer_id: number | null
customer_name: string
order_id: number | null
issue_date: string
due_date: string
tax_date: string
currency: string
apply_vat: number
vat_rate: number
payment_method: string
constant_symbol: string
issued_by: string
notes: string
bank_account_id: number | string
bank_name: string
bank_swift: string
bank_iban: string
bank_account: string
}
export default function InvoiceCreate() {
const keyCounterRef = useRef(0)
const emptyItem = useCallback((): InvoiceItem => ({
_key: `inv-${++keyCounterRef.current}`,
description: '',
quantity: 1,
unit: 'ks',
unit_price: 0,
vat_rate: 21
}), [])
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<InvoiceForm>({
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<BankAccount[]>([])
const [dueDays, setDueDays] = useState(14)
const [items, setItems] = useState<InvoiceItem[]>([emptyItem()])
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [loadingInit, setLoadingInit] = useState(true)
const [invoiceNumber, setInvoiceNumber] = useState('')
// Customer selector
const [customers, setCustomers] = useState<Customer[]>([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
// Draft
const DRAFT_KEY = 'boha_invoice_draft'
const isManual = !fromOrderId
const clearDraft = useCallback(() => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
}, [])
// Load init data
useEffect(() => {
const load = async () => {
try {
const promises = [
apiFetch(`${API_BASE}/invoices/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/bank-accounts`)
]
if (fromOrderId) {
promises.push(apiFetch(`${API_BASE}/invoices/order-data/${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?.next_number || numData.data?.number || '')
}
const custRes = results[1]
if (custRes.ok) {
const custData = await custRes.json()
if (custData.success) setCustomers(Array.isArray(custData.data) ? custData.data : custData.data?.customers || [])
}
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: BankAccount) => a.is_default)
if (defaultAcc) {
setForm(prev => ({
...prev,
bank_account_id: defaultAcc.id,
bank_name: defaultAcc.bank_name || '',
bank_swift: defaultAcc.bic || '',
bank_iban: defaultAcc.iban || '',
bank_account: defaultAcc.account_number || ''
}))
}
}
}
// Pre-fill from order
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: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || '',
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || '',
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, alert])
// Due date calculation
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: string) => {
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: Customer) => {
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
setErrors(prev => ({ ...prev, customer_id: '' }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
// Items management
const updateItem = (index: number, field: keyof InvoiceItem, value: string | number) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addItem = () => setItems(prev => [...prev, emptyItem()])
const removeItem = (index: number) => {
if (items.length <= 1) return
setItems(prev => prev.filter((_, i) => i !== index))
}
// Totals
const totals = useMemo(() => {
let subtotal = 0
const vatByRate: Record<number, number> = {}
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?: React.FormEvent) => {
e?.preventDefault()
const newErrors: Record<string, string> = {}
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`, {
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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
<div className="flex-row gap-4">
<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>}
</div>
</div>
<div className="admin-page-actions">
<button onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (<><div className="admin-spinner admin-spinner-sm" />Ukládání...</>) : 'Uložit'}
</button>
</div>
</motion.div>
<form onSubmit={handleSubmit}>
{/* Basic info */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.06 }}>
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="offers-form-row-3">
<FormField label="Číslo faktury">
<input type="text" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="Odběratel" error={errors.customer_id} required>
{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>
)}
</FormField>
<FormField label="Vystavil">
<input type="text" value={form.issued_by} className="admin-form-input" readOnly style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }} />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Datum vystavení" error={errors.issue_date} required>
<AdminDatePicker mode="date" value={form.issue_date} onChange={(val: string) => { setForm(prev => ({ ...prev, issue_date: val })); setErrors(prev => ({ ...prev, issue_date: '' })) }} />
</FormField>
<FormField label="Splatnost (dny)">
<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>
)}
</FormField>
<FormField label="DÚZP" error={errors.tax_date} required>
<AdminDatePicker mode="date" value={form.tax_date} onChange={(val: string) => { setForm(prev => ({ ...prev, tax_date: val })); setErrors(prev => ({ ...prev, tax_date: '' })) }} />
</FormField>
</div>
<div className="offers-form-row-3">
<FormField label="Forma úhrady">
<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>
</FormField>
<FormField label="Měna">
<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>
</FormField>
<FormField label="DPH">
<div className="flex-row-gap">
<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>
</FormField>
</div>
<FormField label="Bankovní účet" error={errors.bank_account_id} required>
<select
value={form.bank_account_id}
onChange={(e) => { selectBankAccount(e.target.value); setErrors(prev => ({ ...prev, bank_account_id: '' })) }}
className="admin-form-select"
>
<option value="">{'\u2014'} Vyberte účet {'\u2014'}</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>
</FormField>
</div>
</motion.div>
{/* Items */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<div className="flex-between mb-4">
<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: '2rem', 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: '2.5rem' }}></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item._key}>
<td className="text-tertiary text-center fw-500">{index + 1}</td>
<td>
<input type="text" value={item.description} onChange={(e) => updateItem(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." />
</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>
{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>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Totals */}
<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>
{/* Notes */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<h3 className="admin-card-title">Veřejné poznámky na faktuře</h3>
<textarea
className="admin-form-input"
rows={4}
value={form.notes}
onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))}
placeholder="Poznámky zobrazené na faktuře..."
/>
</motion.div>
</form>
</div>
)
}

View File

@@ -0,0 +1,598 @@
import { useState, useEffect, useCallback, useMemo, useRef } 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 FormField from '../components/FormField'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate } from '../utils/formatters'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
issued: 'Vystavena',
paid: 'Zaplacena',
overdue: 'Po splatnosti'
}
const STATUS_CLASSES: Record<string, string> = {
issued: 'admin-badge-invoice-issued',
paid: 'admin-badge-invoice-paid',
overdue: 'admin-badge-invoice-overdue'
}
const TRANSITION_LABELS: Record<string, string> = { paid: 'Zaplaceno' }
const TRANSITION_CLASSES: Record<string, string> = { paid: 'admin-btn admin-btn-primary' }
const VAT_OPTIONS = [
{ value: 21, label: '21%' },
{ value: 12, label: '12%' },
{ value: 0, label: '0%' }
]
interface InvoiceItem {
id?: number
description: string
quantity: number
unit: string
unit_price: number
vat_rate: number
}
interface EditItem extends InvoiceItem {
_key: string
}
interface InvoiceCustomer {
company_id?: string
vat_id?: string
}
interface Invoice {
id: number
invoice_number: string
customer_name: string | null
customer?: InvoiceCustomer
order_id?: number
order_number?: string
currency: string
status: string
issue_date: string
due_date: string
tax_date: string
payment_method: string
issued_by: string | null
paid_date?: string
notes: string
apply_vat: number | string
items: InvoiceItem[]
valid_transitions?: string[]
}
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [invoice, setInvoice] = useState<Invoice | null>(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState<string | null>(null)
const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null }>({ show: false, status: null })
const [pdfLoading, setPdfLoading] = useState(false)
const [langModal, setLangModal] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
// Edit items
const [editingItems, setEditingItems] = useState(false)
const [editItems, setEditItems] = useState<EditItem[]>([])
const editKeyCounter = useRef(0)
const fetchDetail = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/invoices/${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)
}
}, [id, alert, navigate])
useEffect(() => {
fetchDetail()
}, [fetchDetail])
const totals = useMemo(() => {
if (!invoice?.items) return { subtotal: 0, vatByRate: {} as Record<number, number>, totalVat: 0, total: 0 }
let subtotal = 0
const vatByRate: Record<number, number> = {}
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/${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/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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/${id}?lang=${encodeURIComponent(lang)}`)
if (!response.ok) {
newWindow?.close()
alert.error('Nepodařilo se vygenerovat PDF')
return
}
const html = await response.text()
if (newWindow) {
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)
}
}
// Edit items
const startEditItems = () => {
if (!invoice) return
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: number, field: string, value: string | number) => {
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: number) => {
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/${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/${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 className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row gap-2">
<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>
)
}
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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
<div className="flex-row gap-4">
<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 flex-row-gap">
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 admin-spinner-sm" />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?.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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.06 }}>
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form">
<div className="offers-form-row-3 mb-2">
<FormField label="Zákazník">
<div className="fw-500">{invoice.customer_name || '\u2014'}</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>
)}
</FormField>
<FormField label="Objednávka">
<div>
{invoice.order_id ? (
<Link to={`/orders/${invoice.order_id}`} className="link-accent">{invoice.order_number}</Link>
) : '\u2014'}
</div>
</FormField>
<FormField label="Měna"><div>{invoice.currency}</div></FormField>
</div>
<div className="offers-form-row-3 mb-2">
<FormField label="Datum vystavení"><div>{formatDate(invoice.issue_date)}</div></FormField>
<FormField label="Datum splatnosti">
<div className={invoice.status === 'overdue' ? 'text-danger fw-600' : ''}>{formatDate(invoice.due_date)}</div>
</FormField>
<FormField label="DÚZP"><div>{formatDate(invoice.tax_date)}</div></FormField>
</div>
<div className="offers-form-row-3">
<FormField label="Forma úhrady"><div>{invoice.payment_method}</div></FormField>
<FormField label="Variabilní symbol"><div>{invoice.invoice_number}</div></FormField>
<FormField label="Vystavil"><div>{invoice.issued_by || '\u2014'}</div></FormField>
</div>
{invoice.paid_date && (
<div className="admin-form-row mt-2">
<FormField label="Datum úhrady">
<div style={{ color: 'var(--success)', fontWeight: 500 }}>{formatDate(invoice.paid_date)}</div>
</FormField>
</div>
)}
</div>
</motion.div>
{/* Items */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<div className="flex-between mb-4">
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
{isDraft && hasPermission('invoices.edit') && (
editingItems ? (
<div className="flex-row gap-2">
<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' }}></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 fw-500" placeholder="Popis položky..." /></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 className="fw-500">{item.description || '\u2014'}</td>
<td style={{ textAlign: 'center' }}>{item.quantity} {item.unit && <span className="text-tertiary">{item.unit}</span>}</td>
<td style={{ textAlign: 'center' }}>{item.unit || '\u2014'}</td>
<td className="admin-mono text-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>
)}
</>
)}
<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>
{/* Notes */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<h3 className="admin-card-title">Veřejné poznámky na faktuře</h3>
{isPaid ? (
notes && notes.trim() && notes !== '<p><br></p>' ? (
<div dangerouslySetInnerHTML={{ __html: notes }} />
) : (
<p className="text-tertiary">Žádné poznámky.</p>
)
) : (
<>
<textarea className="admin-form-input" rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Poznámky zobrazené na faktuře..." />
{hasPermission('invoices.edit') && (
<div className="mt-2">
<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,651 @@
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'
import Pagination from '../components/Pagination'
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'
]
interface CurrencyAmount {
amount: number
currency: string
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return '0 Kč'
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
}
function formatCzkWithDetail(amounts: CurrencyAmount[], totalCzk: number | null | undefined): { value: string; detail: string | null } {
if (!Array.isArray(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: Record<string, string> = {
issued: 'Vystavena',
paid: 'Zaplacena',
overdue: 'Po splatnosti'
}
const STATUS_CLASSES: Record<string, string> = {
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' }
]
interface Invoice {
id: number
invoice_number: string
customer_name: string | null
status: string
issue_date: string
due_date: string
total: number
currency: string
}
interface InvoiceStats {
paid_month: CurrencyAmount[]
paid_month_czk: number
paid_month_count: number
awaiting: CurrencyAmount[]
awaiting_czk: number
awaiting_count: number
overdue: CurrencyAmount[]
overdue_czk: number
overdue_count: number
vat_month: CurrencyAmount[]
vat_month_czk: number
}
interface DraftData {
form: Record<string, unknown>
items: Record<string, unknown>[]
savedAt?: string
}
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 [page, setPage] = useState(1)
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<InvoiceStats | null>(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/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: boolean; invoice: Invoice | null }>({ show: false, invoice: null })
const [deleting, setDeleting] = useState(false)
const [pdfLoading, setPdfLoading] = useState<number | null>(null)
const [langModal, setLangModal] = useState<Invoice | null>(null)
const [draft, setDraft] = useState<DraftData | null>(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as DraftData
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed
} catch { /* ignore */ }
return null
})
const discardDraft = () => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraft(null)
}
const { items: invoices, loading, initialLoad, pagination, refetch: fetchData } = useListData<Invoice>('invoices', {
search, sort, order, page,
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/${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: Invoice) => {
if (inv.status === 'paid') return
try {
const res = await apiFetch(`${API_BASE}/invoices/${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: Invoice, lang = 'cs') => {
if (pdfLoading) return
setLangModal(null)
setPdfLoading(inv.id)
try {
const response = await apiFetch(`${API_BASE}/invoices-pdf/${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 (initialLoad) {
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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
<div>
<h1 className="admin-page-title">Faktury</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? invoices.length} {czechPlural(pagination?.total ?? 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.06 }}>
<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 mb-4" style={{ 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, 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: number) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (dir: number) => ({ 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: number, zero: string) => 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: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<div className="offers-tabs mb-6">
{STATUS_FILTERS.map(f => (
<button
key={f.value}
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
onClick={() => { setStatusFilter(f.value); setPage(1) }}
>
{f.label}
</button>
))}
</div>
</motion.div>
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
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 className="text-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 as string) || '\u2014'}</td>
<td>{'\u2014'}</td>
<td className="admin-mono">
{draft.form.issue_date ? formatDate(draft.form.issue_date as string) : '\u2014'}
</td>
<td className="admin-mono">
{draft.form.due_date ? formatDate(draft.form.due_date as string) : '\u2014'}
</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 || '\u2014'}</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>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</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,503 @@
import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { motion, AnimatePresence } from 'framer-motion'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import { czechPlural } from '../utils/formatters'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
interface RawLeaveRequest {
id: number
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_note?: string
created_at: string
reviewed_at?: string
users_leave_requests_user_idTousers?: { first_name: string; last_name: string }
users_leave_requests_reviewer_idTousers?: { first_name: string; last_name: string } | null
}
interface LeaveRequest {
id: number
employee_name: string
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_name?: string
reviewer_note?: string
created_at: string
reviewed_at?: string
}
function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
const user = raw.users_leave_requests_user_idTousers
const reviewer = raw.users_leave_requests_reviewer_idTousers
return {
id: raw.id,
employee_name: user ? `${user.first_name} ${user.last_name}` : 'Neznámý',
leave_type: raw.leave_type,
date_from: raw.date_from,
date_to: raw.date_to,
total_days: raw.total_days,
total_hours: raw.total_hours,
status: raw.status,
notes: raw.notes,
reviewer_name: reviewer ? `${reviewer.first_name} ${reviewer.last_name}` : undefined,
reviewer_note: raw.reviewer_note,
created_at: raw.created_at,
reviewed_at: raw.reviewed_at,
}
}
export default function LeaveApproval() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'pending' | 'processed'>('pending')
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>([])
const [approveModal, setApproveModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectModal, setRejectModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectNote, setRejectNote] = useState('')
const [processing, setProcessing] = useState(false)
useModalLock(rejectModal.open)
const fetchPending = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=pending`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest)
setPendingRequests(mapped)
setPendingCount(result.pagination?.total ?? mapped.length)
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
}
}, [alert])
const fetchProcessed = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=approved`)
if (response.status === 401) return
const resultApproved = await response.json()
const response2 = await apiFetch(`${API_BASE}/leave-requests?status=rejected`)
if (response2.status === 401) return
const resultRejected = await response2.json()
const all = [
...(resultApproved.success ? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest) : []),
...(resultRejected.success ? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest) : [])
].sort((a: LeaveRequest, b: LeaveRequest) => new Date(b.reviewed_at!).getTime() - new Date(a.reviewed_at!).getTime())
setProcessedRequests(all)
} catch {
alert.error('Nepodařilo se načíst vyřízené žádosti')
}
}, [alert])
useEffect(() => {
setLoading(true)
fetchPending().finally(() => setLoading(false))
}, [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/${approveModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setApproveModal({ open: false, request: null })
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla schválena')
} 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/${rejectModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'rejected', reviewer_note: rejectNote })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setRejectModal({ open: false, request: null })
setRejectNote('')
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla zamítnuta')
} 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 className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="offers-tabs mb-6">
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
{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 mb-4">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<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 className="flex-1">
<div className="flex-row-gap mb-2">
<strong style={{ fontSize: '1rem' }}>{req.employee_name}</strong>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
{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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<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 mb-4">
{rejectModal.request.employee_name} {leaveTypeLabels[rejectModal.request.leave_type]},{' '}
{formatDate(rejectModal.request.date_from)} {formatDate(rejectModal.request.date_to)} ({rejectModal.request.total_days} dnů)
</p>
)}
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</FormField>
</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,258 @@
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: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
interface LeaveRequest {
id: number
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_note?: string
created_at: string
}
export default function LeaveRequests() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [requests, setRequests] = useState<LeaveRequest[]>([])
const [cancelModal, setCancelModal] = useState<{ open: boolean; id: number | null }>({ open: false, id: null })
const [cancelling, setCancelling] = useState(false)
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests`)
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/${cancelModal.id}`, {
method: 'DELETE',
})
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' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
)
}
function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) => text.length > 40 ? `${text.substring(0, 40)}...` : text
if (req.status === 'rejected' && req.reviewer_note) {
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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>
)
}

321
src/admin/pages/Login.tsx Normal file
View File

@@ -0,0 +1,321 @@
import { useState, useEffect, useRef } from 'react'
import { Navigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { useTheme } from '../../context/ThemeContext'
import { shouldShowSessionExpiredAlert, shouldShowLogoutAlert } from '../utils/api'
import FormField from '../components/FormField'
export default function Login() {
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth()
const alert = useAlert()
const { theme, toggleTheme } = useTheme()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false)
const [shake, setShake] = useState(false)
const [animatingOut, setAnimatingOut] = useState(false)
// 2FA state
const [show2FA, setShow2FA] = useState(false)
const [loginToken, setLoginToken] = useState<string | null>(null)
const [totpCode, setTotpCode] = useState('')
const [useBackupCode, setUseBackupCode] = useState(false)
const totpInputRef = useRef<HTMLInputElement>(null)
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.')
}
}, [alert])
// 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: React.FormEvent) => {
e.preventDefault()
setLoading(true)
const result = await login(username, password, remember)
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null)
setShow2FA(true)
setTotpCode('')
setLoading(false)
} else if (!result.success) {
alert.error(result.error ?? 'Chyba přihlášení')
setShake(true)
setTimeout(() => setShake(false), 500)
setLoading(false)
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
}
}
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!totpCode.trim()) return
setLoading(true)
const result = await verify2FA(loginToken!, totpCode.trim(), remember, useBackupCode)
if (!result.success) {
alert.error(result.error ?? 'Chyba ověření')
setShake(true)
setTimeout(() => setShake(false), 500)
setTotpCode('')
if (totpInputRef.current) totpInputRef.current.focus()
setLoading(false)
} 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.98 }}
animate={animatingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
}
transition={animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
}
>
<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="Logo"
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">
<FormField label="Uživatelské jméno nebo e-mail">
<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"
/>
</FormField>
<FormField label="Heslo">
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="admin-form-input"
placeholder="Zadejte heslo"
/>
</FormField>
<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">
<FormField label={useBackupCode ? 'Záložní kód' : 'Ověřovací kód'}>
<input
ref={totpInputRef}
id="totp-code"
type="text"
inputMode={useBackupCode ? 'text' : 'numeric'}
pattern={useBackupCode ? undefined : '[0-9]*'}
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'
}}
/>
</FormField>
<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>
)
}

View File

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

File diff suppressed because it is too large Load Diff

656
src/admin/pages/Offers.tsx Normal file
View File

@@ -0,0 +1,656 @@
import { useState } 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'
import Pagination from '../components/Pagination'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
const DRAFT_KEY = 'boha_offer_draft'
interface Quotation {
id: number
quotation_number: string
project_code: string
customer_name: string
created_at: string
valid_until: string
currency: string
total: number
status: string
order_id?: number
}
interface Draft {
form: {
project_code: string
customer_name: string
created_at: string
valid_until: string
currency: string
}
items: unknown[]
savedAt?: string
}
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 [page, setPage] = useState(1)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
const [deleting, setDeleting] = useState(false)
const [invalidateConfirm, setInvalidateConfirm] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
const [invalidating, setInvalidating] = useState(false)
const [duplicating, setDuplicating] = useState<number | null>(null)
const [pdfLoading, setPdfLoading] = useState<number | null>(null)
const [creatingOrder, setCreatingOrder] = useState<number | null>(null)
const [orderModal, setOrderModal] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
useModalLock(orderModal.show)
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
const [orderAttachment, setOrderAttachment] = useState<File | null>(null)
const [draft, setDraft] = useState<Draft | null>(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed
} catch { /* ignore corrupt data */ }
return null
})
const { items: quotations, loading, initialLoad, pagination, refetch: fetchData } = useListData('offers', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst nabídky'
})
const discardDraft = () => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraft(null)
}
const getRowClass = (invalidated: boolean, expired: boolean) => {
if (invalidated) return 'offers-invalidated-row'
if (expired) return 'offers-expired-row'
return ''
}
if (!hasPermission('offers.view')) return <Forbidden />
const handleDuplicate = async (quotation: Quotation) => {
setDuplicating(quotation.id)
try {
const response = await apiFetch(`${API_BASE}/offers/${quotation.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
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', String(orderModal.quotation.id))
formData.append('customerOrderNumber', customerOrderNumber.trim())
if (orderAttachment) {
formData.append('attachment', orderAttachment)
}
const response = await apiFetch(`${API_BASE}/orders`, {
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/${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/${invalidateConfirm.quotation.id}/invalidate`, {
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: Quotation) => {
if (pdfLoading) return
setPdfLoading(quotation.id)
try {
const response = await apiFetch(`${API_BASE}/offers-pdf/${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 (initialLoad) {
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 className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Nabídky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? quotations.length} {czechPlural(pagination?.total ?? 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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
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 className="text-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 as Quotation[]).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 text-right fw-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>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</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">
<FormField label="Číslo objednávky zákazníka" required>
<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
/>
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<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>
</FormField>
</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,664 @@
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 FormField from '../components/FormField'
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: Record<string, string> = {
street: 'Ulice',
city_postal: 'Město + PSČ',
country: 'Země',
company_id: 'IČO',
vat_id: 'DIČ',
}
interface Customer {
id: number
name: string
street?: string
city?: string
postal_code?: string
country?: string
company_id?: string
vat_id?: string
quotation_count: number
custom_fields?: CustomField[]
customer_field_order?: string[]
}
interface CustomField {
name: string
value: string
showLabel: boolean
_key?: string
}
interface CustomerForm {
name: string
street: string
city: string
postal_code: string
country: string
company_id: string
vat_id: string
}
export default function OffersCustomers() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [customers, setCustomers] = useState<Customer[]>([])
const [search, setSearch] = useState('')
const [showModal, setShowModal] = useState(false)
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<CustomerForm>({
name: '',
street: '',
city: '',
postal_code: '',
country: '',
company_id: '',
vat_id: '',
})
const [customFields, setCustomFields] = useState<CustomField[]>([])
const customFieldKeyCounter = useRef(0)
const [fieldOrder, setFieldOrder] = useState<string[]>([...DEFAULT_CUSTOMER_FIELD_ORDER])
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; customer: Customer | null }>({ show: false, customer: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
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: number, direction: number) => {
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: string) => {
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`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setCustomers(Array.isArray(result.data) ? result.data : [])
} 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: 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 || '',
})
const cf = Array.isArray(customer.custom_fields) && customer.custom_fields.length > 0
? customer.custom_fields.map(f => ({ ...f, _key: `cf-${++customFieldKeyCounter.current}` }))
: []
setCustomFields(cf)
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/${editingCustomer.id}`
: `${API_BASE}/customers`
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/${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 className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<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">
<FormField label="Název" required>
<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"
/>
</FormField>
<FormField label="Ulice">
<input
type="text"
value={form.street}
onChange={(e) => setForm(prev => ({ ...prev, street: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Město">
<input
type="text"
value={form.city}
onChange={(e) => setForm(prev => ({ ...prev, city: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<FormField label="PSČ">
<input
type="text"
value={form.postal_code}
onChange={(e) => setForm(prev => ({ ...prev, postal_code: e.target.value }))}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Země">
<input
type="text"
value={form.country}
onChange={(e) => setForm(prev => ({ ...prev, country: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.company_id}
onChange={(e) => setForm(prev => ({ ...prev, company_id: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
value={form.vat_id}
onChange={(e) => setForm(prev => ({ ...prev, vat_id: e.target.value }))}
className="admin-form-input"
/>
</FormField>
</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' }}>
<FormField label={idx === 0 ? 'Název' : '\u00A0'} style={{ flex: 1 }}>
<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"
/>
</FormField>
<FormField label={idx === 0 ? 'Hodnota' : '\u00A0'} style={{ flex: 1 }}>
<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>
</FormField>
</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 admin-spinner-sm" />
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,627 @@
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface ItemTemplate {
id: number
name: string
description: string
default_price: number
category: string
}
interface ScopeSection {
_key: string
title: string
title_cz: string
content: string
}
interface ScopeTemplate {
id: number
name: string
sections?: ScopeSection[]
}
interface ItemForm {
name: string
description: string
default_price: number
category: string
}
interface ScopeForm {
name: string
sections: ScopeSection[]
}
export default function OffersTemplates() {
const { hasPermission } = useAuth()
const [activeTab, setActiveTab] = useState<'items' | 'scopes'>('items')
if (!hasPermission('offers.settings')) return <Forbidden />
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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<ItemTemplate[]>([])
const [showModal, setShowModal] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<ItemForm>({ name: '', description: '', default_price: 0, category: '' })
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; template: ItemTemplate | null }>({ show: false, template: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates?action=items`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : [])
}
} 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: ItemTemplate) => {
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?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?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 className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header flex-between">
<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 className="fw-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">
<FormField label="Název" required>
<input type="text" value={form.name} onChange={(e) => setForm(p => ({ ...p, name: e.target.value }))} className="admin-form-input" />
</FormField>
<FormField label="Popis">
<textarea value={form.description} onChange={(e) => setForm(p => ({ ...p, description: e.target.value }))} className="admin-form-input" rows={2} />
</FormField>
<div className="admin-form-row">
<FormField label="Výchozí cena">
<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" />
</FormField>
<FormField label="Kategorie">
<input type="text" value={form.category} onChange={(e) => setForm(p => ({ ...p, category: e.target.value }))} className="admin-form-input" />
</FormField>
</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 admin-spinner-sm" />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<ScopeTemplate[]>([])
const [showModal, setShowModal] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<ScopeForm>({ name: '', sections: [] })
const sectionKeyCounter = useRef(0)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; template: ScopeTemplate | null }>({ show: false, template: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : [])
}
} 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: ScopeTemplate) => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates/${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: { title?: string; title_cz?: string; content?: string }) => ({ _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: number) => {
setForm(prev => ({
...prev,
sections: prev.sections.filter((_, i) => i !== index)
}))
}
const updateSection = (index: number, field: string, value: string) => {
setForm(prev => ({
...prev,
sections: prev.sections.map((s, i) => i === index ? { ...s, [field]: value } : s)
}))
}
const moveSection = (index: number, direction: number) => {
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 url = editingTemplate
? `${API_BASE}/offers-templates/${editingTemplate.id}`
: `${API_BASE}/offers-templates`
const method = editingTemplate ? 'PUT' : 'POST'
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
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/${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 className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header flex-between">
<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 className="fw-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">
<FormField label="Název šablony" required>
<input type="text" value={form.name} onChange={(e) => setForm(p => ({ ...p, name: e.target.value }))} className="admin-form-input" />
</FormField>
<div className="admin-form-group">
<label className="admin-form-label mb-2">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">
<FormField label={<><span className="offers-lang-badge">EN</span> Název sekce</>}>
<input type="text" value={section.title} onChange={(e) => updateSection(index, 'title', e.target.value)} className="admin-form-input" placeholder="Název sekce (anglicky)" />
</FormField>
<FormField label={<><span className="offers-lang-badge offers-lang-badge-cz">CZ</span> Název sekce</>}>
<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)" />
</FormField>
</div>
<FormField label="Obsah">
<textarea
value={section.content}
onChange={(e) => updateSection(index, 'content', e.target.value)}
className="admin-form-input"
placeholder="Obsah sekce..."
rows={6}
style={{ minHeight: '150px' }}
/>
</FormField>
</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 admin-spinner-sm" />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,671 @@
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
const DOMPurify = (window as any).DOMPurify || { sanitize: (html: string) => html }
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 FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate } from '../utils/formatters'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
const STATUS_CLASSES: Record<string, string> = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
const TRANSITION_LABELS: Record<string, string> = {
v_realizaci: 'Zahájit realizaci',
dokoncena: 'Dokončit'
}
const TRANSITION_CLASSES: Record<string, string> = {
v_realizaci: 'admin-btn admin-btn-primary',
dokoncena: 'admin-btn admin-btn-primary'
}
interface OrderItem {
id?: number
description: string
item_description?: string
quantity: number
unit: string
unit_price: number
is_included_in_total: number | boolean
}
interface OrderSection {
id?: number
title: string
title_cz?: string
content: string
}
interface Invoice {
id: number
invoice_number: string
}
interface Project {
id: number
project_number: string
name: string
has_nas_folder?: boolean
}
interface OrderData {
id: number
order_number: string
quotation_id: number
quotation_number: string
project_code?: string
customer_name: string
customer_order_number: string
currency: string
created_at: string
status: string
notes: string
attachment_name?: string
apply_vat: number | boolean
vat_rate: number
language?: string
items: OrderItem[]
sections: OrderSection[]
scope_title?: string
scope_description?: string
valid_transitions?: string[]
invoice?: Invoice
project?: Project
}
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<OrderData | null>(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState<string | null>(null)
const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null }>({ 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 [deleteFiles, setDeleteFiles] = useState(false)
const fetchDetail = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/orders/${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)
}
}, [id, alert, navigate])
useEffect(() => {
fetchDetail()
}, [fetchDetail])
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/${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 = () => {
if (!order) return
setOrderNumber(order.order_number)
setEditingNumber(true)
}
const handleSaveNumber = async () => {
if (!order) return
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/${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/${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/${id}/attachment`)
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)
if (newWindow) 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/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
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 className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row gap-2">
<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>
)
}
if (!order) return null
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<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 flex-row-gap">
{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 admin-spinner-sm" />
) : (
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form-row mb-2">
<FormField label="Nabídka">
<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>
</FormField>
<FormField label="Projekt">
<div>
{order.project ? (
<Link to={`/projects/${order.project.id}`} className="link-accent">
{order.project.project_number} {order.project.name}
</Link>
) : '—'}
</div>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3 mb-2">
<FormField label="Zákazník">
<div className="fw-500">{order.customer_name || '—'}</div>
</FormField>
<FormField label="Číslo obj. zákazníka">
<div>{order.customer_order_number || '—'}</div>
</FormField>
<FormField label="Měna">
<div>{order.currency}</div>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3 mb-2">
<FormField label="Datum vytvoření">
<div>{formatDate(order.created_at)}</div>
</FormField>
<FormField label="Příloha">
<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 admin-spinner-sm" />
) : (
<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>
</FormField>
</div>
</div>
</motion.div>
{/* Items (read-only) */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<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 className="fw-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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.2 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
<FormField label="Poznámky">
<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')}
/>
</FormField>
{hasPermission('orders.edit') && (
<div className="mt-2">
<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)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;{order.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
{order.project?.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
)}
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

290
src/admin/pages/Orders.tsx Normal file
View File

@@ -0,0 +1,290 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
const STATUS_CLASSES: Record<string, string> = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
interface Order {
id: number
order_number: string
quotation_id: number
quotation_number: string
customer_name: string
status: string
created_at: string
total: number
currency: string
invoice_id?: number
}
export default function Orders() {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('order_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; order: Order | null }>({ show: false, order: null })
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: orders, loading, initialLoad, pagination, refetch: fetchData } = useListData('orders', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst objednávky'
})
if (!hasPermission('orders.view')) return <Forbidden />
const handleDelete = async () => {
if (!deleteConfirm.order) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/orders/${deleteConfirm.order.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
alert.success(result.message || 'Objednávka byla smazána')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat objednávku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<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' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Objednávky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? orders.length} {czechPlural(pagination?.total ?? orders.length, 'objednávka', 'objednávky', 'objednávek')}
</p>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
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 className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(orders as Order[]).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 text-right fw-500">
{formatCurrency(o.total, o.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/orders/${o.id}`} className="admin-btn-icon" title="Detail" aria-label="Detail">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,318 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Customer {
id: number
name: string
company_id?: string
city?: string
}
interface User {
id: number
name: string
}
interface ProjectForm {
project_number: string
name: string
customer_id: number | null
customer_name: string
start_date: string
responsible_user_id: string
}
export default function ProjectCreate() {
const navigate = useNavigate()
const alert = useAlert()
const { hasPermission } = useAuth()
const [form, setForm] = useState<ProjectForm>({
project_number: '',
name: '',
customer_id: null,
customer_name: '',
start_date: new Date().toISOString().split('T')[0],
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
const [loadingNumber, setLoadingNumber] = useState(true)
// Customer selector state
const [customers, setCustomers] = useState<Customer[]>([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
// Load initial data
useEffect(() => {
const load = async () => {
try {
const [numRes, custRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/projects/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/users`)
])
const numData = await numRes.json()
if (numData.success) {
setForm(prev => ({ ...prev, project_number: numData.data?.next_number || numData.data?.number || '' }))
}
const custData = await custRes.json()
if (custData.success) {
setCustomers(Array.isArray(custData.data) ? custData.data : custData.data?.items || [])
}
const usersData = await usersRes.json()
if (usersData.success) {
const rawUsers = Array.isArray(usersData.data) ? usersData.data : usersData.data?.items || []
setUsers(rawUsers.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
}
} 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: 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: keyof ProjectForm, value: unknown) => {
setForm(prev => ({ ...prev, [field]: value }))
setErrors(prev => ({ ...prev, [field]: undefined }))
}
const handleSave = async () => {
const newErrors: Record<string, string> = {}
if (!form.name.trim()) newErrors.name = 'Název projektu je povinný'
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const body = {
name: form.name.trim(),
customer_id: form.customer_id,
start_date: form.start_date,
project_number: form.project_number.trim(),
responsible_user_id: form.responsible_user_id || null
}
const res = await apiFetch(`${API_BASE}/projects`, {
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
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">
<FormField label="Číslo projektu">
<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é"
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zákazník" error={errors.customer_id} required>
{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>
)}
</FormField>
<FormField label="Datum zahájení">
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
className="admin-form-select"
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</FormField>
</div>
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,645 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
function formatNoteDate(dateStr: string) {
if (!dateStr) return ''
const d = new Date(dateStr)
const day = d.getDate()
const month = d.getMonth() + 1
const year = d.getFullYear()
const hours = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
return `${day}. ${month}. ${year} ${hours}:${mins}`
}
interface Note {
id: number
content: string
user_name: string
created_at: string
}
interface User {
id: number
name: string
}
interface ProjectData {
id: number
project_number: string
name: string
status: string
start_date: string
end_date: string
customer_name: string
responsible_user_id: string
notes?: string
order_id?: number
order_number?: string
order_status?: string
quotation_id?: number
quotation_number?: string
has_nas_folder?: boolean
}
interface ProjectForm {
name: string
status: string
start_date: string
end_date: string
responsible_user_id: string
}
export default function ProjectDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [project, setProject] = useState<ProjectData | null>(null)
const [form, setForm] = useState<ProjectForm>({
name: '',
status: 'aktivni',
start_date: '',
end_date: '',
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
// Dynamic notes
const [notes, setNotes] = useState<Note[]>([])
const [notesLoading, setNotesLoading] = useState(true)
const [newNote, setNewNote] = useState('')
const [addingNote, setAddingNote] = useState(false)
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null)
const createdShown = useRef(false)
useEffect(() => {
if ((location.state as { created?: boolean })?.created && !createdShown.current) {
createdShown.current = true
alert.success('Projekt byl vytvořen')
navigate(location.pathname, { replace: true, state: {} })
}
}, [location.state, location.pathname, alert, navigate])
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setNotes(result.data.project_notes || [])
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false)
}
}
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
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),
responsible_user_id: p.responsible_user_id || ''
})
} else {
alert.error(result.error || 'Nepodařilo se načíst projekt')
navigate('/projects')
}
} catch {
alert.error('Chyba připojení')
navigate('/projects')
} finally {
setLoading(false)
}
}
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/users`)
if (res.status === 401) return
const data = await res.json()
if (data.success) {
const raw = Array.isArray(data.data) ? data.data : data.data?.items || []
setUsers(raw.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
}
} catch {
// silent
}
}
fetchDetail()
fetchNotes()
fetchUsers()
}, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission('projects.view')) return <Forbidden />
const updateForm = (field: keyof ProjectForm, value: string) => setForm(prev => ({ ...prev, [field]: value }))
const handleSave = async () => {
if (!form.name.trim()) {
alert.error('Název projektu je povinný')
return
}
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/projects/${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,
responsible_user_id: form.responsible_user_id || 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/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
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/${id}/notes`, {
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: number) => {
setDeletingNoteId(noteId)
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setNotes(prev => prev.filter(n => n.id !== noteId))
alert.success('Poznámka byla smazána')
} 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 className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<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>
)
}
if (!project) return null
const canEdit = hasPermission('projects.edit')
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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 admin-spinner-sm" />
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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo projektu">
<input
type="text"
value={project.project_number}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/>
</FormField>
<FormField label="Název">
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
disabled={!canEdit}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zákazník">
<input
type="text"
value={project.customer_name || '—'}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/>
</FormField>
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
className="admin-form-select"
disabled={!canEdit}
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Stav">
<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>
</FormField>
<FormField label="Datum zahájení">
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
disabled={!canEdit}
/>
</FormField>
<FormField label="Datum ukončení">
<AdminDatePicker
mode="date"
value={form.end_date}
onChange={(val: string) => updateForm('end_date', val)}
disabled={!canEdit}
/>
</FormField>
</div>
</div>
</div>
</motion.div>
{/* Notes */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
{/* Add note */}
<div className="mb-4">
<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 className="mt-2">
<button
onClick={handleAddNote}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={addingNote || !newNote.trim()}
>
{addingNote ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
'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 className="admin-skeleton" style={{ gap: '0.75rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '8px' }} />
))}
</div>
)}
{!notesLoading && notes.length === 0 && !project.notes && (
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem', textAlign: 'center', padding: '1rem 0' }}>
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 className="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>
{/* Files placeholder - ProjectFileManager not yet migrated */}
<motion.div
className="admin-card"
style={{ marginBottom: '1rem' }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
Správa souborů projektu bude dostupná v příští verzi.
</p>
</div>
</motion.div>
{/* Links */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Propojení</h3>
<div className="admin-form-row">
<FormField label="Objednávka">
<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>
</FormField>
<FormField label="Nabídka">
<div>
{project.quotation_id ? (
<Link to={`/offers/${project.quotation_id}`} className="link-accent">
{project.quotation_number}
</Link>
) : '—'}
</div>
</FormField>
</div>
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
)}
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,287 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import apiFetch from '../utils/api'
import { formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
const STATUS_CLASSES: Record<string, string> = {
aktivni: 'admin-badge-project-aktivni',
dokonceny: 'admin-badge-project-dokonceny',
zruseny: 'admin-badge-project-zruseny'
}
interface Project {
id: number
project_number: string
name: string
customer_name: string
responsible_user_name: string
status: string
start_date: string
end_date: string
order_id?: number
order_number?: string
}
export default function Projects() {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('project_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: projects, setItems: setProjects, loading, initialLoad, pagination } = useListData<Project>('projects', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst projekty'
})
if (!hasPermission('projects.view')) return <Forbidden />
const handleDelete = async () => {
if (!deleteTarget) return
setDeletingId(deleteTarget.id)
try {
const res = await apiFetch(`${API_BASE}/projects/${deleteTarget.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Projekt byl smazán')
setProjects((prev: Project[]) => prev.filter(p => p.id !== deleteTarget.id))
} else {
alert.error(data.error || 'Nepodařilo se smazat projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeletingId(null)
setDeleteTarget(null)
setDeleteFiles(false)
}
}
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<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' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Projekty</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? projects.length} {czechPlural(pagination?.total ?? projects.length, 'projekt', 'projekty', 'projektů')}
</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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
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>Zodpovědná osoba</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 as Project[]).map((p) => (
<tr key={p.id}>
<td className="admin-mono">
<Link to={`/projects/${p.id}`} className="link-accent">
{p.project_number}
</Link>
</td>
<td className="fw-500">{p.name || '—'}</td>
<td>{p.customer_name || '—'}</td>
<td>{p.responsible_user_name || '—'}</td>
<td>
<span className={`admin-badge ${STATUS_CLASSES[p.status] || ''}`}>
{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 admin-spinner-sm" />
) : (
<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>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
</>
}
confirmText="Smazat"
type="danger"
loading={!!deletingId}
/>
</div>
)
}

View File

@@ -0,0 +1,986 @@
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 FormField from '../components/FormField'
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: Record<string, string> = { unpaid: 'Neuhrazena', paid: 'Uhrazena' }
const STATUS_CLASSES: Record<string, string> = { 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'
]
interface CurrencyAmount {
amount: number
currency: string
}
interface ReceivedInvoice {
id: number
supplier_name: string
invoice_number: string
amount: number
currency: string
vat_rate: number
issue_date: string
due_date: string
notes: string
status: string
file_name?: string
created_at: string
}
interface ReceivedStats {
total_month: CurrencyAmount[]
total_month_czk: number | null
vat_month: CurrencyAmount[]
vat_month_czk: number | null
unpaid: CurrencyAmount[]
unpaid_czk: number | null
unpaid_count: number
month_count: number
}
interface UploadMeta {
supplier_name: string
invoice_number: string
amount: string
currency: string
vat_rate: string
issue_date: string
due_date: string
notes: string
}
interface EditInvoice extends Omit<ReceivedInvoice, 'amount' | 'vat_rate'> {
amount: string
vat_rate: string
_originalStatus: string
}
interface UploadErrors {
[idx: number]: {
[field: string]: string
}
}
interface ReceivedInvoicesProps {
statsMonth: number
statsYear: number
uploadOpen: boolean
setUploadOpen: (open: boolean) => void
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) { return '0 Kč' }
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
}
function formatCzkWithDetail(amounts: CurrencyAmount[], totalCzk: number | null | undefined): { value: string; detail: string | null } {
if (!Array.isArray(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(): UploadMeta {
return {
supplier_name: '',
invoice_number: '',
amount: '',
currency: 'CZK',
vat_rate: '21',
issue_date: '',
due_date: '',
notes: '',
}
}
export default function ReceivedInvoices({ statsMonth, statsYear, uploadOpen, setUploadOpen }: ReceivedInvoicesProps) {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('created_at')
const [search, setSearch] = useState('')
// Data
const [invoices, setInvoices] = useState<ReceivedInvoice[]>([])
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<ReceivedStats | null>(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<EditInvoice | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; invoice: ReceivedInvoice | null }>({ show: false, invoice: null })
const [deleting, setDeleting] = useState(false)
const [saving, setSaving] = useState(false)
// Upload state
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([])
const [uploadErrors, setUploadErrors] = useState<UploadErrors>({})
const fileInputRef = useRef<HTMLInputElement>(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?${params}`)
const data = await res.json()
if (data.success) {
setInvoices(Array.isArray(data.data) ? data.data : [])
}
} catch { /* ignore */ } finally {
setLoading(false)
}
}, [statsMonth, statsYear, search, sort, order])
useEffect(() => { fetchList() }, [fetchList])
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices/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 on month change (with slide animation)
useEffect(() => {
setStatsLoading(true)
const load = async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices/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: React.ChangeEvent<HTMLInputElement>) => {
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: number) => {
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: number, field: keyof UploadMeta, value: string) => {
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 = (): boolean => {
const errors: UploadErrors = {}
uploadMeta.forEach((m, i) => {
const e: Record<string, string> = {}
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`, {
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: ReceivedInvoice) => {
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/${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/${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: ReceivedInvoice) => {
const newWindow = window.open('', '_blank')
try {
const response = await apiFetch(`${API_BASE}/received-invoices/${inv.id}/file`)
if (!response.ok) {
newWindow?.close()
alert.error('Nepodařilo se načíst soubor')
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
if (newWindow) { newWindow.location.href = url }
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
newWindow?.close()
alert.error('Chyba připojení')
}
}
const toggleStatus = async (inv: ReceivedInvoice) => {
if (inv.status === 'paid') return
try {
const res = await apiFetch(`${API_BASE}/received-invoices/${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 mb-6">
{[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: number) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (dir: number) => ({ 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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<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, 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/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 text-right fw-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 className="mb-4">
<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">
<FormField label="Dodavatel" error={uploadErrors[idx]?.supplier_name} required>
<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)}
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.invoice_number || ''}
onChange={(e) => updateMeta(idx, 'invoice_number', e.target.value)}
/>
</FormField>
<div className="received-upload-row">
<FormField label="Částka" error={uploadErrors[idx]?.amount} required style={{ flex: 1 }}>
<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)}
/>
</FormField>
<FormField label="Měna" style={{ width: '90px' }}>
<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>
</FormField>
<FormField label="DPH %" style={{ width: '90px' }}>
<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>
</FormField>
</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">
<FormField label="Datum vystavení" style={{ flex: 1 }}>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.issue_date || ''}
onChange={(val: string) => updateMeta(idx, 'issue_date', val)}
/>
</FormField>
<FormField label="Datum splatnosti" style={{ flex: 1 }}>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.due_date || ''}
onChange={(val: string) => updateMeta(idx, 'due_date', val)}
/>
</FormField>
</div>
<FormField label="Poznámka">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.notes || ''}
onChange={(e) => updateMeta(idx, 'notes', e.target.value)}
/>
</FormField>
</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">
<FormField label="Dodavatel" required>
<input
type="text"
className="admin-form-input"
value={editInvoice.supplier_name}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, supplier_name: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={editInvoice.invoice_number || ''}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, invoice_number: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<div className="admin-form-row admin-form-row-3">
<FormField label="Částka" required>
<input
type="number"
step="0.01"
min="0"
className="admin-form-input"
value={editInvoice.amount}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, amount: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<FormField label="Měna">
<select
className="admin-form-select"
value={editInvoice.currency}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, currency: e.target.value } : null)}
disabled={ro}
>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FormField>
<FormField label="DPH %">
<select
className="admin-form-select"
value={editInvoice.vat_rate}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, vat_rate: e.target.value } : null)}
disabled={ro}
>
{VAT_RATE_OPTIONS.map(r => <option key={r} value={String(r)}>{r}%</option>)}
</select>
</FormField>
</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">
<FormField label="Datum vystavení">
<AdminDatePicker
mode="date"
value={editInvoice.issue_date || ''}
onChange={(val: string) => setEditInvoice(prev => prev ? { ...prev, issue_date: val } : null)}
disabled={ro}
/>
</FormField>
<FormField label="Datum splatnosti">
<AdminDatePicker
mode="date"
value={editInvoice.due_date || ''}
onChange={(val: string) => setEditInvoice(prev => prev ? { ...prev, due_date: val } : null)}
disabled={ro}
/>
</FormField>
</div>
<FormField label="Stav">
<select
className="admin-form-select"
value={editInvoice.status}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, status: e.target.value } : null)}
disabled={ro}
>
<option value="unpaid">Neuhrazena</option>
<option value="paid">Uhrazena</option>
</select>
</FormField>
<FormField label="Poznámka">
<textarea
className="admin-form-input"
rows={3}
value={editInvoice.notes || ''}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, notes: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
</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,643 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useNavigate, Navigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const MODULE_LABELS: Record<string, string> = {
attendance: 'Docházka',
trips: 'Kniha jízd',
offers: 'Nabídky',
orders: 'Objednávky',
projects: 'Projekty',
invoices: 'Faktury',
users: 'Uživatelé',
settings: 'Nastavení'
}
interface Permission {
id: number
name: string
display_name: string
description?: string
}
interface Role {
id: number
name: string
display_name: string
description: string | null
permissions: Permission[]
role_permissions?: unknown[]
}
interface RoleForm {
name: string
display_name: string
description: string
permissions: string[]
}
export default function Settings() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [roles, setRoles] = useState<Role[]>([])
const [, setAllPermissions] = useState<Permission[]>([])
const [permissionGroups, setPermissionGroups] = useState<Record<string, Permission[]>>({})
// 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<Role | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<RoleForm>({
name: '',
display_name: '',
description: '',
permissions: []
})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; role: Role | null }>({ show: false, role: null })
const [deleting, setDeleting] = useState(false)
const canRoles = hasPermission('settings.roles')
const canSecurity = hasPermission('settings.security')
if (!canRoles && !canSecurity) {
return <Navigate to="/" replace />
}
useModalLock(showModal)
const fetchData = useCallback(async () => {
if (!canRoles) {
setLoading(false)
return
}
try {
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
])
const rolesResult = await rolesRes.json()
const permsResult = await permsRes.json()
if (rolesResult.success) {
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : [])
} else {
alert.error(rolesResult.error || 'Nepodařilo se načíst role')
}
if (permsResult.success) {
const perms: Permission[] = Array.isArray(permsResult.data) ? permsResult.data : []
setAllPermissions(perms)
// Group by module (part before '.')
const groups: Record<string, Permission[]> = {}
for (const p of perms) {
const mod = p.name.split('.')[0] || 'other'
if (!groups[mod]) groups[mod] = []
groups[mod].push(p)
}
setPermissionGroups(groups)
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert, canRoles])
useEffect(() => {
fetchData()
}, [fetchData])
const fetch2FARequired = useCallback(async () => {
// TODO: Backend endpoint for 2FA requirement settings not yet implemented
setRequire2FALoading(false)
}, [])
useEffect(() => {
fetch2FARequired()
}, [fetch2FARequired])
const handleToggle2FARequired = async () => {
// TODO: Backend endpoint for 2FA requirement settings not yet implemented
alert.error('Tato funkce zatím není k dispozici')
}
const generateSlug = (text: string): string => {
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: Role) => {
setEditingRole(role)
setForm({
name: role.name,
display_name: role.display_name,
description: role.description || '',
permissions: (role.permissions || []).map(p => typeof p === 'string' ? p : p.name)
})
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingRole(null)
}
const handleDisplayNameChange = (value: string) => {
const updates: Partial<RoleForm> = { display_name: value }
if (!editingRole) {
updates.name = generateSlug(value)
}
setForm(prev => ({ ...prev, ...updates }))
}
const togglePermission = (permName: string) => {
setForm(prev => ({
...prev,
permissions: prev.permissions.includes(permName)
? prev.permissions.filter(p => p !== permName)
: [...prev.permissions, permName]
}))
}
const toggleModulePermissions = (moduleName: string) => {
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?: React.FormEvent) => {
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/${editingRole.id}`
: `${API_BASE}/roles`
const response = await apiFetch(url, {
method: editingRole ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...form,
permission_ids: form.permissions.map(name => {
// Find permission ID by name from groups
for (const perms of Object.values(permissionGroups)) {
const found = perms.find(p => p.name === name)
if (found) return found.id
}
return null
}).filter(Boolean),
})
})
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/${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 className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
const isAdminRole = (role: Role) => role.name === 'admin'
const get2FADescription = (): React.ReactNode => {
if (require2FALoading) {
return <div className="admin-skeleton-line" style={{ width: '200px', height: '12px' }} />
}
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'
}
const get2FAButtonLabel = (): string => {
if (require2FASaving) return 'Ukládání...'
return require2FA ? 'Vypnout' : 'Zapnout'
}
const renderRoleButtonContent = (): React.ReactNode => {
if (saving) {
return <><div className="admin-spinner admin-spinner-sm" />Ukládání...</>
}
return editingRole ? 'Uložit změny' : 'Vytvořit roli'
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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 className="flex-row-gap">
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<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 || '\u2014'}
</td>
<td>
<span className="admin-badge admin-badge-info">
{isAdminRole(role) ? 'Vše' : (role.permissions?.length ?? 0)}
</span>
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{0}
</span>
</td>
<td>
{!isAdminRole(role) && (
<div className="flex-row gap-2">
<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={0 > 0 ? 'Nelze smazat roli s přiřazenými uživateli' : 'Smazat'}
aria-label={0 > 0 ? 'Nelze smazat roli s přiřazenými uživateli' : 'Smazat'}
disabled={0 > 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>
</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>
)}
<FormField label="Zobrazovaný název">
<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))}
/>
</FormField>
<FormField label="Systémový název (slug)">
<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>
)}
</FormField>
<FormField label="Popis">
<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))}
/>
</FormField>
<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.tsx 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 FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Vehicle {
id: number | string
spz: string
name: string
}
interface Trip {
id: number
vehicle_id: number | string
trip_date: string
start_km: number
end_km: number
distance?: number | null
route_from: string
route_to: string
is_business: boolean
notes?: string | null
users?: { id: number; first_name: string; last_name: string }
vehicles?: { id: number; name: string; spz: string }
}
interface TripForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
}
export default function Trips() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [showModal, setShowModal] = useState(false)
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; tripId: number | null }>({ show: false, tripId: null })
const [form, setForm] = useState<TripForm>({
vehicle_id: '',
trip_date: new Date().toISOString().split('T')[0],
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [, setLastKm] = useState(0)
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`),
])
const tripsResult = await tripsRes.json()
const vehiclesResult = await vehiclesRes.json()
if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : [])
}
if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showModal)
if (!hasPermission('trips.record')) return <Forbidden />
const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) {
setLastKm(0)
return
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
const result = await response.json()
if (result.success) {
const km = result.data?.last_km || 0
setLastKm(km)
if (!editingTrip) {
setForm(prev => ({ ...prev, start_km: km }))
}
return
}
} catch { /* fallback below */ }
setLastKm(0)
}
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: Trip) => {
setEditingTrip(trip)
setForm({
vehicle_id: String(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: Number(trip.is_business),
notes: trip.notes || ''
})
setLastKm(trip.start_km)
setErrors({})
setShowModal(true)
}
const handleVehicleChange = (vehicleId: string) => {
setForm(prev => ({ ...prev, vehicle_id: vehicleId }))
fetchLastKm(vehicleId)
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!form.vehicle_id) newErrors.vehicle_id = 'Vyberte vozidlo'
if (!form.trip_date) newErrors.trip_date = 'Zadejte datum'
if (!form.start_km) newErrors.start_km = 'Zadejte počáteční km'
if (!form.end_km) newErrors.end_km = 'Zadejte konečný km'
if (form.start_km && form.end_km && parseInt(String(form.end_km)) <= parseInt(String(form.start_km))) {
newErrors.end_km = 'Musí být větší než počáteční'
}
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/${editingTrip.id}`
: `${API_BASE}/trips`
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: number) => {
try {
const response = await apiFetch(`${API_BASE}/trips/${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 = (): number => {
const start = parseInt(String(form.start_km)) || 0
const end = parseInt(String(form.end_km)) || 0
return end > start ? end - start : 0
}
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div>
<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-grid admin-grid-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: '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>
</div>
</div>
</div>
)
}
const totals = trips.reduce(
(acc, t) => {
const dist = t.distance ?? (t.end_km - t.start_km)
acc.count++
acc.total += dist
if (t.is_business) acc.business += dist
else acc.private += dist
return acc
},
{ total: 0, business: 0, private: 0, count: 0 }
)
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední jízdy</h2>
<Link to="/trips/history" className="admin-btn admin-btn-secondary admin-btn-sm">
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.vehicles?.spz ?? ''}</span>
</td>
<td>{trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : ''}</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance ?? (trip.end_km - trip.start_km))} km</strong></td>
<td>
<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">
<FormField label="Vozidlo" error={errors.vehicle_id} required>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value)
setErrors(prev => ({ ...prev, vehicle_id: '' }))
}}
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>
</FormField>
<FormField label="Datum jízdy" error={errors.trip_date} required>
<AdminDatePicker
mode="date"
value={form.trip_date}
onChange={(val: string) => {
setForm({ ...form, trip_date: val })
setErrors(prev => ({ ...prev, trip_date: '' }))
}}
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Počáteční stav km" error={errors.start_km} required>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value })
setErrors(prev => ({ ...prev, start_km: '' }))
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km" error={errors.end_km} required>
<input
type="number"
inputMode="numeric"
value={form.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value })
setErrors(prev => ({ ...prev, end_km: '' }))
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu" error={errors.route_from} required>
<input
type="text"
value={form.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value })
setErrors(prev => ({ ...prev, route_from: '' }))
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<FormField label="Místo příjezdu" error={errors.route_to} required>
<input
type="text"
value={form.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value })
setErrors(prev => ({ ...prev, route_to: '' }))
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<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>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</FormField>
</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,831 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Vehicle {
id: number | string
spz: string
name: string
}
interface UserShort {
id: number | string
name: string
}
interface Trip {
id: number
vehicle_id: number | string
trip_date: string
start_km: number
end_km: number
distance: number
route_from: string
route_to: string
is_business: number | boolean
notes?: string
spz: string
driver_name: string
}
interface BackendTrip {
id: number
vehicle_id: number
user_id: number
trip_date: string
start_km: number
end_km: number
distance: number | null
route_from: string
route_to: string
is_business: boolean
notes: string | null
users: { id: number; first_name: string; last_name: string }
vehicles: { id: number; name: string; spz: string }
}
interface EditForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
}
function mapTrip(bt: BackendTrip): Trip {
const distance = bt.distance ?? (bt.end_km - bt.start_km)
return {
id: bt.id,
vehicle_id: bt.vehicle_id,
trip_date: bt.trip_date,
start_km: bt.start_km,
end_km: bt.end_km,
distance,
route_from: bt.route_from,
route_to: bt.route_to,
is_business: bt.is_business ? 1 : 0,
notes: bt.notes || undefined,
spz: bt.vehicles?.spz ?? '',
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : '',
}
}
export default function TripsAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [filterMonth, setFilterMonth] = useState(() => String(new Date().getMonth() + 1))
const [filterYear, setFilterYear] = useState(() => String(new Date().getFullYear()))
const [filterVehicleId, setFilterVehicleId] = useState('')
const [filterUserId, setFilterUserId] = useState('')
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [users, setUsers] = useState<UserShort[]>([])
const printRef = useRef<HTMLDivElement>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
const [editForm, setEditForm] = useState<EditForm>({
vehicle_id: '',
trip_date: '',
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; trip: Trip | null }>({ show: false, trip: null })
// Fetch vehicles and users once on mount
useEffect(() => {
const fetchLookups = async () => {
try {
const [vRes, uRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/users?limit=1000`),
])
const vJson = await vRes.json()
const uJson = await uRes.json()
if (vJson.success) setVehicles(vJson.data)
if (uJson.success) {
setUsers(uJson.data.map((u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
})))
}
} catch {
// silently fail, filters will just be empty
}
}
fetchLookups()
}, [])
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`
if (filterUserId) url += `&user_id=${filterUserId}`
const response = await apiFetch(url)
const result = await response.json()
if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip)
setTrips(mapped)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [filterMonth, filterYear, filterVehicleId, filterUserId, alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showEditModal)
if (!hasPermission('trips.admin')) return <Forbidden />
const openEditModal = (trip: Trip) => {
setEditingTrip(trip)
setEditForm({
vehicle_id: String(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: Number(trip.is_business),
notes: trip.notes || ''
})
setShowEditModal(true)
}
const handleEditSubmit = async () => {
if (!editingTrip) return
if (parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))) {
alert.error('Konečný stav km musí být větší než počáteční')
return
}
try {
const response = await apiFetch(`${API_BASE}/trips/${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/${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 getPeriodName = () => new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString('cs-CZ', { month: 'long', year: 'numeric' })
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null
const v = vehicles.find(v => String(v.id) === filterVehicleId)
return v ? `${v.spz} - ${v.name}` : null
}
const getSelectedUserName = () => {
if (!filterUserId) return null
const u = users.find(u => String(u.id) === filterUserId)
return u?.name || null
}
const handlePrint = () => {
const periodName = getPeriodName()
setTimeout(() => {
if (printRef.current) {
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
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 - ${periodName}</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>
${content}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
}, 100)
}
const calculateDistance = (): number => {
const start = parseInt(String(editForm.start_km)) || 0
const end = parseInt(String(editForm.end_km)) || 0
return end > start ? end - start : 0
}
const totals = {
count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips.filter(t => Number(t.is_business)).reduce((sum, t) => sum + t.distance, 0),
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row admin-form-row-4">
<FormField label="Měsíc" style={{ marginBottom: 0 }}>
<select
value={filterMonth}
onChange={(e) => setFilterMonth(e.target.value)}
className="admin-form-select"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(2000, i).toLocaleString('cs-CZ', { month: 'long' })}
</option>
))}
</select>
</FormField>
<FormField label="Rok" style={{ marginBottom: 0 }}>
<select
value={filterYear}
onChange={(e) => setFilterYear(e.target.value)}
className="admin-form-select"
>
{Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i
return <option key={y} value={y}>{y}</option>
})}
</select>
</FormField>
<FormField label="Vozidlo" style={{ marginBottom: 0 }}>
<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>
</FormField>
<FormField label="Řidič" style={{ marginBottom: 0 }}>
<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>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-grid admin-grid-3 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<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 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<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} &rarr; {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">
<FormField label="Vozidlo">
<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>
</FormField>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) => setEditForm({ ...editForm, trip_date: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) => setEditForm({ ...editForm, start_km: e.target.value })}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) => setEditForm({ ...editForm, end_km: e.target.value })}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) => setEditForm({ ...editForm, route_from: e.target.value })}
className="admin-form-input"
/>
</FormField>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) => setEditForm({ ...editForm, route_to: e.target.value })}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<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>
</FormField>
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
/>
</FormField>
</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 */}
{trips.length > 0 && (
<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">{getPeriodName()}</div>
{getSelectedVehicleName() && <div className="filters">Vozidlo: {getSelectedVehicleName()}</div>}
{getSelectedUserName() && <div className="filters">Řidič: {getSelectedUserName()}</div>}
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
<div className="summary">
<div className="summary-item">
<div className="summary-value">{totals.count}</div>
<div className="summary-label">Počet jízd</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total)} km</div>
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.business)} km</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total - totals.business)} km</div>
<div className="summary-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>
{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} &rarr; {trip.route_to}</td>
<td className="text-right">{formatKm(trip.start_km)} - {formatKm(trip.end_km)}</td>
<td className="text-right"><strong>{formatKm(trip.distance)} km</strong></td>
<td className="text-center">
<span className={`badge ${trip.is_business ? 'badge-success' : 'badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td>{trip.notes || ''}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">Celkem:</td>
<td className="text-right"><strong>{formatKm(totals.total)} km</strong></td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,273 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Vehicle {
id: number | string
spz: string
name: string
}
interface Trip {
id: number
trip_date: string
spz: string
driver_name: string
route_from: string
route_to: string
start_km: number
end_km: number
distance: number
is_business: number | boolean
notes?: string
}
export default function TripsHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [vehicleId, setVehicleId] = useState('')
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const totals = trips.reduce(
(acc, t) => ({
total: acc.total + (t.distance || 0),
business: acc.business + (t.is_business ? (t.distance || 0) : 0),
count: acc.count + 1,
}),
{ total: 0, business: 0, count: 0 }
)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ month })
if (user?.id) params.set('user_id', String(user.id))
if (vehicleId) params.set('vehicle_id', vehicleId)
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips?${params}`),
apiFetch(`${API_BASE}/vehicles`),
])
if (tripsRes.status === 401) return
const tripsResult = await tripsRes.json()
const vehiclesResult = await vehiclesRes.json()
if (tripsResult.success) {
const raw = Array.isArray(tripsResult.data) ? tripsResult.data : tripsResult.data?.items || []
setTrips(raw.map((t: Record<string, unknown>) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || '',
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ''} ${(t.users as Record<string, string>).last_name || ''}`.trim()
: '',
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})))
}
if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
setLoading(false)
}
}, [month, vehicleId, alert, user?.id])
useEffect(() => {
fetchData()
}, [fetchData])
if (!hasPermission('trips.history')) return <Forbidden />
const getMonthName = (monthStr: string): string => {
const [yearStr, monthNum] = monthStr.split('-')
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1)
return date.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
<FormField label="Vozidlo">
<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>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-grid admin-grid-3 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<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 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton gap-5">
{[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} &rarr; {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>
)
}

495
src/admin/pages/Users.tsx Normal file
View File

@@ -0,0 +1,495 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface User {
id: number
username: string
email: string
first_name: string
last_name: string
role_id: number
roles?: { id: number; name: string; display_name: string } | null
is_active: boolean
}
interface Role {
id: number
name: string
display_name: string
}
interface FormData {
username: string
email: string
password: string
first_name: string
last_name: string
role_id: number | string
is_active: boolean
}
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth()
const alert = useAlert()
const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user: User | null }>({ isOpen: false, user: null })
const [deleting, setDeleting] = useState(false)
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: '',
is_active: true
})
useModalLock(showModal)
const fetchUsers = useCallback(async () => {
try {
const usersRes = await apiFetch(`${API_BASE}/users`)
const usersData = await usersRes.json()
if (usersData.success) {
setUsers(Array.isArray(usersData.data) ? usersData.data : [])
} else {
alert.error(usersData.error || 'Nepodařilo se načíst uživatele')
}
// Roles fetch — gracefully handle 403 if user lacks settings.roles permission
try {
const rolesRes = await apiFetch(`${API_BASE}/roles`)
const rolesData = await rolesRes.json()
if (rolesData.success) {
setRoles(Array.isArray(rolesData.data) ? rolesData.data : [])
}
} catch { /* roles not accessible */ }
} 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: 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?: React.FormEvent) => {
e?.preventDefault()
const dataToSave = { ...formData }
const wasEditing = editingUser
const editingId = editingUser?.id
try {
const url = wasEditing
? `${API_BASE}/users/${editingId}`
: `${API_BASE}/users`
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: 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/${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: User) => {
try {
const response = await apiFetch(`${API_BASE}/users/${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: string): string => {
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 className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">Správa uživatelských úč a oprávnění</p>
</div>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<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.roles?.name ?? '')}>
{user.roles?.display_name || user.roles?.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>
</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">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
<FormField label={`Heslo ${editingUser ? '(ponechte prázdné pro zachování stávajícího)' : ''}`}>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<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>
</FormField>
<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,470 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
interface Vehicle {
id: number
spz: string
name: string
brand?: string
model?: string
initial_km: number
current_km: number
trip_count: number
is_active: boolean | number
}
interface VehicleForm {
spz: string
name: string
brand: string
model: string
initial_km: number
is_active: boolean
}
export default function Vehicles() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [showModal, setShowModal] = useState(false)
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
const [form, setForm] = useState<VehicleForm>({
spz: '',
name: '',
brand: '',
model: '',
initial_km: 0,
is_active: true
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; vehicle: Vehicle | null }>({ show: false, vehicle: null })
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/vehicles`)
const result = await response.json()
if (result.success) {
setVehicles(Array.isArray(result.data) ? result.data : [])
}
} 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: 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: Record<string, string> = {}
if (!form.spz) newErrors.spz = 'Zadejte SPZ'
if (!form.name) newErrors.name = 'Zadejte název'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
try {
const url = editingVehicle
? `${API_BASE}/vehicles/${editingVehicle.id}`
: `${API_BASE}/vehicles`
const method = editingVehicle ? 'PUT' : 'POST'
const response = await apiFetch(url, {
method,
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í')
}
}
const handleDelete = async () => {
if (!deleteConfirm.vehicle) return
try {
const response = await apiFetch(`${API_BASE}/vehicles/${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: Vehicle) => {
try {
const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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 className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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 fw-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 fw-500">{formatKm(vehicle.current_km)} km</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
>
{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">
<FormField label="SPZ" error={errors.spz} required>
<input
type="text"
value={form.spz}
onChange={(e) => {
setForm({ ...form, spz: e.target.value.toUpperCase() })
setErrors(prev => ({ ...prev, spz: '' }))
}}
className="admin-form-input"
placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value })
setErrors(prev => ({ ...prev, name: '' }))
}}
className="admin-form-input"
placeholder="Služební #1"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Značka">
<input
type="text"
value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })}
className="admin-form-input"
placeholder="Škoda"
/>
</FormField>
<FormField label="Model">
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
className="admin-form-input"
placeholder="Octavia Combi"
/>
</FormField>
</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>
)
}

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

@@ -0,0 +1,64 @@
/* ============================================================================
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;
}
@media (max-width: 768px) {
.admin-permission-item {
padding: 0.75rem;
}
.admin-permission-item .admin-form-checkbox {
min-height: 44px;
}
}

102
src/admin/utils/api.ts Normal file
View File

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

View File

@@ -0,0 +1,151 @@
interface AttendanceRecord {
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
leave_type?: string
leave_hours?: number
shift_date?: string
notes?: string
project_logs?: Array<{
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
}>
}
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('cs-CZ')
}
export const formatDatetime = (datetime: string | null | undefined): string => {
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: string | null | undefined): string => {
if (!datetime) return '—'
return new Date(datetime).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
export const calculateWorkMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (record.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: number, withUnit = false): string => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${h}:${String(m).padStart(2, '0')}${withUnit ? ' h' : ''}`
}
export const getLeaveTypeName = (type: string): string => {
const types: Record<string, string> = {
work: 'Práce',
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno',
}
return types[type] || 'Práce'
}
export const getLeaveTypeBadgeClass = (type: string): string => {
const classes: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
holiday: 'badge-holiday',
unpaid: 'badge-unpaid',
}
return classes[type] || ''
}
export const getDatePart = (datetime: string | null | undefined): string => {
if (!datetime) return ''
if (datetime.includes('T')) {
return datetime.split('T')[0]
}
return datetime.split(' ')[0]
}
export const getTimePart = (datetime: string | null | undefined): string => {
if (!datetime) return ''
const d = new Date(datetime)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export const calcProjectMinutesTotal = (logs: Array<{ project_id?: number; hours?: string | number; minutes?: string | number }>): number => {
return logs.filter(l => l.project_id).reduce((sum, l) => {
return sum + (parseInt(String(l.hours)) || 0) * 60 + (parseInt(String(l.minutes)) || 0)
}, 0)
}
interface ShiftForm {
arrival_time?: string
departure_time?: string
arrival_date?: string
departure_date?: string
break_start_time?: string
break_end_time?: string
break_start_date?: string
break_end_date?: string
}
export const calcFormWorkMinutes = (form: ShiftForm): number => {
if (!form.arrival_time || !form.departure_time) return 0
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`
const departureStr = `${form.departure_date}T${form.departure_time}`
let mins = (new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000
if (form.break_start_time && form.break_end_time) {
const bsStr = `${form.break_start_date}T${form.break_start_time}`
const beStr = `${form.break_end_date}T${form.break_end_time}`
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000
}
return Math.max(0, Math.floor(mins))
}
export const formatTimeOrDatetimePrint = (datetime: string | null | undefined, shiftDate: string): string => {
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: AttendanceRecord): number => {
const leaveType = record.leave_type || 'work'
if (leaveType !== 'work') {
return (Number(record.leave_hours) || 8) * 60
}
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (record.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,79 @@
export const LEAVE_TYPE_LABELS: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno',
}
export const STATUS_DOT_CLASS: Record<string, string> = {
in: 'dash-status-in',
away: 'dash-status-away',
out: 'dash-status-out',
leave: 'dash-status-leave',
}
export const STATUS_LABELS: Record<string, string> = {
in: 'Přítomen',
away: 'Přestávka',
out: 'Nepřihlášen',
leave: 'Nepřítomen',
}
export const ENTITY_TYPE_LABELS: Record<string, string> = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
export const ACTION_LABELS: Record<string, string> = {
create: 'Vytvořil',
update: 'Upravil',
delete: 'Smazal',
login: 'Přihlášení',
}
export function getCzechDate(): string {
const now = new Date()
const days = ['Neděle', 'Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota']
const months = ['ledna', 'února', 'března', 'dubna', 'května', 'června', 'července', 'srpna', 'září', 'října', 'listopadu', 'prosince']
const day = days[now.getDay()]
const oneJan = new Date(now.getFullYear(), 0, 1)
const week = Math.ceil(((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7)
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`
}
export function getActivityIconClass(action: string): string {
const map: Record<string, string> = { create: 'success', update: 'info', delete: 'danger', login: 'accent' }
return map[action] || 'muted'
}
export function formatActivityTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return 'Právě teď'
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
return date.toLocaleDateString('cs-CZ', { day: '2-digit', month: '2-digit' })
}
export function formatSessionDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('cs-CZ', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}

View File

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

7
src/config/database.ts Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
log: process.env.APP_ENV === 'local' ? ['warn', 'error'] : ['error'],
});
export default prisma;

51
src/config/env.ts Normal file
View File

@@ -0,0 +1,51 @@
import dotenv from 'dotenv';
dotenv.config();
function required(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing required env variable: ${key}`);
return val;
}
export const config = {
port: parseInt(process.env.PORT || '3001', 10),
host: process.env.HOST || '127.0.0.1',
appEnv: process.env.APP_ENV || 'local',
isProduction: process.env.APP_ENV === 'production',
db: {
url: required('DATABASE_URL'),
},
jwt: {
secret: required('JWT_SECRET'),
accessTokenExpiry: parseInt(process.env.ACCESS_TOKEN_EXPIRY || '900', 10),
refreshTokenSessionExpiry: parseInt(process.env.REFRESH_TOKEN_SESSION_EXPIRY || '3600', 10),
refreshTokenRememberExpiry: parseInt(process.env.REFRESH_TOKEN_REMEMBER_EXPIRY || '2592000', 10),
},
totp: {
encryptionKey: required('TOTP_ENCRYPTION_KEY'),
},
nas: {
path: process.env.NAS_PATH || 'Z:/02_PROJEKTY',
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || '52428800', 10),
},
email: {
contactTo: process.env.CONTACT_EMAIL_TO || '',
contactFrom: process.env.CONTACT_EMAIL_FROM || '',
smtpFrom: process.env.SMTP_FROM || '',
},
cors: {
origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean),
},
security: {
maxLoginAttempts: 5,
lockoutMinutes: 15,
bcryptCost: 12,
},
} as const;

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
interface ThemeContextValue {
theme: string
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('boha-theme') || 'dark'
}
return 'dark'
})
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
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(): ThemeContextValue {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within a ThemeProvider')
return context
}

15
src/main.tsx 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>
)

51
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,51 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { verifyAccessToken } from '../services/auth';
import { error } from '../utils/response';
import { AuthData } from '../types';
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return error(reply, 'Vyžadována autentizace', 401);
}
const token = authHeader.slice(7);
const authData = await verifyAccessToken(token);
if (!authData) {
return error(reply, 'Neplatný nebo expirovaný token', 401);
}
request.authData = authData;
}
export async function optionalAuth(
request: FastifyRequest,
_reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) return;
const token = authHeader.slice(7);
request.authData = (await verifyAccessToken(token)) ?? undefined;
}
export function requirePermission(...permissionNames: string[]) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
await requireAuth(request, reply);
if (reply.sent) return;
const authData = request.authData!;
// Admin has all permissions
if (authData.roleName === 'admin') return;
const hasAll = permissionNames.every((p) => authData.permissions.includes(p));
if (!hasAll) {
return error(reply, 'Nedostatečná oprávnění', 403);
}
};
}

View File

@@ -0,0 +1,15 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../config/env';
export async function securityHeaders(
_request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('X-Frame-Options', 'DENY');
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
if (config.isProduction) {
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { success, paginated, error } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
export default async function auditLogRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.action) where.action = String(query.action);
if (query.entity_type) where.entity_type = String(query.entity_type);
if (query.user_id) where.user_id = Number(query.user_id);
if (search) where.description = { contains: search };
if (query.date_from || query.date_to) {
const dateFilter: Record<string, Date> = {};
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
if (query.date_to) dateFilter.lte = new Date(String(query.date_to) + 'T23:59:59');
where.created_at = dateFilter;
}
const [logs, total] = await Promise.all([
prisma.audit_logs.findMany({ where, skip, take: limit, orderBy: { created_at: order } }),
prisma.audit_logs.count({ where }),
]);
return paginated(reply, logs, buildPaginationMeta(total, page, limit));
});
// POST /api/admin/audit-log/cleanup — delete old audit logs
fastify.post('/cleanup', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const days = body.days !== undefined ? Number(body.days) : null;
// days === 0 means "delete all" (from frontend "Vše" option)
if (days === 0 || body.action === 'all') {
const result = await prisma.audit_logs.deleteMany({});
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
}
if (days && days > 0) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const result = await prisma.audit_logs.deleteMany({ where: { created_at: { lt: cutoff } } });
return success(reply, null, 200, `Smazáno ${result.count} záznamů starších než ${days} dní`);
}
return error(reply, 'Zadejte počet dní', 400);
});
}

188
src/routes/admin/auth.ts Normal file
View File

@@ -0,0 +1,188 @@
import { FastifyInstance } from 'fastify';
import { login, refreshAccessToken, logout, verifyAccessToken } from '../../services/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import { config } from '../../config/env';
import { LoginRequest, TotpVerifyRequest } from '../../types';
import prisma from '../../config/database';
import crypto from 'crypto';
import { OTPAuth } from '../../utils/totp';
function setRefreshCookie(reply: import('fastify').FastifyReply, token: string, rememberMe: boolean) {
const maxAge = rememberMe
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
reply.setCookie('refresh_token', token, {
httpOnly: true,
secure: config.isProduction,
sameSite: 'strict',
path: '/api/admin',
maxAge,
});
}
export default async function authRoutes(fastify: FastifyInstance): Promise<void> {
// POST /api/admin/login
fastify.post<{ Body: LoginRequest }>('/login', async (request, reply) => {
const { username, password, remember_me } = request.body;
if (!username || !password) {
return error(reply, 'Uživatelské jméno a heslo jsou povinné', 400);
}
const result = await login(username, password, !!remember_me, request);
if (result.type === 'error') {
await logAudit({
request,
action: 'login_failed',
entityType: 'user',
description: `Neúspěšný pokus o přihlášení: ${username}`,
});
return error(reply, result.message, result.status);
}
if (result.type === 'totp_required') {
return success(reply, { totp_required: true, login_token: result.loginToken });
}
await logAudit({
request,
authData: result.user,
action: 'login',
entityType: 'user',
entityId: result.user.userId,
description: `Přihlášení uživatele ${result.user.username}`,
});
setRefreshCookie(reply, result.refreshToken, !!remember_me);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
// POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>('/login/totp', async (request, reply) => {
const { login_token, totp_code } = request.body;
if (!login_token || !totp_code) {
return error(reply, 'Login token a TOTP kód jsou povinné', 400);
}
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex');
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, 'Neplatný nebo expirovaný login token', 401);
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_secret) {
return error(reply, 'Uživatel nenalezen', 401);
}
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
if (!isValid) {
return error(reply, 'Neplatný TOTP kód', 401);
}
// Delete used login token
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
// Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({
where: { id: user.id },
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() },
});
// Create tokens directly — password was already verified before TOTP was requested
const authData = await (await import('../../services/auth')).loadAuthData(user.id);
if (!authData) {
return error(reply, 'Chyba načítání uživatele', 500);
}
// Create tokens manually since password was already verified
const jwt = await import('jsonwebtoken');
const accessToken = jwt.default.sign(
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
await prisma.refresh_tokens.create({
data: {
user_id: user.id,
token_hash: refreshTokenHash,
expires_at: new Date(Date.now() + config.jwt.refreshTokenSessionExpiry * 1000),
remember_me: false,
ip_address: request.ip,
user_agent: request.headers['user-agent'] ?? null,
},
});
setRefreshCookie(reply, refreshTokenRaw, false);
return success(reply, { access_token: accessToken, user: authData });
});
// POST /api/admin/refresh
fastify.post('/refresh', async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (!refreshTokenRaw) {
return error(reply, 'Refresh token chybí', 401);
}
const result = await refreshAccessToken(refreshTokenRaw, request);
if (result.type === 'error') {
reply.clearCookie('refresh_token', { path: '/api/admin' });
return error(reply, result.message, result.status);
}
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
// POST /api/admin/logout
fastify.post('/logout', async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) {
await logout(refreshTokenRaw);
}
reply.clearCookie('refresh_token', { path: '/api/admin' });
return success(reply, null, 200, 'Odhlášení úspěšné');
});
// GET /api/admin/session
fastify.get('/session', async (request, reply) => {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return error(reply, 'Vyžadována autentizace', 401);
}
const token = authHeader.slice(7);
const authData = await verifyAccessToken(token);
if (!authData) {
return error(reply, 'Neplatný token', 401);
}
return success(reply, { user: authData });
});
}

View File

@@ -0,0 +1,68 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
export default async function bankAccountsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (_request, reply) => {
const accounts = await prisma.bank_accounts.findMany({ orderBy: { position: 'asc' } });
return success(reply, accounts);
});
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const account = await prisma.bank_accounts.create({
data: {
account_name: body.account_name ? String(body.account_name) : null,
bank_name: body.bank_name ? String(body.bank_name) : null,
account_number: body.account_number ? String(body.account_number) : null,
iban: body.iban ? String(body.iban) : null,
bic: body.bic ? String(body.bic) : null,
currency: body.currency ? String(body.currency) : 'CZK',
is_default: body.is_default === true || body.is_default === 1 || body.is_default === '1',
position: body.position ? Number(body.position) : 0,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'bank_account', entityId: account.id, description: `Vytvořen bankovní účet ${account.account_name}` });
return success(reply, { id: account.id }, 201, 'Bankovní účet vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
if (!existing) return error(reply, 'Účet nenalezen', 404);
await prisma.bank_accounts.update({
where: { id },
data: {
account_name: body.account_name !== undefined ? (body.account_name ? String(body.account_name) : null) : undefined,
bank_name: body.bank_name !== undefined ? (body.bank_name ? String(body.bank_name) : null) : undefined,
account_number: body.account_number !== undefined ? (body.account_number ? String(body.account_number) : null) : undefined,
iban: body.iban !== undefined ? (body.iban ? String(body.iban) : null) : undefined,
bic: body.bic !== undefined ? (body.bic ? String(body.bic) : null) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
is_default: body.is_default !== undefined ? (body.is_default === true || body.is_default === 1 || body.is_default === '1') : undefined,
position: body.position !== undefined ? Number(body.position) : undefined,
modified_at: new Date(),
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'bank_account', entityId: id, description: `Upraven bankovní účet` });
return success(reply, { id }, 200, 'Bankovní účet uložen');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
if (!existing) return error(reply, 'Účet nenalezen', 404);
await prisma.bank_accounts.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'bank_account', entityId: id, description: `Smazán bankovní účet` });
return success(reply, null, 200, 'Účet smazán');
});
}

View File

@@ -0,0 +1,179 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import multipart from '@fastify/multipart';
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; supplier_field_order: string[] } {
if (!raw) return { custom_fields: [], supplier_field_order: [] };
try {
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
return { custom_fields: parsed.fields || [], supplier_field_order: parsed.field_order || [] };
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, supplier_field_order: [] };
}
return { custom_fields: [], supplier_field_order: [] };
} catch {
return { custom_fields: [], supplier_field_order: [] };
}
}
export default async function companySettingsRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
// GET /api/admin/company-settings/logo
fastify.get('/logo', { preHandler: requireAuth }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({ select: { logo_data: true } });
if (!settings?.logo_data) return error(reply, 'Logo nenalezeno', 404);
// Detect image type from magic bytes
const buf = settings.logo_data;
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
return reply.type(mime).send(buf);
});
// POST /api/admin/company-settings/logo
fastify.post('/logo', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const file = await request.file();
if (!file) return error(reply, 'Nebyl nahrán žádný soubor', 400);
const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return error(reply, 'Nepodporovaný formát. Povoleno: PNG, JPG, GIF, WebP', 400);
}
const buffer = await file.toBuffer();
const existing = await prisma.company_settings.findFirst();
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
await prisma.company_settings.update({
where: { id: existing.id },
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Nahráno logo' });
return success(reply, null, 200, 'Logo nahráno');
});
fastify.get('/', { preHandler: requireAuth }, async (_request, reply) => {
let settings = await prisma.company_settings.findFirst({
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
},
});
if (!settings) {
settings = await prisma.company_settings.create({
data: {
company_name: '',
quotation_prefix: 'N',
default_currency: 'EUR',
default_vat_rate: 21.0,
},
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
},
});
}
// Check if logo exists
const logoCheck = await prisma.company_settings.findFirst({
where: { id: settings.id },
select: { logo_data: true },
});
const has_logo = !!(logoCheck?.logo_data);
const { custom_fields, supplier_field_order } = decodeCustomFields(settings.custom_fields as string | null);
return success(reply, { ...settings, custom_fields, supplier_field_order, has_logo });
});
fastify.put('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const existing = await prisma.company_settings.findFirst();
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = ['company_name', 'street', 'city', 'postal_code', 'country', 'company_id', 'vat_id', 'quotation_prefix', 'default_currency', 'order_type_code', 'invoice_type_code'];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.default_vat_rate !== undefined) data.default_vat_rate = Number(body.default_vat_rate);
if (body.require_2fa !== undefined) data.require_2fa = body.require_2fa === true || body.require_2fa === 1 || body.require_2fa === '1';
if (body.custom_fields !== undefined || body.supplier_field_order !== undefined) {
let existingFields: unknown[] = [];
let existingOrder: unknown[] = [];
if (existing.custom_fields) {
try {
const parsed = JSON.parse(existing.custom_fields);
existingFields = parsed?.fields || [];
existingOrder = parsed?.field_order || [];
} catch { /* invalid JSON, use defaults */ }
}
data.custom_fields = encodeCustomFields(
body.custom_fields !== undefined ? body.custom_fields : existingFields,
body.supplier_field_order !== undefined ? body.supplier_field_order : existingOrder,
);
}
data.sync_version = (existing.sync_version ?? 0) + 1;
await prisma.company_settings.update({ where: { id: existing.id }, data });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Upraveno firemní nastavení' });
return success(reply, { id: existing.id }, 200, 'Nastavení bylo uloženo');
});
}

View File

@@ -0,0 +1,141 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const ALLOWED_SORT_FIELDS = ['id', 'name', 'company_id', 'city', 'country'];
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; customer_field_order: string[] } {
if (!raw) return { custom_fields: [], customer_field_order: [] };
try {
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
return { custom_fields: parsed.fields || [], customer_field_order: parsed.field_order || [] };
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, customer_field_order: [] };
}
return { custom_fields: [], customer_field_order: [] };
} catch {
return { custom_fields: [], customer_field_order: [] };
}
}
export default async function customersRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(request.query as Record<string, unknown>);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'name';
const where = search
? { OR: [{ name: { contains: search } }, { company_id: { contains: search } }] }
: {};
const [customers, total] = await Promise.all([
prisma.customers.findMany({
where, skip, take: limit, orderBy: { [sortField]: order },
include: { _count: { select: { quotations: true } } },
}),
prisma.customers.count({ where }),
]);
const enriched = customers.map(c => {
const { custom_fields, customer_field_order } = decodeCustomFields(c.custom_fields);
return { ...c, custom_fields, customer_field_order, quotation_count: c._count?.quotations ?? 0 };
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const customer = await prisma.customers.findUnique({ where: { id } });
if (!customer) return error(reply, 'Zákazník nenalezen', 404);
const { custom_fields, customer_field_order } = decodeCustomFields(customer.custom_fields);
return success(reply, { ...customer, custom_fields, customer_field_order });
});
fastify.post('/', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const name = body.name ? String(body.name).trim() : '';
if (!name) return error(reply, 'Název zákazníka je povinný', 400);
const customer = await prisma.customers.create({
data: {
name,
street: body.street ? String(body.street) : null,
city: body.city ? String(body.city) : null,
postal_code: body.postal_code ? String(body.postal_code) : null,
country: body.country ? String(body.country) : null,
company_id: body.company_id ? String(body.company_id) : null,
vat_id: body.vat_id ? String(body.vat_id) : null,
custom_fields: encodeCustomFields(body.custom_fields, body.customer_field_order),
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'customer', entityId: customer.id, description: `Vytvořen zákazník ${customer.name}` });
return success(reply, { id: customer.id }, 201, 'Zákazník byl vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.customers.findUnique({ where: { id } });
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
await prisma.customers.update({
where: { id },
data: {
name: body.name !== undefined ? String(body.name) : undefined,
street: body.street !== undefined ? (body.street ? String(body.street) : null) : undefined,
city: body.city !== undefined ? (body.city ? String(body.city) : null) : undefined,
postal_code: body.postal_code !== undefined ? (body.postal_code ? String(body.postal_code) : null) : undefined,
country: body.country !== undefined ? (body.country ? String(body.country) : null) : undefined,
company_id: body.company_id !== undefined ? (body.company_id ? String(body.company_id) : null) : undefined,
vat_id: body.vat_id !== undefined ? (body.vat_id ? String(body.vat_id) : null) : undefined,
custom_fields: body.custom_fields !== undefined ? encodeCustomFields(body.custom_fields, body.customer_field_order) : undefined,
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'customer', entityId: id, description: `Upraven zákazník ${existing.name}` });
return success(reply, { id }, 200, 'Zákazník byl uložen');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.customers.findUnique({ where: { id } });
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
// Check for FK references before deleting
const [quotCount, orderCount, invoiceCount, projectCount] = await Promise.all([
prisma.quotations.count({ where: { customer_id: id } }),
prisma.orders.count({ where: { customer_id: id } }),
prisma.invoices.count({ where: { customer_id: id } }),
prisma.projects.count({ where: { customer_id: id } }),
]);
if (quotCount + orderCount + invoiceCount + projectCount > 0) {
return error(reply, 'Zákazníka nelze smazat — existují propojené nabídky, objednávky, faktury nebo projekty', 400);
}
await prisma.customers.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'customer', entityId: id, description: `Smazán zákazník ${existing.name}` });
return success(reply, null, 200, 'Zákazník smazán');
});
}

View File

@@ -0,0 +1,252 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth } from '../../middleware/auth';
import { success } from '../../utils/response';
export default async function dashboardRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const userId = request.authData!.userId;
const [
usersCount,
activeProjectsCount,
pendingOrdersCount,
unpaidInvoicesCount,
pendingLeaveRequests,
// Attendance
todayAttendance,
onLeaveToday,
// Offers / quotations
openQuotations,
convertedQuotations,
expiredQuotations,
quotationsThisMonth,
// Invoices
issuedInvoicesThisMonth,
// My shift
myShiftToday,
// Recent activity
recentActivity,
// Active projects list
activeProjectsList,
] = await Promise.all([
// Existing counts
prisma.users.count({ where: { is_active: true } }),
prisma.projects.count({ where: { status: 'aktivni' } }),
prisma.orders.count({ where: { status: 'prijata' } }),
prisma.invoices.count({ where: { status: 'issued' } }),
prisma.leave_requests.count({ where: { status: 'pending' } }),
// Attendance: today's WORK records with user info
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
OR: [{ leave_type: null }, { leave_type: 'work' }],
},
include: { users: { select: { id: true, first_name: true, last_name: true } } },
orderBy: { arrival_time: 'asc' },
}),
// Users on leave today (attendance records with leave type)
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
leave_type: { in: ['vacation', 'sick', 'holiday', 'unpaid'] },
},
include: { users: { select: { id: true, first_name: true, last_name: true } } },
}),
// Quotation stats
prisma.quotations.count({ where: { status: 'active' } }),
prisma.quotations.count({ where: { status: 'converted' } }),
prisma.quotations.count({ where: { status: 'expired' } }),
prisma.quotations.count({
where: { created_at: { gte: monthStart, lt: monthEnd } },
}),
// Invoice stats — this month's invoices
prisma.invoices.findMany({
where: {
issue_date: { gte: monthStart, lt: monthEnd },
},
include: { invoice_items: true },
}),
// My active (ongoing) shift — any unclosed shift, not just today
prisma.attendance.findFirst({
where: {
user_id: userId,
arrival_time: { not: null },
departure_time: null,
},
orderBy: { created_at: 'desc' },
}),
// Recent audit log activity (last 10)
prisma.audit_logs.findMany({
orderBy: { created_at: 'desc' },
take: 10,
select: {
id: true,
action: true,
entity_type: true,
description: true,
username: true,
created_at: true,
},
}),
// Active projects with customer
prisma.projects.findMany({
where: { status: 'aktivni' },
include: { customers: { select: { name: true } } },
orderBy: { created_at: 'desc' },
}),
]);
// Build attendance users list — deduplicate by user_id, keep latest record per user
// Match PHP status logic: in = working, away = on break, out = departed
const userAttendanceMap = new Map<number, typeof todayAttendance[0]>();
for (const a of todayAttendance) {
const existing = userAttendanceMap.get(a.users.id);
if (!existing || (a.arrival_time && existing.arrival_time && a.arrival_time > existing.arrival_time)) {
userAttendanceMap.set(a.users.id, a);
}
}
let presentCount = 0;
const attendanceUsers: Array<{
user_id: number; name: string; initials: string;
status: string; arrived_at: string | null; leave_type?: string;
}> = [];
// Work records — deduplicate by user, determine status
for (const a of userAttendanceMap.values()) {
const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? '';
const lastInitial = user.last_name?.charAt(0) ?? '';
let status: string = 'out';
if (a.arrival_time) {
if (a.departure_time) {
status = 'out';
} else if (a.break_start && !a.break_end) {
status = 'away';
} else {
status = 'in';
presentCount++;
}
}
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status,
arrived_at: a.arrival_time ? a.arrival_time.toISOString() : null,
});
}
// Leave records — add users on leave with status 'leave' + leave_type (matching PHP)
const leaveUserIds = new Set<number>();
for (const a of onLeaveToday) {
if (leaveUserIds.has(a.users.id)) continue; // deduplicate
leaveUserIds.add(a.users.id);
const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? '';
const lastInitial = user.last_name?.charAt(0) ?? '';
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status: 'leave',
arrived_at: null,
leave_type: (a.leave_type as string) || 'vacation',
});
}
// Compute invoice revenue this month grouped by currency
const revenueByCurrency: Record<string, number> = {};
for (const inv of issuedInvoicesThisMonth) {
const currency = inv.currency ?? 'CZK';
let total = 0;
for (const item of inv.invoice_items) {
const qty = item.quantity ? Number(item.quantity) : 0;
const price = item.unit_price ? Number(item.unit_price) : 0;
total += qty * price;
}
revenueByCurrency[currency] = (revenueByCurrency[currency] ?? 0) + total;
}
const revenueThisMonth = Object.entries(revenueByCurrency).map(([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}));
const revenueCzk = revenueByCurrency['CZK'] != null
? Math.round(revenueByCurrency['CZK'] * 100) / 100
: null;
return success(reply, {
// Existing counts
users_count: usersCount,
active_projects: activeProjectsCount,
pending_orders: pendingOrdersCount,
unpaid_invoices: unpaidInvoicesCount,
pending_leave_requests: pendingLeaveRequests,
// Attendance data
attendance: {
present_today: presentCount,
total_active: usersCount,
on_leave: leaveUserIds.size,
users: attendanceUsers,
},
// Offers/quotations stats
offers: {
open_count: openQuotations,
converted_count: convertedQuotations,
expired_count: expiredQuotations,
created_this_month: quotationsThisMonth,
},
// Invoice revenue
invoices: {
revenue_this_month: revenueThisMonth,
unpaid_count: unpaidInvoicesCount,
revenue_czk: revenueCzk,
},
// Leave pending
leave_pending: { count: pendingLeaveRequests },
// Current user's shift status
my_shift: {
has_ongoing: myShiftToday !== null,
},
// Recent audit log activity
recent_activity: recentActivity.map((log) => ({
id: log.id,
action: log.action,
entity_type: log.entity_type ?? '',
description: log.description ?? '',
username: log.username ?? null,
created_at: log.created_at ? log.created_at.toISOString() : '',
})),
// Active projects list
projects: {
active_projects: activeProjectsList.map((p) => ({
id: p.id,
name: p.name ?? '',
customer_name: p.customers?.name ?? null,
})),
},
});
});
}

View File

@@ -0,0 +1,266 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
function formatDate(date: Date | string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`;
}
function formatNumber(n: number): string {
return n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
const LABELS: Record<string, Record<string, string>> = {
cs: {
invoice: 'Faktura',
invoice_number: 'Číslo faktury',
issue_date: 'Datum vystavení',
due_date: 'Datum splatnosti',
tax_date: 'Datum zdanitelného plnění',
payment_method: 'Způsob platby',
variable_symbol: 'Variabilní symbol',
constant_symbol: 'Konstantní symbol',
bank: 'Banka',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Číslo účtu',
supplier: 'Dodavatel',
customer: 'Odběratel',
ico: 'IČO',
dic: 'DIČ',
description: 'Popis',
qty: 'Množství',
unit: 'Jednotka',
unit_price: 'Cena/ks',
vat: 'DPH %',
total: 'Celkem',
subtotal: 'Základ',
vat_total: 'DPH',
grand_total: 'Celkem k úhradě',
paid_date: 'Datum úhrady',
issued_by: 'Vystavil',
notes: 'Poznámky',
order_number: 'Objednávka',
currency: 'Měna',
},
en: {
invoice: 'Invoice',
invoice_number: 'Invoice number',
issue_date: 'Issue date',
due_date: 'Due date',
tax_date: 'Tax date',
payment_method: 'Payment method',
variable_symbol: 'Variable symbol',
constant_symbol: 'Constant symbol',
bank: 'Bank',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Account number',
supplier: 'Supplier',
customer: 'Customer',
ico: 'Company ID',
dic: 'VAT ID',
description: 'Description',
qty: 'Qty',
unit: 'Unit',
unit_price: 'Unit price',
vat: 'VAT %',
total: 'Total',
subtotal: 'Subtotal',
vat_total: 'VAT',
grand_total: 'Total due',
paid_date: 'Paid date',
issued_by: 'Issued by',
notes: 'Notes',
order_number: 'Order',
currency: 'Currency',
},
};
export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
const query = request.query as Record<string, string>;
const lang = query.lang === 'en' ? 'en' : 'cs';
const L = LABELS[lang];
const invoice = await prisma.invoices.findUnique({
where: { id },
include: {
customers: true,
invoice_items: { orderBy: { position: 'asc' } },
orders: { select: { order_number: true } },
},
});
if (!invoice) {
return reply.status(404).type('text/html').send('<html><body><h1>Faktura nenalezena</h1></body></html>');
}
const settings = await prisma.company_settings.findFirst();
// Compute totals
const items = invoice.invoice_items.map(item => {
const qty = Number(item.quantity) || 0;
const price = Number(item.unit_price) || 0;
const vatRate = Number(item.vat_rate) || Number(invoice.vat_rate) || 21;
const lineTotal = qty * price;
const lineVat = invoice.apply_vat ? lineTotal * (vatRate / 100) : 0;
return { ...item, qty, price, vatRate, lineTotal, lineVat };
});
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0);
const vatTotal = items.reduce((s, i) => s + i.lineVat, 0);
const grandTotal = subtotal + vatTotal;
// Logo as base64
let logoHtml = '';
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
const b64 = buf.toString('base64');
logoHtml = `<img src="data:${mime};base64,${b64}" style="max-height:60px;max-width:200px;" />`;
}
const cust = invoice.customers;
const html = `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<title>${L.invoice} ${escapeHtml(invoice.invoice_number)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 12px; color: #333; padding: 20px; }
@page { size: A4; margin: 15mm; }
@media print { body { padding: 0; } .no-print { display: none; } }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; border-bottom: 2px solid #2563eb; padding-bottom: 15px; }
.header-left { flex: 1; }
.header-right { text-align: right; }
.company-name { font-size: 18px; font-weight: 700; color: #1e40af; }
.invoice-title { font-size: 22px; font-weight: 700; color: #1e40af; margin-bottom: 5px; }
.invoice-number { font-size: 14px; color: #666; }
.parties { display: flex; gap: 40px; margin: 20px 0; }
.party { flex: 1; padding: 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
.party-title { font-weight: 700; font-size: 11px; text-transform: uppercase; color: #64748b; margin-bottom: 8px; letter-spacing: 0.5px; }
.party-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.meta-item { display: flex; gap: 8px; }
.meta-label { font-weight: 600; color: #64748b; min-width: 160px; }
.meta-value { color: #1e293b; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
thead th { background: #1e40af; color: white; padding: 8px 10px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; }
thead th:last-child, thead th.num { text-align: right; }
tbody td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; }
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:nth-child(even) { background: #f8fafc; }
.totals { margin-left: auto; width: 280px; margin-top: 10px; }
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e2e8f0; }
.totals-row.grand { font-weight: 700; font-size: 16px; color: #1e40af; border-top: 2px solid #1e40af; border-bottom: none; padding-top: 10px; }
.notes { margin-top: 20px; padding: 12px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 6px; }
.notes-title { font-weight: 700; margin-bottom: 4px; }
.footer { margin-top: 30px; font-size: 10px; color: #94a3b8; text-align: center; border-top: 1px solid #e2e8f0; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
${logoHtml}
<div class="company-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="header-right">
<div class="invoice-title">${L.invoice}</div>
<div class="invoice-number">${escapeHtml(invoice.invoice_number)}</div>
</div>
</div>
<div class="parties">
<div class="party">
<div class="party-title">${L.supplier}</div>
<div class="party-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="party">
<div class="party-title">${L.customer}</div>
<div class="party-name">${escapeHtml(cust?.name)}</div>
<div>${escapeHtml(cust?.street)}</div>
<div>${escapeHtml(cust?.city)} ${escapeHtml(cust?.postal_code)}</div>
${cust?.company_id ? `<div>${L.ico}: ${escapeHtml(cust.company_id)}</div>` : ''}
${cust?.vat_id ? `<div>${L.dic}: ${escapeHtml(cust.vat_id)}</div>` : ''}
</div>
</div>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">${L.invoice_number}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
<div class="meta-item"><span class="meta-label">${L.issue_date}:</span><span class="meta-value">${formatDate(invoice.issue_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.due_date}:</span><span class="meta-value">${formatDate(invoice.due_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.tax_date}:</span><span class="meta-value">${formatDate(invoice.tax_date)}</span></div>
${invoice.payment_method ? `<div class="meta-item"><span class="meta-label">${L.payment_method}:</span><span class="meta-value">${escapeHtml(invoice.payment_method)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.variable_symbol}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
${invoice.constant_symbol ? `<div class="meta-item"><span class="meta-label">${L.constant_symbol}:</span><span class="meta-value">${escapeHtml(invoice.constant_symbol)}</span></div>` : ''}
${invoice.bank_name ? `<div class="meta-item"><span class="meta-label">${L.bank}:</span><span class="meta-value">${escapeHtml(invoice.bank_name)}</span></div>` : ''}
${invoice.bank_iban ? `<div class="meta-item"><span class="meta-label">${L.iban}:</span><span class="meta-value">${escapeHtml(invoice.bank_iban)}</span></div>` : ''}
${invoice.bank_swift ? `<div class="meta-item"><span class="meta-label">${L.swift}:</span><span class="meta-value">${escapeHtml(invoice.bank_swift)}</span></div>` : ''}
${invoice.bank_account ? `<div class="meta-item"><span class="meta-label">${L.account}:</span><span class="meta-value">${escapeHtml(invoice.bank_account)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.currency}:</span><span class="meta-value">${escapeHtml(invoice.currency)}</span></div>
${invoice.orders?.order_number ? `<div class="meta-item"><span class="meta-label">${L.order_number}:</span><span class="meta-value">${escapeHtml(invoice.orders.order_number)}</span></div>` : ''}
${invoice.issued_by ? `<div class="meta-item"><span class="meta-label">${L.issued_by}:</span><span class="meta-value">${escapeHtml(invoice.issued_by)}</span></div>` : ''}
${invoice.paid_date ? `<div class="meta-item"><span class="meta-label">${L.paid_date}:</span><span class="meta-value">${formatDate(invoice.paid_date)}</span></div>` : ''}
</div>
<table>
<thead>
<tr>
<th style="width:40px;">#</th>
<th>${L.description}</th>
<th class="num" style="width:70px;">${L.qty}</th>
<th style="width:60px;">${L.unit}</th>
<th class="num" style="width:100px;">${L.unit_price}</th>
${invoice.apply_vat ? `<th class="num" style="width:60px;">${L.vat}</th>` : ''}
<th class="num" style="width:110px;">${L.total}</th>
</tr>
</thead>
<tbody>
${items.map((item, i) => `
<tr>
<td>${i + 1}</td>
<td>${escapeHtml(item.description)}</td>
<td class="num">${formatNumber(item.qty)}</td>
<td>${escapeHtml(item.unit)}</td>
<td class="num">${formatNumber(item.price)}</td>
${invoice.apply_vat ? `<td class="num">${item.vatRate}%</td>` : ''}
<td class="num">${formatNumber(item.lineTotal)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="totals">
<div class="totals-row"><span>${L.subtotal}:</span><span>${formatNumber(subtotal)} ${invoice.currency || 'CZK'}</span></div>
${invoice.apply_vat ? `<div class="totals-row"><span>${L.vat_total}:</span><span>${formatNumber(vatTotal)} ${invoice.currency || 'CZK'}</span></div>` : ''}
<div class="totals-row grand"><span>${L.grand_total}:</span><span>${formatNumber(grandTotal)} ${invoice.currency || 'CZK'}</span></div>
</div>
${invoice.notes ? `<div class="notes"><div class="notes-title">${L.notes}:</div><div>${escapeHtml(invoice.notes)}</div></div>` : ''}
</body>
</html>`;
return reply.type('text/html').send(html);
});
}

View File

@@ -0,0 +1,373 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
import { getNextNumber } from '../../utils/sequence';
// Status transition rules matching PHP
const VALID_TRANSITIONS: Record<string, string[]> = {
issued: ['paid'],
overdue: ['paid'],
paid: [],
};
const ALLOWED_SORT_FIELDS = ['id', 'invoice_number', 'status', 'issue_date', 'due_date', 'currency'];
interface InvoiceItemInput { description?: string; quantity?: number; unit?: string; unit_price?: number; vat_rate?: number; position?: number }
function computeInvoiceTotals(items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, applyVat: boolean | null, defaultVatRate: unknown) {
const subtotal = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vatAmount = applyVat
? items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return s + base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
}, 0)
: 0;
return {
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
}
export default async function invoicesRoutes(fastify: FastifyInstance): Promise<void> {
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
fastify.addHook('onRequest', async (request) => {
if (request.method !== 'GET') return;
try {
await prisma.invoices.updateMany({
where: { status: 'issued', due_date: { lt: new Date() } },
data: { status: 'overdue' },
});
} catch { /* silent */ }
});
// GET /api/admin/invoices
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
if (search) {
where.OR = [
{ invoice_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
];
}
const sortField = ALLOWED_SORT_FIELDS.includes(String(query.sort || '')) ? String(query.sort) : 'id';
const orderBy: Record<string, string> = { [sortField]: order };
const [invoices, total] = await Promise.all([
prisma.invoices.findMany({
where,
skip,
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
invoice_items: true,
orders: { select: { id: true, order_number: true } },
},
}),
prisma.invoices.count({ where }),
]);
const enriched = invoices.map(inv => {
const totals = computeInvoiceTotals(inv.invoice_items, inv.apply_vat, inv.vat_rate);
const { invoice_items, ...rest } = inv;
return {
...rest,
items: invoice_items,
customer_name: inv.customers?.name || null,
order_number: inv.orders?.order_number || null,
...totals,
};
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
// GET /api/admin/invoices/next-number
fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => {
// Match PHP: prefix = YY + invoice_type_code from company_settings
const settings = await prisma.company_settings.findFirst({ select: { invoice_type_code: true } });
const typeCode = settings?.invoice_type_code || '81';
const year = new Date().getFullYear();
const yy = String(year).slice(-2);
const prefix = `${yy}${typeCode}`;
// Atomic numbering via number_sequences table
const nextNum = await getNextNumber('invoice', year);
const number = `${prefix}${String(nextNum).padStart(4, '0')}`;
return success(reply, { number, next_number: number });
});
// GET /api/admin/invoices/stats
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const now = new Date();
const year = Number(query.year) || now.getFullYear();
const month = Number(query.month) || (now.getMonth() + 1);
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59);
const allInvoices = await prisma.invoices.findMany({
include: { invoice_items: true },
});
// Helper: compute invoice total WITH VAT (matching PHP)
const invoiceTotalWithVat = (inv: typeof allInvoices[0]) => {
const sub = inv.invoice_items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vat = inv.apply_vat
? inv.invoice_items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return s + base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100);
}, 0)
: 0;
return sub + vat;
};
// Helper: aggregate by currency → CurrencyAmount[]
const aggregateByCurrency = (invoices: typeof allInvoices) => {
const map: Record<string, number> = {};
for (const inv of invoices) {
const cur = inv.currency || 'CZK';
map[cur] = (map[cur] || 0) + invoiceTotalWithVat(inv);
}
return Object.entries(map).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
};
const sumCzk = (invoices: typeof allInvoices) => {
let total = 0;
for (const inv of invoices) {
total += invoiceTotalWithVat(inv); // Simplified: no real FX conversion
}
return Math.round(total * 100) / 100;
};
const monthInvoices = allInvoices.filter(inv => {
const issueDate = inv.issue_date ? new Date(inv.issue_date) : null;
return issueDate && issueDate >= monthStart && issueDate <= monthEnd;
});
const paidInvoices = monthInvoices.filter(i => i.status === 'paid');
const awaitingInvoices = allInvoices.filter(i => i.status === 'issued');
const overdueInvoices = allInvoices.filter(i => i.status === 'overdue');
// VAT by currency
const vatMap: Record<string, number> = {};
for (const inv of monthInvoices) {
if (!inv.apply_vat) continue;
const cur = inv.currency || 'CZK';
for (const item of inv.invoice_items) {
const base = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const vat = base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100);
vatMap[cur] = (vatMap[cur] || 0) + vat;
}
}
const vatAmounts = Object.entries(vatMap).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
let vatCzk = 0;
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
return success(reply, {
paid_month: aggregateByCurrency(paidInvoices),
paid_month_czk: sumCzk(paidInvoices),
paid_month_count: paidInvoices.length,
awaiting: aggregateByCurrency(awaitingInvoices),
awaiting_czk: sumCzk(awaitingInvoices),
awaiting_count: awaitingInvoices.length,
overdue: aggregateByCurrency(overdueInvoices),
overdue_czk: sumCzk(overdueInvoices),
overdue_count: overdueInvoices.length,
vat_month: vatAmounts,
vat_month_czk: Math.round(vatCzk * 100) / 100,
month,
year,
});
});
// GET /api/admin/invoices/order-data/:id
fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
const orderId = parseId(request.params.id, reply);
if (orderId === null) return;
const order = await prisma.orders.findUnique({
where: { id: orderId },
include: {
customers: true,
order_items: { orderBy: { position: 'asc' } },
},
});
if (!order) return error(reply, 'Objednávka nenalezena', 404);
const { order_items, customers, ...rest } = order;
return success(reply, { ...rest, items: order_items, customer_name: customers?.name || null });
});
// GET /api/admin/invoices/:id
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.invoices.findUnique({
where: { id },
include: {
customers: true,
invoice_items: { orderBy: { position: 'asc' } },
orders: { select: { id: true, order_number: true } },
},
});
if (!invoice) return error(reply, 'Faktura nenalezena', 404);
const { invoice_items, ...rest } = invoice;
return success(reply, {
...rest,
items: invoice_items,
customer: invoice.customers,
customer_name: invoice.customers?.name || null,
order_number: invoice.orders?.order_number || null,
valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [],
});
});
// POST /api/admin/invoices
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const invoice = await prisma.invoices.create({
data: {
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
order_id: body.order_id ? Number(body.order_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: body.status ? String(body.status) : 'issued',
currency: body.currency ? String(body.currency) : 'CZK',
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
payment_method: body.payment_method ? String(body.payment_method) : null,
constant_symbol: body.constant_symbol ? String(body.constant_symbol) : null,
bank_name: body.bank_name ? String(body.bank_name) : null,
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
bank_account: body.bank_account ? String(body.bank_account) : null,
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
notes: body.notes ? String(body.notes) : null,
internal_notes: body.internal_notes ? String(body.internal_notes) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.invoice_items.createMany({
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: invoice.id,
description: item.description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena faktura ${invoice.invoice_number}` });
// Return both invoice_id and id for frontend compatibility
return success(reply, { id: invoice.id, invoice_id: invoice.id, invoice_number: invoice.invoice_number }, 201, 'Faktura byla vystavena');
});
// PUT /api/admin/invoices/:id
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Faktura nenalezena', 404);
const currentStatus = existing.status as string;
// Handle status transition
if (body.status !== undefined && body.status !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return error(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400);
}
}
const data: Record<string, unknown> = { modified_at: new Date() };
// Only allow full editing in 'issued' state
const isDraft = currentStatus === 'issued';
if (isDraft) {
const strFields = ['currency', 'payment_method', 'constant_symbol', 'bank_name', 'bank_swift', 'bank_iban', 'bank_account', 'issued_by'];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1';
if (body.issue_date !== undefined) data.issue_date = body.issue_date ? new Date(String(body.issue_date)) : null;
if (body.due_date !== undefined) data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
if (body.tax_date !== undefined) data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
}
// Notes editable in issued/overdue
if (currentStatus === 'issued' || currentStatus === 'overdue') {
if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined) data.internal_notes = body.internal_notes ? String(body.internal_notes) : null;
}
// Status change
if (body.status !== undefined) {
data.status = String(body.status);
// Auto-set paid_date when transitioning to paid
if (String(body.status) === 'paid' && !existing.paid_date) {
data.paid_date = new Date();
}
}
if (body.paid_date !== undefined) data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
await prisma.invoices.update({ where: { id }, data });
// Only allow items update in draft state
if (isDraft && Array.isArray(body.items)) {
await prisma.$transaction(async (tx) => {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
});
}
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${existing.invoice_number}` });
return success(reply, { id }, 200, 'Faktura byla aktualizována');
});
// DELETE /api/admin/invoices/:id
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Faktura nenalezena', 404);
await prisma.invoices.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána faktura ${existing.invoice_number}` });
return success(reply, null, 200, 'Faktura smazána');
});
}

View File

@@ -0,0 +1,238 @@
import { FastifyInstance } from 'fastify';
import { attendance_leave_type, leave_requests_leave_type, leave_requests_status } from '@prisma/client';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const VALID_LEAVE_TYPES = ['vacation', 'sick', 'unpaid'] as const;
const VALID_REVIEW_STATUSES = ['approved', 'rejected'] as const;
export default async function leaveRequestsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const isAdmin = authData.permissions.includes('attendance.approve');
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.status) where.status = String(query.status);
const [requests, total] = await Promise.all([
prisma.leave_requests.findMany({
where, skip, take: limit, orderBy: { created_at: order },
include: {
users_leave_requests_user_idTousers: { select: { id: true, first_name: true, last_name: true } },
users_leave_requests_reviewer_idTousers: { select: { id: true, first_name: true, last_name: true } },
},
}),
prisma.leave_requests.count({ where }),
]);
return reply.send({ success: true, data: requests, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const leaveType = String(body.leave_type || '');
if (!VALID_LEAVE_TYPES.includes(leaveType as typeof VALID_LEAVE_TYPES[number])) {
return error(reply, 'Neplatný typ nepřítomnosti', 400);
}
if (!body.date_from || !body.date_to) {
return error(reply, 'Datum od a do je povinné', 400);
}
const dateFrom = new Date(String(body.date_from));
const dateTo = new Date(String(body.date_to));
if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) {
return error(reply, 'Neplatné datum', 400);
}
if (dateTo < dateFrom) {
return error(reply, 'Datum do musí být po datu od', 400);
}
// Compute business days server-side (matching PHP logic)
let businessDays = 0;
const current = new Date(dateFrom);
while (current <= dateTo) {
const day = current.getDay();
if (day !== 0 && day !== 6) businessDays++;
current.setDate(current.getDate() + 1);
}
if (businessDays === 0) {
return error(reply, 'Zvolený rozsah neobsahuje žádné pracovní dny', 400);
}
const leaveRequest = await prisma.leave_requests.create({
data: {
user_id: authData.userId,
leave_type: leaveType as leave_requests_leave_type,
date_from: dateFrom,
date_to: dateTo,
total_hours: businessDays * 8,
total_days: businessDays,
notes: body.notes ? String(body.notes) : null,
status: 'pending',
},
});
await logAudit({ request, authData, action: 'create', entityType: 'leave_request', entityId: leaveRequest.id, description: `Vytvořena žádost o nepřítomnost` });
return success(reply, { id: leaveRequest.id }, 201, 'Žádost byla odeslána ke schválení');
});
// PUT /api/admin/leave-requests/:id (approve/reject)
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('attendance.approve') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const status = String(body.status || '');
if (!VALID_REVIEW_STATUSES.includes(status as typeof VALID_REVIEW_STATUSES[number])) {
return error(reply, 'Neplatný stav', 400);
}
const existing = await prisma.leave_requests.findUnique({ where: { id } });
if (!existing) return error(reply, 'Žádost nenalezena', 404);
if (existing.status !== 'pending') {
return error(reply, 'Lze schválit/zamítnout pouze čekající žádosti', 400);
}
if (status === 'approved') {
// --- APPROVAL: create attendance records + update leave balance (matching PHP) ---
const leaveType = existing.leave_type as string;
const dateFrom = new Date(existing.date_from);
const dateTo = new Date(existing.date_to);
// For vacation: re-check balance at approval time
if (leaveType === 'vacation') {
const year = dateFrom.getFullYear();
const balance = await prisma.leave_balances.findFirst({
where: { user_id: existing.user_id, year },
});
const vacTotal = balance ? Number(balance.vacation_total) : 160;
const vacUsed = balance ? Number(balance.vacation_used) : 0;
const vacRemaining = vacTotal - vacUsed;
const totalHours = Number(existing.total_hours) || 0;
if (totalHours > vacRemaining) {
return error(reply, `Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`, 400);
}
}
// Count business days and create attendance records
let totalBusinessDays = 0;
const current = new Date(dateFrom);
const attendanceCreates: Array<{
user_id: number;
shift_date: Date;
leave_type: attendance_leave_type;
leave_hours: number;
notes: string;
}> = [];
while (current <= dateTo) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6) {
totalBusinessDays++;
attendanceCreates.push({
user_id: existing.user_id,
shift_date: new Date(Date.UTC(current.getFullYear(), current.getMonth(), current.getDate(), 12, 0, 0)),
leave_type: leaveType as attendance_leave_type,
leave_hours: 8,
notes: `Schválená žádost #${id}`,
});
}
current.setDate(current.getDate() + 1);
}
const totalHours = totalBusinessDays * 8;
// Run everything in a transaction
await prisma.$transaction(async (tx) => {
// 1. Create attendance records for each business day
if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates });
}
// 2. Update leave balance (vacation/sick only — not unpaid)
if (leaveType === 'vacation' || leaveType === 'sick') {
const year = dateFrom.getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: existing.user_id, year },
});
if (existingBalance) {
const updateData: Record<string, unknown> = { updated_at: new Date() };
if (leaveType === 'vacation') {
updateData.vacation_used = Number(existingBalance.vacation_used) + totalHours;
} else {
updateData.sick_used = Number(existingBalance.sick_used) + totalHours;
}
await tx.leave_balances.update({ where: { id: existingBalance.id }, data: updateData });
} else {
await tx.leave_balances.create({
data: {
user_id: existing.user_id,
year,
vacation_total: 160,
vacation_used: leaveType === 'vacation' ? totalHours : 0,
sick_used: leaveType === 'sick' ? totalHours : 0,
},
});
}
}
// 3. Update request status
await tx.leave_requests.update({
where: { id },
data: {
status: 'approved' as leave_requests_status,
reviewer_id: authData.userId,
reviewed_at: new Date(),
},
});
});
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: `Žádost schválena — vytvořeno ${totalBusinessDays} záznamů (${totalHours}h)` });
return success(reply, { id }, 200, 'Žádost byla schválena');
}
// --- REJECTION: just update status ---
await prisma.leave_requests.update({
where: { id },
data: {
status: 'rejected' as leave_requests_status,
reviewer_id: authData.userId,
reviewer_note: body.reviewer_note ? String(body.reviewer_note) : null,
reviewed_at: new Date(),
},
});
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: 'Žádost zamítnuta' });
return success(reply, { id }, 200, 'Žádost byla zamítnuta');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.leave_requests.findUnique({ where: { id } });
if (!existing) return error(reply, 'Žádost nenalezena', 404);
if (existing.status !== 'pending') {
return error(reply, 'Lze zrušit pouze čekající žádosti', 400);
}
await prisma.leave_requests.update({ where: { id }, data: { status: 'cancelled' } });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'leave_request', entityId: id, description: `Žádost zrušena` });
return success(reply, null, 200, 'Žádost zrušena');
});
}

View File

@@ -0,0 +1,721 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
function formatDate(date: Date | string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
/** Format number with comma decimal separator and non-breaking space thousands separator */
function formatNum(n: number, decimals: number): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split('.');
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function formatCurrency(amount: number, currency: string): string {
const n = Number(amount) || 0;
switch (currency) {
case 'EUR': return `${formatNum(n, 2)} \u20AC`;
case 'USD': return `$${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
case 'CZK': return `${formatNum(n, 2)} K\u010D`;
case 'GBP': return `\u00A3${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
default: return `${formatNum(n, 2)} ${currency}`;
}
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return '';
const allowedTags = '<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>';
// Simple strip_tags equivalent: remove tags not in allowed list
let s = html;
// Remove dangerous tags with content
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, '');
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, '');
// Strip event handlers
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '');
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
// Strip javascript: in href
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, ' ');
// Merge adjacent spans with same attributes
let prev = '';
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2');
}
return s;
}
interface AddressResult { name: string; lines: string[] }
function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
t: (key: string) => string,
): AddressResult {
if (!entity) return { name: '', lines: [] };
const nameKey = isSupplier ? 'company_name' : 'name';
const name = String(entity[nameKey] || '');
// Parse custom_fields
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = [];
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === 'object') {
if ((parsed as Record<string, unknown>).fields) {
cfData = ((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order || (parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) {
cfData = parsed;
}
}
}
// Legacy PascalCase key compat
if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = {
Name: 'name', CompanyName: 'company_name',
Street: 'street', CityPostal: 'city_postal',
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id',
};
fieldOrder = fieldOrder.map(k => legacyMap[k] || k);
}
const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String);
const cityPostal = cityParts.join(' ').trim();
if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id) fieldMap.company_id = `${t('ico')}: ${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${t('dic')}: ${entity.vat_id}`;
cfData.forEach((cf, i) => {
const cfName = (cf.name || '').trim();
const cfValue = (cf.value || '').trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
}
});
const lines: string[] = [];
if (Array.isArray(fieldOrder) && fieldOrder.length > 0) {
for (const key of fieldOrder) {
if (key === nameKey) continue;
if (fieldMap[key]) lines.push(fieldMap[key]);
}
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
if (!fieldOrder.includes(key)) lines.push(line);
}
} else {
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
lines.push(line);
}
}
return { name, lines };
}
const TRANSLATIONS: Record<string, Record<string, string>> = {
title: { EN: 'PRICE QUOTATION', CZ: 'CENOV\u00C1 NAB\u00CDDKA' },
scope_title: { EN: 'SCOPE OF THE PROJECT', CZ: 'ROZSAH PROJEKTU' },
valid_until: { EN: 'Valid until', CZ: 'Platnost do' },
customer: { EN: 'Customer', CZ: 'Z\u00E1kazn\u00EDk' },
supplier: { EN: 'Supplier', CZ: 'Dodavatel' },
no: { EN: 'N.', CZ: '\u010C.' },
description: { EN: 'Description', CZ: 'Popis' },
qty: { EN: 'Qty', CZ: 'Mn.' },
unit_price: { EN: 'Unit Price', CZ: 'Jedn. cena' },
included: { EN: 'Included', CZ: 'Zahrnuto' },
total: { EN: 'Total', CZ: 'Celkem' },
subtotal: { EN: 'Subtotal', CZ: 'Mezisou\u010Det' },
vat: { EN: 'VAT', CZ: 'DPH' },
total_to_pay: { EN: 'Total to pay', CZ: 'Celkem k \u00FAhrad\u011B' },
exchange_rate: { EN: 'Exchange rate', CZ: 'Sm\u011Bnn\u00FD kurz' },
ico: { EN: 'ID', CZ: 'I\u010CO' },
dic: { EN: 'VAT ID', CZ: 'DI\u010C' },
page: { EN: 'Page', CZ: 'Strana' },
of: { EN: 'of', CZ: 'z' },
};
export default async function offersPdfRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
try {
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) {
return reply.status(404).type('text/html').send('<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>');
}
const settings = await prisma.company_settings.findFirst();
const isCzech = (quotation.language ?? 'EN') !== 'EN';
const langKey = isCzech ? 'CZ' : 'EN';
const currency = quotation.currency || 'EUR';
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
// Logo
let logoImg = '';
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp';
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString('base64')}" class="logo" />`;
}
// Calculations
const items = quotation.quotation_items;
let subtotal = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
subtotal += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
}
const applyVat = !!quotation.apply_vat;
const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount;
const exchangeRate = Number(quotation.exchange_rate) || 0;
// Scope content check
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || '').trim() || (s.title || '').trim()) {
hasScopeContent = true;
break;
}
}
// Addresses
const cust = buildAddressLines(quotation.customers as unknown as Record<string, unknown>, false, t);
const supp = buildAddressLines(settings as unknown as Record<string, unknown>, true, t);
const custLinesHtml = cust.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
const suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
// Indentation CSS for Quill
let indentCSS = '';
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
// Items HTML
let itemsHtml = '';
items.forEach((item, i) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const subDesc = item.item_description || '';
const evenClass = (i % 2 === 1) ? ' class="even"' : '';
itemsHtml += `<tr${evenClass}>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ''}</td>
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || '').trim() ? ` / ${escapeHtml((item.unit || '').trim())}` : ''}</td>
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
});
// Totals HTML
let totalsHtml = '';
if (applyVat) {
totalsHtml += `<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t('subtotal'))}:</span>
<span class="value">${formatCurrency(subtotal, currency)}</span>
</div>
<div class="row">
<span class="label">${escapeHtml(t('vat'))} (${Math.round(vatRate)}%):</span>
<span class="value">${formatCurrency(vatAmount, currency)}</span>
</div>
</div>`;
}
totalsHtml += `<div class="grand">
<span class="label">${escapeHtml(t('total_to_pay'))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span>
</div>`;
if (exchangeRate > 0) {
totalsHtml += `<div class="exchange-rate">${escapeHtml(t('exchange_rate'))}: ${formatNum(exchangeRate, 4)}</div>`;
}
// Scope HTML
let scopeHtml = '';
if (hasScopeContent) {
scopeHtml += '<div class="scope-page">';
scopeHtml += `<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t('scope_title'))}</div>`;
if (quotation.scope_title) {
scopeHtml += `<div class="scope-subtitle">${escapeHtml(quotation.scope_title)}</div>`;
}
if (quotation.scope_description) {
scopeHtml += `<div class="scope-description">${escapeHtml(quotation.scope_description)}</div>`;
}
scopeHtml += '</div>';
if (logoImg) {
scopeHtml += `<div class="right"><div class="logo-header">${logoImg}</div></div>`;
}
scopeHtml += `</div>
<hr class="separator" />`;
for (const section of quotation.scope_sections) {
const title = isCzech && (section.title_cz || '').trim() ? section.title_cz : (section.title || '');
const content = (section.content || '').trim();
if (!title && !content) continue;
scopeHtml += '<div class="scope-section">';
if (title) scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content) scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
scopeHtml += '</div>';
}
scopeHtml += '</div>';
}
const quotationNumber = escapeHtml(quotation.quotation_number);
const pageLabel = escapeHtml(t('page'));
const ofLabel = escapeHtml(t('of'));
const html = `<!DOCTYPE html>
<html lang="${isCzech ? 'cs' : 'en'}">
<head>
<meta charset="utf-8" />
<title>${quotationNumber}</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
<link rel="shortcut icon" href="/favicon.ico">
<style>
/* ---- Base ---- */
@page {
size: A4;
margin: 15mm 15mm 25mm 15mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 180mm;
}
img, table, pre, code { max-width: 100%; }
/* ---- Quill 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; }
/* ---- Quill alignment ---- */
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
/* ---- Quill indentation ---- */
${indentCSS}
/* ---- Page header ---- */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4mm;
}
.page-header .left { flex: 1; }
.page-header .right { flex-shrink: 0; margin-left: 10mm; }
.logo { max-width: 42mm; max-height: 22mm; object-fit: contain; }
.page-title {
font-size: 18pt;
font-weight: bold;
color: #1a1a1a;
margin: 0;
}
.scope-page .page-title { font-size: 16pt; }
.quotation-number {
font-size: 12pt;
color: #1a1a1a;
margin: 1mm 0;
}
.project-code {
font-size: 10pt;
color: #646464;
}
.valid-until {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.scope-subtitle {
font-size: 11pt;
color: #646464;
margin-top: 1mm;
}
.scope-description {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.separator {
border: none;
border-top: 0.5pt solid #e0e0e0;
margin: 3mm 0 5mm 0;
}
/* ---- Addresses ---- */
.addresses {
display: flex;
justify-content: space-between;
margin-bottom: 8mm;
}
.address-block { width: 48%; }
.address-block.right { text-align: right; }
.address-label {
font-size: 9pt;
font-weight: bold;
color: #646464;
line-height: 1.5;
}
.address-name {
font-size: 9pt;
font-weight: bold;
color: #1a1a1a;
line-height: 1.5;
}
.address-line {
font-size: 9pt;
color: #646464;
line-height: 1.5;
}
/* ---- Items table ---- */
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8pt;
font-weight: 600;
color: #646464;
padding: 6px 8px;
text-align: left;
letter-spacing: 0.02em;
text-transform: uppercase;
border-bottom: 1pt solid #1a1a1a;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 7px 8px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 8pt;
}
table.items tbody td.desc {
font-size: 10pt;
font-weight: 500;
color: #1a1a1a;
}
table.items tbody td.total-cell {
font-weight: 700;
}
.item-subdesc {
font-size: 9pt;
color: #646464;
margin-top: 2px;
font-weight: 400;
}
/* ---- Totals ---- */
.totals-wrapper {
display: flex;
justify-content: flex-end;
break-inside: avoid;
margin-top: 8mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 8.5pt;
color: #646464;
margin-bottom: 2mm;
}
.totals .row:last-child { margin-bottom: 0; }
.totals .row .value {
color: #1a1a1a;
font-size: 8.5pt;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 9.5pt;
font-weight: 400;
color: #646464;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .exchange-rate {
text-align: right;
font-size: 7.5pt;
color: #969696;
margin-top: 3mm;
}
/* ---- Scope sections ---- */
.scope-page {
page-break-before: always;
}
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 1mm;
}
.section-content {
font-size: 9pt;
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
/* ---- Repeating page header ---- */
table.page-layout {
width: 100%;
border-collapse: collapse;
}
table.page-layout > thead > tr > td,
table.page-layout > tbody > tr > td {
padding: 0;
border: none;
vertical-align: top;
}
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; }
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
@bottom-center {
content: "${pageLabel} " counter(page) " ${ofLabel} " counter(pages);
font-size: 8pt;
color: #969696;
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
}
}
}
/* ---- Screen-only: A4 page preview ---- */
@media screen {
html {
background: #525659;
}
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
min-height: 100vh;
overflow-x: hidden;
}
.quotation-page, .scope-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
table.page-layout,
table.page-layout > thead,
table.page-layout > thead > tr,
table.page-layout > thead > tr > td,
table.page-layout > tbody,
table.page-layout > tbody > tr,
table.page-layout > tbody > tr > td {
display: block;
width: 100%;
}
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
}
</style>
</head>
<body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
<div class="quotation-page">
<table class="page-layout">
<thead>
<tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t('title'))}</div>
<div class="quotation-number">${quotationNumber}</div>
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ''}
<div class="valid-until">${escapeHtml(t('valid_until'))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div>
</div>
<hr class="separator" />
<div class="addresses">
<div class="address-block left">
<div class="address-label">${escapeHtml(t('customer'))}</div>
<div class="address-name">${escapeHtml(cust.name)}</div>
${custLinesHtml}
</div>
<div class="address-block right">
<div class="address-label">${escapeHtml(t('supplier'))}</div>
<div class="address-name">${escapeHtml(supp.name)}</div>
${suppLinesHtml}
</div>
</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:5%">${escapeHtml(t('no'))}</th>
<th style="width:44%">${escapeHtml(t('description'))}</th>
<th class="center" style="width:13%">${escapeHtml(t('qty'))}</th>
<th class="right" style="width:18%">${escapeHtml(t('unit_price'))}</th>
<th class="right" style="width:20%">${escapeHtml(t('total'))}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<div class="totals-wrapper">
<div class="totals">
${totalsHtml}
</div>
</div>
</div>
</td></tr>
</tbody>
</table>
</div>
${scopeHtml}
</body>
</html>`;
return reply.type('text/html').send(html);
} catch (err) {
request.log.error(err, 'PDF generation failed');
return reply.status(500).type('text/html').send('<html><body><h1>Chyba p\u0159i generov\u00E1n\u00ED PDF</h1></body></html>');
}
});
}

526
src/routes/admin/orders.ts Normal file
View File

@@ -0,0 +1,526 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
import multipart from '@fastify/multipart';
// Status transition rules matching PHP
const VALID_TRANSITIONS: Record<string, string[]> = {
prijata: ['v_realizaci', 'stornovana'],
v_realizaci: ['dokoncena', 'stornovana'],
dokoncena: [],
stornovana: [],
};
// Shared number generator matching PHP generateSharedNumber()
// Format: YYtypeCode + 4-digit sequence, shared between orders and projects
async function generateSharedNumber(): Promise<string> {
const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
const typeCode = settings?.order_type_code || '71';
const yy = String(new Date().getFullYear()).slice(-2);
const prefix = `${yy}${typeCode}`;
const prefixLen = prefix.length;
const likePattern = `${prefix}%`;
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM orders WHERE order_number LIKE ${likePattern}
UNION ALL
SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM projects WHERE project_number LIKE ${likePattern}
) combined
`;
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
return `${prefix}${String(nextNum).padStart(4, '0')}`;
}
async function generateOrderNumber(): Promise<string> {
return generateSharedNumber();
}
async function generateProjectNumber(): Promise<string> {
return generateSharedNumber();
}
interface OrderItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number }
interface OrderSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
export default async function ordersRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
// GET /api/admin/orders/next-number
fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => {
const number = await generateOrderNumber();
return success(reply, { number, next_number: number });
});
const ORDER_ALLOWED_SORT_FIELDS = ['id', 'order_number', 'status', 'currency', 'created_at'];
fastify.get('/', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order } = parsePagination(query);
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
const [orders, total] = await Promise.all([
prisma.orders.findMany({
where, skip, take: limit, orderBy: { [sortField]: order },
include: {
customers: { select: { id: true, name: true } },
order_items: { orderBy: { position: 'asc' } },
order_sections: { orderBy: { position: 'asc' } },
quotations: { select: { quotation_number: true, project_code: true } },
invoices: { select: { id: true, invoice_number: true }, take: 1 },
},
}),
prisma.orders.count({ where }),
]);
const enriched = orders.map(o => {
const subtotal = o.order_items
.filter(i => i.is_included_in_total !== false)
.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vatAmount = o.apply_vat ? subtotal * ((Number(o.vat_rate) || 21) / 100) : 0;
const { order_items, order_sections, ...rest } = o;
const invoice = o.invoices?.[0] || null;
return {
...rest,
items: order_items,
sections: order_sections,
customer_name: o.customers?.name || null,
quotation_number: o.quotations?.quotation_number || null,
project_code: o.quotations?.project_code || null,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.orders.findUnique({
where: { id },
include: {
customers: true,
order_items: { orderBy: { position: 'asc' } },
order_sections: { orderBy: { position: 'asc' } },
quotations: { select: { id: true, quotation_number: true, project_code: true } },
projects: { select: { id: true, project_number: true, name: true, status: true } },
invoices: { select: { id: true, invoice_number: true, status: true }, take: 1 },
},
});
if (!order) return error(reply, 'Objednávka nenalezena', 404);
const { order_items, order_sections, ...rest } = order;
const invoice = order.invoices?.[0] || null;
return success(reply, {
...rest,
items: order_items,
sections: order_sections,
customer: order.customers,
customer_name: order.customers?.name || null,
quotation_number: order.quotations?.quotation_number || null,
project_code: order.quotations?.project_code || null,
project: order.projects?.[0] || null,
invoice: invoice,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
valid_transitions: VALID_TRANSITIONS[(order.status as string) || ''] || [],
});
});
// GET /api/admin/orders/:id/attachment
fastify.get<{ Params: { id: string } }>('/:id/attachment', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.orders.findUnique({
where: { id },
select: { attachment_data: true, attachment_name: true },
});
if (!order?.attachment_data) return error(reply, 'Příloha nenalezena', 404);
const filename = order.attachment_name || `order-${id}.pdf`;
return reply
.type('application/pdf')
.header('Content-Disposition', `inline; filename="${filename}"`)
.send(Buffer.from(order.attachment_data));
});
// POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation)
fastify.post('/', { preHandler: requirePermission('orders.create') }, async (request, reply) => {
const isMultipart = request.headers['content-type']?.includes('multipart');
if (isMultipart) {
// === Order from quotation flow ===
const fields: Record<string, string> = {};
let attachmentBuffer: Buffer | null = null;
let attachmentName: string | null = null;
const parts = request.parts();
for await (const part of parts) {
if (part.type === 'field') {
fields[part.fieldname] = String(part.value);
} else if (part.type === 'file' && part.fieldname === 'attachment') {
attachmentBuffer = await part.toBuffer();
attachmentName = part.filename;
}
}
const quotationId = parseInt(fields.quotationId, 10);
const customerOrderNumber = fields.customerOrderNumber || '';
if (!quotationId || isNaN(quotationId)) {
return error(reply, 'Chybí ID nabídky', 400);
}
const quotation = await prisma.quotations.findUnique({
where: { id: quotationId },
include: {
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
const orderNumber = await generateOrderNumber();
const projectNumber = await generateProjectNumber();
const result = await prisma.$transaction(async (tx) => {
// Create the order
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: customerOrderNumber || null,
quotation_id: quotationId,
customer_id: quotation.customer_id,
status: 'prijata',
currency: quotation.currency || 'CZK',
language: quotation.language || 'cs',
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
attachment_data: attachmentBuffer ? new Uint8Array(attachmentBuffer) : null,
attachment_name: attachmentName,
},
});
// Copy quotation_items → order_items
if (quotation.quotation_items.length > 0) {
await tx.order_items.createMany({
data: quotation.quotation_items.map((item) => ({
order_id: order.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
// Copy scope_sections → order_sections
if (quotation.scope_sections.length > 0) {
await tx.order_sections.createMany({
data: quotation.scope_sections.map((s) => ({
order_id: order.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
// Link quotation back to order and mark as ordered
await tx.quotations.update({
where: { id: quotationId },
data: { order_id: order.id, status: 'ordered', modified_at: new Date() },
});
// Create project automatically
const project = await tx.projects.create({
data: {
project_number: projectNumber,
name: quotation.project_code || quotation.quotation_number || orderNumber,
customer_id: quotation.customer_id,
quotation_id: quotationId,
order_id: order.id,
status: 'aktivni',
},
});
return { order, project };
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.order.id, description: `Vytvořena objednávka ${orderNumber} z nabídky #${quotationId}` });
return success(reply, { order_id: result.order.id, id: result.order.id, order_number: orderNumber }, 201, 'Objednávka byla vytvořena');
}
// === JSON body — either from-quotation (no attachment) or manual order ===
const body = request.body as Record<string, unknown>;
// From-quotation flow via JSON (no attachment)
if (body.quotationId) {
const quotationId = Number(body.quotationId);
const customerOrderNumber = body.customerOrderNumber ? String(body.customerOrderNumber) : '';
if (!quotationId || isNaN(quotationId)) {
return error(reply, 'Chybí ID nabídky', 400);
}
const quotation = await prisma.quotations.findUnique({
where: { id: quotationId },
include: {
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
const orderNumber = await generateOrderNumber();
const projectNumber = await generateProjectNumber();
const result = await prisma.$transaction(async (tx) => {
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: customerOrderNumber || null,
quotation_id: quotationId,
customer_id: quotation.customer_id,
status: 'prijata',
currency: quotation.currency || 'CZK',
language: quotation.language || 'cs',
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
},
});
if (quotation.quotation_items.length > 0) {
await tx.order_items.createMany({
data: quotation.quotation_items.map((item) => ({
order_id: order.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
if (quotation.scope_sections.length > 0) {
await tx.order_sections.createMany({
data: quotation.scope_sections.map((s) => ({
order_id: order.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
await tx.quotations.update({
where: { id: quotationId },
data: { order_id: order.id, status: 'ordered', modified_at: new Date() },
});
const project = await tx.projects.create({
data: {
project_number: projectNumber,
name: quotation.project_code || quotation.quotation_number || orderNumber,
customer_id: quotation.customer_id,
quotation_id: quotationId,
order_id: order.id,
status: 'aktivni',
},
});
return { order, project };
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.order.id, description: `Vytvořena objednávka ${orderNumber} z nabídky #${quotationId}` });
return success(reply, { order_id: result.order.id, id: result.order.id, order_number: orderNumber }, 201, 'Objednávka byla vytvořena');
}
// Manual order creation
const order = await prisma.orders.create({
data: {
order_number: body.order_number ? String(body.order_number) : null,
customer_order_number: body.customer_order_number ? String(body.customer_order_number) : null,
quotation_id: body.quotation_id ? Number(body.quotation_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: body.status ? String(body.status) : 'prijata',
currency: body.currency ? String(body.currency) : 'CZK',
language: body.language ? String(body.language) : 'cs',
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description ? String(body.scope_description) : null,
notes: body.notes ? String(body.notes) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.order_items.createMany({
data: (body.items as OrderItemInput[]).map((item, i) => ({
order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await prisma.order_sections.createMany({
data: (body.sections as OrderSectionInput[]).map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: order.id, description: `Vytvořena objednávka ${order.order_number}` });
return success(reply, { id: order.id }, 201, 'Objednávka byla vytvořena');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.orders.findUnique({ where: { id } });
if (!existing) return error(reply, 'Objednávka nenalezena', 404);
const currentStatus = existing.status as string;
// Validate status transition
if (body.status !== undefined && String(body.status) !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return error(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400);
}
}
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = ['order_number', 'customer_order_number', 'status', 'currency', 'language', 'scope_title', 'scope_description', 'notes'];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1';
await prisma.orders.update({ where: { id }, data });
// Sync project_number when order_number changes (matching PHP)
if (body.order_number !== undefined && String(body.order_number) !== existing.order_number) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { project_number: String(body.order_number) },
});
}
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: 'aktivni',
dokoncena: 'dokonceny',
stornovana: 'zruseny',
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
await prisma.$transaction(async (tx) => {
if (Array.isArray(body.items)) {
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_items.createMany({
data: (body.items as OrderItemInput[]).map((item, i) => ({
order_id: id, description: item.description ?? null, item_description: item.item_description ?? null,
quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false, position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.order_sections.createMany({
data: (body.sections as OrderSectionInput[]).map((s, i) => ({
order_id: id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: s.position ?? i,
})),
});
}
});
}
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'order', entityId: id, description: `Upravena objednávka ${existing.order_number}` });
return success(reply, { id }, 200, 'Objednávka byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.orders.findUnique({ where: { id } });
if (!existing) return error(reply, 'Objednávka nenalezena', 404);
// Clear quotation back-reference (matching PHP)
await prisma.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true } });
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map(p => p.id);
await prisma.project_notes.deleteMany({ where: { project_id: { in: projectIds } } });
await prisma.projects.deleteMany({ where: { order_id: id } });
}
await prisma.orders.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'order', entityId: id, description: `Smazána objednávka ${existing.order_number}` });
return success(reply, null, 200, 'Objednávka smazána');
});
}

View File

@@ -0,0 +1,53 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth } from '../../middleware/auth';
import { success, error } from '../../utils/response';
import bcrypt from 'bcryptjs';
import { config } from '../../config/env';
import { logAudit } from '../../services/audit';
export default async function profileRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
select: {
id: true, username: true, email: true, first_name: true, last_name: true,
totp_enabled: true, last_login: true, password_changed_at: true,
roles: { select: { id: true, name: true, display_name: true } },
},
});
if (!user) return error(reply, 'Uživatel nenalezen', 404);
return success(reply, user);
});
fastify.put('/', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const userId = request.authData!.userId;
const data: Record<string, unknown> = {};
if (body.email) {
const newEmail = String(body.email).trim();
const existing = await prisma.users.findFirst({ where: { email: newEmail, id: { not: userId } } });
if (existing) return error(reply, 'E-mail již existuje', 409);
data.email = newEmail;
}
if (body.first_name) data.first_name = String(body.first_name);
if (body.last_name) data.last_name = String(body.last_name);
if (body.current_password && body.new_password) {
const user = await prisma.users.findUnique({ where: { id: userId } });
if (!user) return error(reply, 'Uživatel nenalezen', 404);
const valid = await bcrypt.compare(String(body.current_password), user.password_hash);
if (!valid) return error(reply, 'Nesprávné aktuální heslo', 400);
data.password_hash = await bcrypt.hash(String(body.new_password), config.security.bcryptCost);
data.password_changed_at = new Date();
await logAudit({ request, authData: request.authData, action: 'password_change', entityType: 'user', entityId: userId, description: 'Změna hesla' });
}
await prisma.users.update({ where: { id: userId }, data });
return success(reply, null, 200, 'Profil aktualizován');
});
}

View File

@@ -0,0 +1,166 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const PROJECT_ALLOWED_SORT_FIELDS = ['id', 'project_number', 'name', 'status', 'created_at'];
export default async function projectsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order, search } = parsePagination(query);
const sortField = PROJECT_ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
if (search) where.OR = [{ name: { contains: search } }, { project_number: { contains: search } }];
const [projects, total] = await Promise.all([
prisma.projects.findMany({
where, skip, take: limit, orderBy: { [sortField]: order },
include: {
customers: { select: { id: true, name: true } },
users: { select: { id: true, first_name: true, last_name: true } },
orders: { select: { order_number: true } },
},
}),
prisma.projects.count({ where }),
]);
const enriched = projects.map(p => ({
...p,
customer_name: p.customers?.name || null,
responsible_user_name: p.users ? `${p.users.first_name} ${p.users.last_name}`.trim() : null,
order_number: p.orders?.[0]?.order_number || null,
}));
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const project = await prisma.projects.findUnique({
where: { id },
include: { customers: true, users: true, quotations: true, orders: true, project_notes: { orderBy: { created_at: 'desc' } } },
});
if (!project) return error(reply, 'Projekt nenalezen', 404);
return success(reply, project);
});
fastify.post('/', { preHandler: requirePermission('projects.create') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const project = await prisma.projects.create({
data: {
project_number: body.project_number ? String(body.project_number) : null,
name: body.name ? String(body.name) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
responsible_user_id: body.responsible_user_id ? Number(body.responsible_user_id) : null,
quotation_id: body.quotation_id ? Number(body.quotation_id) : null,
order_id: body.order_id ? Number(body.order_id) : null,
status: body.status ? String(body.status) : 'aktivni',
start_date: body.start_date ? new Date(String(body.start_date)) : null,
end_date: body.end_date ? new Date(String(body.end_date)) : null,
notes: body.notes ? String(body.notes) : null,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'project', entityId: project.id, description: `Vytvořen projekt ${project.name}` });
return success(reply, { id: project.id }, 201, 'Projekt byl vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return error(reply, 'Projekt nenalezen', 404);
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = ['project_number', 'name', 'status', 'notes'];
for (const f of strFields) if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.responsible_user_id !== undefined) data.responsible_user_id = body.responsible_user_id ? Number(body.responsible_user_id) : null;
if (body.quotation_id !== undefined) data.quotation_id = body.quotation_id ? Number(body.quotation_id) : null;
if (body.order_id !== undefined) data.order_id = body.order_id ? Number(body.order_id) : null;
if (body.start_date !== undefined) data.start_date = body.start_date ? new Date(String(body.start_date)) : null;
if (body.end_date !== undefined) data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
await prisma.projects.update({ where: { id }, data });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'project', entityId: id, description: `Upraven projekt ${existing.name}` });
return success(reply, { id }, 200, 'Projekt byl uložen');
});
// POST /api/admin/projects/:id/notes
fastify.post<{ Params: { id: string } }>('/:id/notes', { preHandler: requirePermission('projects.edit') }, async (request, reply) => {
const projectId = parseId(request.params.id, reply);
if (projectId === null) return;
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const note = await prisma.project_notes.create({
data: {
project_id: projectId,
user_id: authData.userId,
user_name: `${authData.firstName} ${authData.lastName}`,
content: body.content ? String(body.content) : null,
},
});
return success(reply, { note }, 201, 'Poznámka byla přidána');
});
// GET /api/admin/projects/next-number — shared sequence with orders (matches PHP)
fastify.get('/next-number', { preHandler: requirePermission('projects.create') }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
const typeCode = settings?.order_type_code || '71';
const yy = String(new Date().getFullYear()).slice(-2);
const prefix = `${yy}${typeCode}`;
const prefixLen = prefix.length;
const likePattern = `${prefix}%`;
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM orders WHERE order_number LIKE ${likePattern}
UNION ALL
SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM projects WHERE project_number LIKE ${likePattern}
) combined
`;
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
return success(reply, { next_number: `${prefix}${String(nextNum).padStart(4, '0')}` });
});
// DELETE /api/admin/projects/:id/notes/:noteId
fastify.delete<{ Params: { id: string; noteId: string } }>('/:id/notes/:noteId', { preHandler: requirePermission('projects.edit') }, async (request, reply) => {
const noteId = parseId(request.params.noteId, reply);
if (noteId === null) return;
const projectId = parseId(request.params.id, reply);
if (projectId === null) return;
const note = await prisma.project_notes.findFirst({ where: { id: noteId, project_id: projectId } });
if (!note) return error(reply, 'Poznámka nenalezena', 404);
await prisma.project_notes.delete({ where: { id: noteId } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project', entityId: projectId, description: `Smazána poznámka projektu` });
return success(reply, null, 200, 'Poznámka smazána');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return error(reply, 'Projekt nenalezen', 404);
if (existing.order_id) return error(reply, 'Nelze smazat projekt propojený s objednávkou. Nejdříve smažte objednávku.', 400);
await prisma.projects.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project', entityId: id, description: `Smazán projekt ${existing.name}` });
return success(reply, null, 200, 'Projekt smazán');
});
}

View File

@@ -0,0 +1,326 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
interface QuotationItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number }
interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
const ALLOWED_SORT_FIELDS = ['id', 'quotation_number', 'project_code', 'created_at', 'valid_until', 'currency', 'status'];
export default async function quotationsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order, search } = parsePagination(query);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
if (search) {
where.OR = [
{ quotation_number: { contains: search } },
{ project_code: { contains: search } },
{ customers: { name: { contains: search } } },
];
}
const [quotations, total] = await Promise.all([
prisma.quotations.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
}),
prisma.quotations.count({ where }),
]);
// Compute totals and map relation names
const enriched = quotations.map(q => {
const subtotal = q.quotation_items
.filter(i => i.is_included_in_total !== false)
.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vatAmount = q.apply_vat ? subtotal * ((Number(q.vat_rate) || 21) / 100) : 0;
const { quotation_items, scope_sections, ...rest } = q;
return {
...rest,
items: quotation_items,
sections: scope_sections,
customer_name: q.customers?.name || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
// GET /api/admin/offers/next-number
fastify.get('/next-number', { preHandler: requirePermission('offers.create') }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
const prefix = settings?.quotation_prefix || 'NA';
const year = new Date().getFullYear();
const likePattern = `${year}/${prefix}/%`;
// Match PHP logic: find MAX number from existing quotations
const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
FROM quotations
WHERE quotation_number LIKE ${likePattern}
`;
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
const number = `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`;
return success(reply, { number, next_number: number });
});
// POST /api/admin/offers/:id/duplicate
fastify.post<{ Params: { id: string } }>('/:id/duplicate', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const original = await prisma.quotations.findUnique({
where: { id },
include: { quotation_items: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: 'asc' } } },
});
if (!original) return error(reply, 'Nabídka nenalezena', 404);
// Get next number by querying MAX from existing quotations (matches PHP logic)
const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
const qPrefix = settings?.quotation_prefix || 'NA';
const year = new Date().getFullYear();
const likePattern = `${year}/${qPrefix}/%`;
const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
FROM quotations
WHERE quotation_number LIKE ${likePattern}
`;
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
const copy = await prisma.quotations.create({
data: {
quotation_number: `${year}/${qPrefix}/${String(nextNum).padStart(3, '0')}`,
project_code: original.project_code,
customer_id: original.customer_id,
valid_until: null,
currency: original.currency,
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
exchange_rate: original.exchange_rate,
status: 'active',
scope_title: original.scope_title,
scope_description: original.scope_description,
},
});
if (original.quotation_items.length > 0) {
await prisma.quotation_items.createMany({
data: original.quotation_items.map((item) => ({
quotation_id: copy.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
if (original.scope_sections.length > 0) {
await prisma.scope_sections.createMany({
data: original.scope_sections.map((s) => ({
quotation_id: copy.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: copy.id, description: `Duplikována nabídka ${original.quotation_number}${copy.quotation_number}` });
return success(reply, { id: copy.id, quotation_number: copy.quotation_number }, 201, 'Nabídka byla duplikována');
});
// POST /api/admin/offers/:id/invalidate
fastify.post<{ Params: { id: string } }>('/:id/invalidate', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
await prisma.quotations.update({ where: { id }, data: { status: 'invalidated', modified_at: new Date() } });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Zneplatněna nabídka ${existing.quotation_number}` });
return success(reply, null, 200, 'Nabídka zneplatněna');
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
// Fetch linked order if exists
let orderInfo = null;
if (quotation.order_id) {
const order = await prisma.orders.findUnique({
where: { id: quotation.order_id },
select: { id: true, order_number: true, status: true },
});
orderInfo = order;
}
const { quotation_items, scope_sections, ...rest } = quotation;
return success(reply, {
...rest,
items: quotation_items,
sections: scope_sections,
customer: quotation.customers,
customer_name: quotation.customers?.name || null,
order: orderInfo,
});
});
fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const quotation = await prisma.quotations.create({
data: {
quotation_number: body.quotation_number ? String(body.quotation_number) : null,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
valid_until: body.valid_until ? new Date(String(body.valid_until)) : null,
currency: body.currency ? String(body.currency) : 'CZK',
language: body.language ? String(body.language) : 'cs',
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : 'active',
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description ? String(body.scope_description) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: quotation.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await prisma.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: quotation.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: quotation.id, description: `Vytvořena nabídka ${quotation.quotation_number}` });
return success(reply, { id: quotation.id }, 201, 'Nabídka byla vytvořena');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
if (existing.status === 'invalidated') return error(reply, 'Nelze upravit zneplatněnou nabídku', 400);
await prisma.quotations.update({
where: { id },
data: {
quotation_number: body.quotation_number !== undefined ? String(body.quotation_number) : undefined,
customer_id: body.customer_id !== undefined ? Number(body.customer_id) : undefined,
valid_until: body.valid_until !== undefined ? (body.valid_until ? new Date(String(body.valid_until)) : null) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
language: body.language !== undefined ? String(body.language) : undefined,
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
apply_vat: body.apply_vat !== undefined ? (body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1') : undefined,
exchange_rate: body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
status: body.status !== undefined ? String(body.status) : undefined,
project_code: body.project_code !== undefined ? (body.project_code ? String(body.project_code) : null) : undefined,
scope_title: body.scope_title !== undefined ? (body.scope_title ? String(body.scope_title) : null) : undefined,
scope_description: body.scope_description !== undefined ? (body.scope_description ? String(body.scope_description) : null) : undefined,
modified_at: new Date(),
},
});
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
await prisma.$transaction(async (tx) => {
if (Array.isArray(body.items)) {
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
await tx.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.scope_sections.deleteMany({ where: { quotation_id: id } });
await tx.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
});
}
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Upravena nabídka ${existing.quotation_number}` });
return success(reply, { id }, 200, 'Nabídka byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
await prisma.quotations.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'quotation', entityId: id, description: `Smazána nabídka ${existing.quotation_number}` });
return success(reply, null, 200, 'Nabídka smazána');
});
}

View File

@@ -0,0 +1,284 @@
import { FastifyInstance } from 'fastify';
import multipart from '@fastify/multipart';
import { received_invoices_status } from '@prisma/client';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const VALID_STATUSES = ['unpaid', 'paid'] as const;
const ALLOWED_SORT_FIELDS = ['id', 'supplier_name', 'amount', 'issue_date', 'due_date', 'status', 'created_at'];
export default async function receivedInvoicesRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.year) where.year = Number(query.year);
if (query.month) where.month = Number(query.month);
if (query.status) where.status = String(query.status);
if (query.supplier_name) where.supplier_name = { contains: String(query.supplier_name) };
// Search across supplier_name, invoice_number, description
if (query.search) {
const search = String(query.search);
where.OR = [
{ supplier_name: { contains: search } },
{ invoice_number: { contains: search } },
{ description: { contains: search } },
];
}
// Sort field whitelisting
const sortField = query.sort && ALLOWED_SORT_FIELDS.includes(String(query.sort)) ? String(query.sort) : 'id';
const [invoices, total] = await Promise.all([
prisma.received_invoices.findMany({ where, skip, take: limit, orderBy: { [sortField]: order } }),
prisma.received_invoices.count({ where }),
]);
return reply.send({ success: true, data: invoices, pagination: buildPaginationMeta(total, page, limit) });
});
// GET /api/admin/received-invoices/stats
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const now = new Date();
const year = Number(query.year) || now.getFullYear();
const month = Number(query.month) || (now.getMonth() + 1);
const where: Record<string, unknown> = { year, month };
const monthInvoices = await prisma.received_invoices.findMany({ where });
// Aggregate by currency → CurrencyAmount[] format
const aggregateByCurrency = (invs: typeof monthInvoices, field: 'amount' | 'vat_amount') => {
const map: Record<string, number> = {};
for (const inv of invs) {
const cur = inv.currency || 'CZK';
map[cur] = (map[cur] || 0) + (Number(inv[field]) || 0);
}
return Object.entries(map).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
};
const sumCzk = (invs: typeof monthInvoices, field: 'amount' | 'vat_amount') => {
let total = 0;
for (const inv of invs) total += Number(inv[field]) || 0;
return Math.round(total * 100) / 100;
};
// Also get all-time unpaid
const allUnpaid = await prisma.received_invoices.findMany({ where: { status: { not: 'paid' } } });
return success(reply, {
total_month: aggregateByCurrency(monthInvoices, 'amount'),
total_month_czk: sumCzk(monthInvoices, 'amount'),
vat_month: aggregateByCurrency(monthInvoices, 'vat_amount'),
vat_month_czk: sumCzk(monthInvoices, 'vat_amount'),
unpaid: aggregateByCurrency(allUnpaid, 'amount'),
unpaid_czk: sumCzk(allUnpaid, 'amount'),
unpaid_count: allUnpaid.length,
month_count: monthInvoices.length,
});
});
// GET /api/admin/received-invoices/:id/file
fastify.get<{ Params: { id: string } }>('/:id/file', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.received_invoices.findUnique({
where: { id },
select: { file_data: true, file_name: true, file_mime: true },
});
if (!invoice?.file_data) return error(reply, 'Soubor nenalezen', 404);
const mime = invoice.file_mime || 'application/pdf';
const filename = invoice.file_name || `received-invoice-${id}.pdf`;
return reply
.type(mime)
.header('Content-Disposition', `inline; filename="${filename}"`)
.send(Buffer.from(invoice.file_data));
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.received_invoices.findUnique({ where: { id } });
if (!invoice) return error(reply, 'Přijatá faktura nenalezena', 404);
// Don't send file_data in detail response (can be large)
const { file_data: _fileData, ...rest } = invoice;
return success(reply, rest);
});
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
const contentType = request.headers['content-type'] || '';
// Multipart upload: files[] + invoices JSON metadata
if (contentType.includes('multipart/form-data')) {
const parts = request.parts();
const files: Array<{ data: Buffer; name: string; mime: string; size: number }> = [];
let invoicesMeta: Array<Record<string, unknown>> = [];
for await (const part of parts) {
if (part.type === 'file') {
const buf = await part.toBuffer();
files.push({ data: buf, name: part.filename || 'file', mime: part.mimetype || 'application/octet-stream', size: buf.length });
} else if (part.fieldname === 'invoices') {
try { invoicesMeta = JSON.parse(part.value as string); } catch { /* ignore parse error */ }
}
}
if (files.length === 0) return error(reply, 'Vyberte alespoň jeden soubor', 400);
const now = new Date();
const createdIds: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const meta = invoicesMeta[i] || {};
const amount = Number(meta.amount ?? 0);
const vatRate = Number(meta.vat_rate ?? 21);
const vatAmount = Math.round(amount * vatRate) / 100;
const invoice = await prisma.received_invoices.create({
data: {
month: Number(meta.month) || (now.getMonth() + 1),
year: Number(meta.year) || now.getFullYear(),
supplier_name: meta.supplier_name ? String(meta.supplier_name) : file.name,
invoice_number: meta.invoice_number ? String(meta.invoice_number) : null,
description: meta.description ? String(meta.description) : null,
amount,
currency: meta.currency ? String(meta.currency) : 'CZK',
vat_rate: vatRate,
vat_amount: vatAmount,
issue_date: meta.issue_date ? new Date(String(meta.issue_date)) : null,
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
status: 'unpaid',
notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId,
file_data: Uint8Array.from(file.data),
file_name: file.name,
file_mime: file.mime,
file_size: file.size,
},
});
createdIds.push(invoice.id);
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: createdIds[0], description: `Nahráno ${createdIds.length} přijatých faktur` });
return success(reply, { ids: createdIds, count: createdIds.length }, 201, `Nahráno ${createdIds.length} faktur`);
}
// JSON body: single invoice creation (no file)
const body = request.body as Record<string, unknown>;
const status = body.status ? String(body.status) : 'unpaid';
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
return error(reply, 'Neplatný stav', 400);
}
const amount = Number(body.amount ?? 0);
const vatRate = Number(body.vat_rate ?? 21);
if (!body.supplier_name) return error(reply, 'Název dodavatele je povinný', 400);
const invoice = await prisma.received_invoices.create({
data: {
month: Number(body.month),
year: Number(body.year),
supplier_name: String(body.supplier_name),
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
description: body.description ? String(body.description) : null,
amount,
currency: body.currency ? String(body.currency) : 'CZK',
vat_rate: vatRate,
vat_amount: Number(body.vat_amount ?? 0),
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
status: status as received_invoices_status,
notes: body.notes ? String(body.notes) : null,
uploaded_by: request.authData?.userId,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena přijatá faktura od ${invoice.supplier_name}` });
return success(reply, { id: invoice.id }, 201, 'Faktura byla vytvořena');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.received_invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Přijatá faktura nenalezena', 404);
if (body.status !== undefined) {
const status = String(body.status);
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
return error(reply, 'Neplatný stav', 400);
}
// Prevent reverting paid status (matching PHP)
if (String(existing.status) === 'paid' && status !== 'paid') {
return error(reply, 'Nelze vrátit stav uhrazené faktury', 400);
}
}
// Recalculate vat_amount when amount or vat_rate changes (matching PHP)
const finalAmount = body.amount !== undefined ? Number(body.amount) : Number(existing.amount);
const finalVatRate = body.vat_rate !== undefined ? Number(body.vat_rate) : Number(existing.vat_rate);
const computedVat = Math.round(finalAmount * finalVatRate) / 100;
// Auto-set paid_date when status transitions to paid (matching PHP)
const newStatus = body.status !== undefined ? String(body.status) : String(existing.status);
const paidDate = newStatus === 'paid' && String(existing.status) !== 'paid'
? new Date()
: (body.paid_date !== undefined ? (body.paid_date ? new Date(String(body.paid_date)) : null) : undefined);
// Auto-update month/year from issue_date if issue_date changes (matching PHP)
let autoMonth = body.month !== undefined ? Number(body.month) : undefined;
let autoYear = body.year !== undefined ? Number(body.year) : undefined;
if (body.issue_date && !body.month && !body.year) {
const issueDate = new Date(String(body.issue_date));
if (!isNaN(issueDate.getTime())) {
autoMonth = issueDate.getMonth() + 1;
autoYear = issueDate.getFullYear();
}
}
await prisma.received_invoices.update({
where: { id },
data: {
supplier_name: body.supplier_name !== undefined ? String(body.supplier_name) : undefined,
invoice_number: body.invoice_number !== undefined ? (body.invoice_number ? String(body.invoice_number) : null) : undefined,
description: body.description !== undefined ? (body.description ? String(body.description) : null) : undefined,
amount: body.amount !== undefined ? Number(body.amount) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
vat_amount: (body.amount !== undefined || body.vat_rate !== undefined) ? computedVat : (body.vat_amount !== undefined ? Number(body.vat_amount) : undefined),
issue_date: body.issue_date !== undefined ? (body.issue_date ? new Date(String(body.issue_date)) : null) : undefined,
due_date: body.due_date !== undefined ? (body.due_date ? new Date(String(body.due_date)) : null) : undefined,
paid_date: paidDate,
status: body.status !== undefined ? String(body.status) as received_invoices_status : undefined,
notes: body.notes !== undefined ? (body.notes ? String(body.notes) : null) : undefined,
month: autoMonth,
year: autoYear,
modified_at: new Date(),
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena přijatá faktura` });
return success(reply, { id }, 200, 'Faktura byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.received_invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Přijatá faktura nenalezena', 404);
await prisma.received_invoices.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána přijatá faktura` });
return success(reply, null, 200, 'Přijatá faktura smazána');
});
}

130
src/routes/admin/roles.ts Normal file
View File

@@ -0,0 +1,130 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
export default async function rolesRoutes(fastify: FastifyInstance): Promise<void> {
// GET /api/admin/roles
fastify.get('/', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const roles = await prisma.roles.findMany({
include: {
role_permissions: {
include: { permissions: true },
},
},
orderBy: { id: 'asc' },
});
const data = roles.map(r => ({
...r,
permissions: r.role_permissions.map(rp => rp.permissions),
}));
return success(reply, data);
});
// GET /api/admin/roles/permissions
fastify.get('/permissions', { preHandler: requirePermission('settings.roles') }, async (_request, reply) => {
const permissions = await prisma.permissions.findMany({ orderBy: { module: 'asc' } });
return success(reply, permissions);
});
// POST /api/admin/roles
fastify.post('/', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const role = await prisma.roles.create({
data: {
name: String(body.name),
display_name: String(body.display_name),
description: body.description ? String(body.description) : null,
},
});
if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({
role_id: role.id,
permission_id: pid,
})),
});
}
await logAudit({
request,
authData: request.authData,
action: 'create',
entityType: 'role',
entityId: role.id,
description: `Vytvořena role ${role.name}`,
});
return success(reply, { id: role.id }, 201, 'Role byla vytvořena');
});
// PUT /api/admin/roles/:id
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.roles.findUnique({ where: { id } });
if (!existing) return error(reply, 'Role nenalezena', 404);
await prisma.roles.update({
where: { id },
data: {
display_name: body.display_name ? String(body.display_name) : undefined,
description: body.description !== undefined ? String(body.description) : undefined,
},
});
if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.deleteMany({ where: { role_id: id } });
await prisma.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({
role_id: id,
permission_id: pid,
})),
});
}
await logAudit({
request,
authData: request.authData,
action: 'update',
entityType: 'role',
entityId: id,
description: `Upravena role ${existing.name}`,
});
return success(reply, { id }, 200, 'Role byla aktualizována');
});
// DELETE /api/admin/roles/:id
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.roles.findUnique({ where: { id } });
if (!existing) return error(reply, 'Role nenalezena', 404);
if (existing.name === 'admin') {
return error(reply, 'Nelze smazat roli admin', 400);
}
await prisma.roles.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,
action: 'delete',
entityType: 'role',
entityId: id,
description: `Smazána role ${existing.name}`,
});
return success(reply, { id }, 200, 'Role byla smazána');
});
}

View File

@@ -0,0 +1,148 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { success, error, parseId } from '../../utils/response';
interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
export default async function scopeTemplatesRoutes(fastify: FastifyInstance): Promise<void> {
// Legacy ?action= dispatcher for item templates
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const action = query.action ? String(query.action) : null;
// Item templates
if (action === 'items') {
const items = await prisma.item_templates.findMany({
where: { is_deleted: false },
orderBy: { name: 'asc' },
});
return success(reply, items);
}
// Default: scope templates
const templates = await prisma.scope_templates.findMany({
where: { is_deleted: false },
include: { scope_template_sections: { where: { is_deleted: false }, orderBy: { position: 'asc' } } },
orderBy: { name: 'asc' },
});
return success(reply, templates);
});
// Item template CRUD via ?action=item
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const body = request.body as Record<string, unknown>;
if (String(query.action) === 'item') {
const itemData = {
name: body.name ? String(body.name) : null,
description: body.description ? String(body.description) : null,
default_price: body.default_price != null ? Number(body.default_price) : 0,
category: body.category ? String(body.category) : null,
};
// Update existing item if id is provided
if (body.id) {
const existingItem = await prisma.item_templates.findUnique({ where: { id: Number(body.id) } });
if (!existingItem) return error(reply, 'Šablona nenalezena', 404);
await prisma.item_templates.update({
where: { id: Number(body.id) },
data: { ...itemData, modified_at: new Date() },
});
return success(reply, { id: Number(body.id) }, 200, 'Položka byla uložena');
}
const item = await prisma.item_templates.create({ data: itemData });
return success(reply, { id: item.id }, 201, 'Položka byla vytvořena');
}
// Scope template create (original logic below)
const template = await prisma.scope_templates.create({
data: {
name: body.name ? String(body.name) : null,
title: body.title ? String(body.title) : null,
description: body.description ? String(body.description) : null,
},
});
if (Array.isArray(body.sections)) {
await prisma.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: template.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return success(reply, { id: template.id }, 201, 'Šablona byla vytvořena');
});
// Item template delete via DELETE ?action=item&id=X
fastify.delete('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
if (String(query.action) === 'item' && query.id) {
const id = Number(query.id);
await prisma.item_templates.update({ where: { id }, data: { is_deleted: true, modified_at: new Date() } });
return success(reply, null, 200, 'Šablona smazána');
}
return error(reply, 'Neplatná akce', 400);
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const template = await prisma.scope_templates.findUnique({
where: { id },
include: { scope_template_sections: { where: { is_deleted: false }, orderBy: { position: 'asc' } } },
});
if (!template || template.is_deleted) return error(reply, 'Šablona nenalezena', 404);
return success(reply, template);
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.scope_templates.findUnique({ where: { id } });
if (!existing) return error(reply, 'Šablona nenalezena', 404);
await prisma.scope_templates.update({
where: { id },
data: {
name: body.name !== undefined ? String(body.name) : undefined,
title: body.title !== undefined ? String(body.title) : undefined,
description: body.description !== undefined ? String(body.description) : undefined,
modified_at: new Date(),
},
});
if (Array.isArray(body.sections)) {
await prisma.scope_template_sections.deleteMany({ where: { scope_template_id: id } });
await prisma.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return success(reply, { id }, 200, 'Šablona byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.scope_templates.update({ where: { id }, data: { is_deleted: true, modified_at: new Date() } });
return success(reply, null, 200, 'Šablona smazána');
});
}

View File

@@ -0,0 +1,106 @@
import { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import prisma from '../../config/database';
import { requireAuth } from '../../middleware/auth';
import { success, error } from '../../utils/response';
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
/** Parse user-agent string into browser, OS, and device icon */
function parseUserAgent(ua: string | null): { browser: string; os: string; icon: string } {
if (!ua) return { browser: 'Neznámý prohlížeč', os: 'Neznámý systém', icon: 'monitor' };
// Browser detection
let browser = 'Neznámý prohlížeč';
if (ua.includes('Edg/')) browser = 'Edge';
else if (ua.includes('OPR/') || ua.includes('Opera')) browser = 'Opera';
else if (ua.includes('Chrome/')) browser = 'Chrome';
else if (ua.includes('Safari/') && !ua.includes('Chrome')) browser = 'Safari';
else if (ua.includes('Firefox/')) browser = 'Firefox';
// OS detection
let os = 'Neznámý systém';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
else if (ua.includes('Linux') && !ua.includes('Android')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
// Device icon
let icon = 'monitor';
if (ua.includes('Mobile') || ua.includes('iPhone') || ua.includes('Android')) {
icon = ua.includes('iPad') || ua.includes('Tablet') ? 'tablet' : 'smartphone';
}
return { browser, os, icon };
}
export default async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
// GET /api/admin/sessions — list active sessions for current user
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const currentToken = request.cookies?.refresh_token;
const currentHash = currentToken ? hashToken(currentToken) : null;
const sessions = await prisma.refresh_tokens.findMany({
where: { user_id: authData.userId, replaced_at: null, expires_at: { gt: new Date() } },
orderBy: { created_at: 'desc' },
});
const enriched = sessions.map(s => {
const device_info = parseUserAgent(s.user_agent);
return {
id: s.id,
is_current: currentHash ? s.token_hash === currentHash : false,
device_info,
ip_address: s.ip_address || '',
created_at: s.created_at ? s.created_at.toISOString() : '',
};
});
return success(reply, enriched);
});
// DELETE /api/admin/sessions/:id — delete specific session
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
const authData = request.authData!;
const session = await prisma.refresh_tokens.findFirst({
where: { id, user_id: authData.userId },
});
if (!session) return error(reply, 'Relace nenalezena', 404);
await prisma.refresh_tokens.update({
where: { id },
data: { replaced_at: new Date() },
});
return success(reply, null, 200, 'Relace ukončena');
});
// DELETE /api/admin/sessions — delete all sessions except current
fastify.delete('/', { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const query = request.query as Record<string, unknown>;
if (query.action === 'all') {
// Get current token from cookie to exclude (hash it to match stored token_hash)
const currentToken = request.cookies?.refresh_token;
const currentHash = currentToken ? hashToken(currentToken) : null;
await prisma.refresh_tokens.updateMany({
where: {
user_id: authData.userId,
replaced_at: null,
...(currentHash ? { token_hash: { not: currentHash } } : {}),
},
data: { replaced_at: new Date() },
});
return success(reply, null, 200, 'Všechny ostatní relace ukončeny');
}
return error(reply, 'Neplatná akce', 400);
});
}

237
src/routes/admin/totp.ts Normal file
View File

@@ -0,0 +1,237 @@
import { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { success, error } from '../../utils/response';
import { encrypt } from '../../utils/encryption';
import { OTPAuth } from '../../utils/totp';
import * as OTPAuthLib from 'otpauth';
import { logAudit } from '../../services/audit';
export default async function totpRoutes(fastify: FastifyInstance): Promise<void> {
// GET - generate new TOTP secret
fastify.get('/setup', { preHandler: requireAuth }, async (request, reply) => {
const secret = new OTPAuthLib.Secret();
const totp = new OTPAuthLib.TOTP({
issuer: 'BOHA Automation',
label: request.authData!.email,
secret,
algorithm: 'SHA1',
digits: 6,
period: 30,
});
return success(reply, {
secret: secret.base32,
uri: totp.toString(),
});
});
// POST - enable TOTP
fastify.post('/enable', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const { secret, code } = body;
if (!secret || !code) {
return error(reply, 'Secret a kód jsou povinné', 400);
}
// Verify the code first
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: String(code), window: 1 });
if (delta === null) {
return error(reply, 'Neplatný TOTP kód', 400);
}
// Generate 8 backup codes
const backupCodesPlain: string[] = [];
const backupCodesHashed: string[] = [];
for (let i = 0; i < 8; i++) {
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
backupCodesPlain.push(code);
backupCodesHashed.push(bcrypt.hashSync(code, 10));
}
// Encrypt and store
const encryptedSecret = encrypt(String(secret));
await prisma.users.update({
where: { id: request.authData!.userId },
data: {
totp_secret: encryptedSecret,
totp_enabled: true,
totp_backup_codes: JSON.stringify(backupCodesHashed),
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'user', entityId: request.authData!.userId, description: '2FA aktivováno' });
return success(reply, { backup_codes: backupCodesPlain }, 200, '2FA aktivováno');
});
// PUT - disable TOTP
fastify.put('/disable', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
if (!body.code) {
return error(reply, 'TOTP kód je povinný pro deaktivaci', 400);
}
const user = await prisma.users.findUnique({ where: { id: request.authData!.userId } });
if (!user?.totp_secret) {
return error(reply, '2FA není aktivní', 400);
}
const isValid = OTPAuth.verify(user.totp_secret, String(body.code));
if (!isValid) {
return error(reply, 'Neplatný TOTP kód', 400);
}
await prisma.users.update({
where: { id: request.authData!.userId },
data: { totp_secret: null, totp_enabled: false, totp_backup_codes: null },
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'user', entityId: request.authData!.userId, description: '2FA deaktivováno' });
return success(reply, null, 200, '2FA deaktivováno');
});
// GET - TOTP status for current user
fastify.get('/status', { preHandler: requireAuth }, async (request, reply) => {
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
select: { totp_enabled: true },
});
return success(reply, { totp_enabled: user?.totp_enabled ?? false });
});
// GET - check if 2FA is required company-wide
fastify.get('/required', { preHandler: [requireAuth, requirePermission('settings.security')] }, async (request, reply) => {
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
return success(reply, { require_2fa: settings?.require_2fa ?? false });
});
// POST - toggle mandatory 2FA
fastify.post('/required', { preHandler: [requireAuth, requirePermission('settings.security')] }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const required = body.required === true || body.required === 1 || body.required === '1';
await prisma.company_settings.updateMany({
data: { require_2fa: required },
});
const message = required
? '2FA je nyní povinné pro všechny uživatele'
: '2FA již není povinné';
return success(reply, null, 200, message);
});
// POST - verify backup code (pre-auth, no requireAuth)
fastify.post('/backup-verify', async (request, reply) => {
const body = request.body as Record<string, unknown>;
const { login_token, code } = body;
if (!login_token || !code) {
return error(reply, 'Login token a záložní kód jsou povinné', 400);
}
const tokenHash = crypto.createHash('sha256').update(String(login_token)).digest('hex');
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, 'Neplatný nebo expirovaný login token', 401);
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_backup_codes) {
return error(reply, 'Uživatel nenalezen', 401);
}
const backupCodes: string[] = JSON.parse(user.totp_backup_codes as string);
let matchIndex = -1;
for (let i = 0; i < backupCodes.length; i++) {
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break;
}
}
if (matchIndex === -1) {
return error(reply, 'Neplatný záložní kód', 401);
}
// Remove used backup code
backupCodes.splice(matchIndex, 1);
await prisma.users.update({
where: { id: user.id },
data: {
totp_backup_codes: JSON.stringify(backupCodes),
failed_login_attempts: 0,
locked_until: null,
last_login: new Date(),
},
});
// Delete used login token
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
// Create tokens (same as /login/totp flow)
const { loadAuthData } = await import('../../services/auth');
const authData = await loadAuthData(user.id);
if (!authData) {
return error(reply, 'Chyba načítání uživatele', 500);
}
const jwt = await import('jsonwebtoken');
const { config } = await import('../../config/env');
const accessToken = jwt.default.sign(
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
await prisma.refresh_tokens.create({
data: {
user_id: user.id,
token_hash: refreshTokenHash,
expires_at: new Date(Date.now() + config.jwt.refreshTokenSessionExpiry * 1000),
remember_me: false,
ip_address: request.ip,
user_agent: request.headers['user-agent'] ?? null,
},
});
reply.setCookie('refresh_token', refreshTokenRaw, {
httpOnly: true,
secure: config.isProduction,
sameSite: 'strict',
path: '/api/admin',
maxAge: config.jwt.refreshTokenSessionExpiry,
});
return success(reply, { access_token: accessToken, user: authData });
});
}

Some files were not shown because too many files have changed in this diff Show More