initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
src/App.tsx
Normal file
27
src/App.tsx
Normal 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
96
src/admin/AdminApp.tsx
Normal 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
2860
src/admin/admin.css
Normal file
File diff suppressed because it is too large
Load Diff
434
src/admin/attendance.css
Normal file
434
src/admin/attendance.css
Normal 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;
|
||||
}
|
||||
185
src/admin/components/AdminDatePicker.tsx
Normal file
185
src/admin/components/AdminDatePicker.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
107
src/admin/components/AdminLayout.tsx
Normal file
107
src/admin/components/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/admin/components/AlertContainer.tsx
Normal file
67
src/admin/components/AlertContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
181
src/admin/components/AttendanceShiftTable.tsx
Normal file
181
src/admin/components/AttendanceShiftTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
src/admin/components/BulkAttendanceModal.tsx
Normal file
192
src/admin/components/BulkAttendanceModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/admin/components/ConfirmModal.tsx
Normal file
52
src/admin/components/ConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/admin/components/ErrorBoundary.tsx
Normal file
29
src/admin/components/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
11
src/admin/components/Forbidden.tsx
Normal file
11
src/admin/components/Forbidden.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/admin/components/FormField.tsx
Normal file
22
src/admin/components/FormField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/admin/components/Pagination.tsx
Normal file
62
src/admin/components/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
src/admin/components/RichEditor.tsx
Normal file
105
src/admin/components/RichEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
521
src/admin/components/ShiftFormModal.tsx
Normal file
521
src/admin/components/ShiftFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
src/admin/components/ShortcutsHelp.tsx
Normal file
3
src/admin/components/ShortcutsHelp.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function ShortcutsHelp() {
|
||||
return null
|
||||
}
|
||||
419
src/admin/components/Sidebar.tsx
Normal file
419
src/admin/components/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
20
src/admin/components/SortIcon.tsx
Normal file
20
src/admin/components/SortIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
src/admin/components/dashboard/DashActivityFeed.tsx
Normal file
80
src/admin/components/dashboard/DashActivityFeed.tsx
Normal 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 →</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>
|
||||
)
|
||||
}
|
||||
50
src/admin/components/dashboard/DashAttendanceToday.tsx
Normal file
50
src/admin/components/dashboard/DashAttendanceToday.tsx
Normal 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 →</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>
|
||||
)
|
||||
}
|
||||
130
src/admin/components/dashboard/DashKpiCards.tsx
Normal file
130
src/admin/components/dashboard/DashKpiCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
344
src/admin/components/dashboard/DashProfile.tsx
Normal file
344
src/admin/components/dashboard/DashProfile.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
378
src/admin/components/dashboard/DashQuickActions.tsx
Normal file
378
src/admin/components/dashboard/DashQuickActions.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
218
src/admin/components/dashboard/DashSessions.tsx
Normal file
218
src/admin/components/dashboard/DashSessions.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
src/admin/context/AlertContext.tsx
Normal file
71
src/admin/context/AlertContext.tsx
Normal 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
|
||||
}
|
||||
306
src/admin/context/AuthContext.tsx
Normal file
306
src/admin/context/AuthContext.tsx
Normal 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
544
src/admin/dashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
45
src/admin/hooks/useApiCall.ts
Normal file
45
src/admin/hooks/useApiCall.ts
Normal 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 }
|
||||
}
|
||||
766
src/admin/hooks/useAttendanceAdmin.ts
Normal file
766
src/admin/hooks/useAttendanceAdmin.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
14
src/admin/hooks/useDebounce.ts
Normal file
14
src/admin/hooks/useDebounce.ts
Normal 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
|
||||
}
|
||||
91
src/admin/hooks/useListData.ts
Normal file
91
src/admin/hooks/useListData.ts
Normal 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 }
|
||||
}
|
||||
14
src/admin/hooks/useModalLock.ts
Normal file
14
src/admin/hooks/useModalLock.ts
Normal 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])
|
||||
}
|
||||
19
src/admin/hooks/useTableSort.ts
Normal file
19
src/admin/hooks/useTableSort.ts
Normal 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
141
src/admin/invoices.css
Normal 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
143
src/admin/login.css
Normal 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
775
src/admin/offers.css
Normal 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;
|
||||
}
|
||||
929
src/admin/pages/Attendance.tsx
Normal file
929
src/admin/pages/Attendance.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
341
src/admin/pages/AttendanceAdmin.tsx
Normal file
341
src/admin/pages/AttendanceAdmin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
728
src/admin/pages/AttendanceBalances.tsx
Normal file
728
src/admin/pages/AttendanceBalances.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
324
src/admin/pages/AttendanceCreate.tsx
Normal file
324
src/admin/pages/AttendanceCreate.tsx
Normal 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">
|
||||
← 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>
|
||||
)
|
||||
}
|
||||
586
src/admin/pages/AttendanceHistory.tsx
Normal file
586
src/admin/pages/AttendanceHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
335
src/admin/pages/AttendanceLocation.tsx
Normal file
335
src/admin/pages/AttendanceLocation.tsx
Normal 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: '© 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">
|
||||
← 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>
|
||||
)
|
||||
}
|
||||
437
src/admin/pages/AuditLog.tsx
Normal file
437
src/admin/pages/AuditLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
788
src/admin/pages/CompanySettings.tsx
Normal file
788
src/admin/pages/CompanySettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
378
src/admin/pages/Dashboard.tsx
Normal file
378
src/admin/pages/Dashboard.tsx
Normal 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 →</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 →</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>
|
||||
)
|
||||
}
|
||||
597
src/admin/pages/InvoiceCreate.tsx
Normal file
597
src/admin/pages/InvoiceCreate.tsx
Normal 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 (Kč)</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>
|
||||
)
|
||||
}
|
||||
598
src/admin/pages/InvoiceDetail.tsx
Normal file
598
src/admin/pages/InvoiceDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
651
src/admin/pages/Invoices.tsx
Normal file
651
src/admin/pages/Invoices.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
503
src/admin/pages/LeaveApproval.tsx
Normal file
503
src/admin/pages/LeaveApproval.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
258
src/admin/pages/LeaveRequests.tsx
Normal file
258
src/admin/pages/LeaveRequests.tsx
Normal 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
321
src/admin/pages/Login.tsx
Normal 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 mě</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' }}
|
||||
>
|
||||
← Zpět na přihlášení
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
30
src/admin/pages/NotFound.tsx
Normal file
30
src/admin/pages/NotFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1140
src/admin/pages/OfferDetail.tsx
Normal file
1140
src/admin/pages/OfferDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
656
src/admin/pages/Offers.tsx
Normal file
656
src/admin/pages/Offers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
664
src/admin/pages/OffersCustomers.tsx
Normal file
664
src/admin/pages/OffersCustomers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
627
src/admin/pages/OffersTemplates.tsx
Normal file
627
src/admin/pages/OffersTemplates.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
671
src/admin/pages/OrderDetail.tsx
Normal file
671
src/admin/pages/OrderDetail.tsx
Normal 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 "{order.order_number}"? 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
290
src/admin/pages/Orders.tsx
Normal 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 "{deleteConfirm.order?.order_number}"? 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>
|
||||
)
|
||||
}
|
||||
318
src/admin/pages/ProjectCreate.tsx
Normal file
318
src/admin/pages/ProjectCreate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
645
src/admin/pages/ProjectDetail.tsx
Normal file
645
src/admin/pages/ProjectDetail.tsx
Normal 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 "{project.project_number} – {project.name}"? 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>
|
||||
)
|
||||
}
|
||||
287
src/admin/pages/Projects.tsx
Normal file
287
src/admin/pages/Projects.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
986
src/admin/pages/ReceivedInvoices.tsx
Normal file
986
src/admin/pages/ReceivedInvoices.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
643
src/admin/pages/Settings.tsx
Normal file
643
src/admin/pages/Settings.tsx
Normal 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 má 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
653
src/admin/pages/Trips.tsx
Normal 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} → {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>
|
||||
)
|
||||
}
|
||||
831
src/admin/pages/TripsAdmin.tsx
Normal file
831
src/admin/pages/TripsAdmin.tsx
Normal 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} → {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} → {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>
|
||||
)
|
||||
}
|
||||
273
src/admin/pages/TripsHistory.tsx
Normal file
273
src/admin/pages/TripsHistory.tsx
Normal 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} → {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
495
src/admin/pages/Users.tsx
Normal 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 účtů a oprávnění</p>
|
||||
</div>
|
||||
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Přidat uživatele
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 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>
|
||||
)
|
||||
}
|
||||
470
src/admin/pages/Vehicles.tsx
Normal file
470
src/admin/pages/Vehicles.tsx
Normal 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
64
src/admin/settings.css
Normal 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
102
src/admin/utils/api.ts
Normal 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
|
||||
151
src/admin/utils/attendanceHelpers.ts
Normal file
151
src/admin/utils/attendanceHelpers.ts
Normal 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))
|
||||
}
|
||||
79
src/admin/utils/dashboardHelpers.ts
Normal file
79
src/admin/utils/dashboardHelpers.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
26
src/admin/utils/formatters.ts
Normal file
26
src/admin/utils/formatters.ts
Normal 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
7
src/config/database.ts
Normal 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
51
src/config/env.ts
Normal 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;
|
||||
40
src/context/ThemeContext.tsx
Normal file
40
src/context/ThemeContext.tsx
Normal 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
15
src/main.tsx
Normal 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
51
src/middleware/auth.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
15
src/middleware/security.ts
Normal file
15
src/middleware/security.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
1146
src/routes/admin/attendance.ts
Normal file
1146
src/routes/admin/attendance.ts
Normal file
File diff suppressed because it is too large
Load Diff
53
src/routes/admin/audit-log.ts
Normal file
53
src/routes/admin/audit-log.ts
Normal 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
188
src/routes/admin/auth.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
68
src/routes/admin/bank-accounts.ts
Normal file
68
src/routes/admin/bank-accounts.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
179
src/routes/admin/company-settings.ts
Normal file
179
src/routes/admin/company-settings.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
141
src/routes/admin/customers.ts
Normal file
141
src/routes/admin/customers.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
252
src/routes/admin/dashboard.ts
Normal file
252
src/routes/admin/dashboard.ts
Normal 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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
266
src/routes/admin/invoices-pdf.ts
Normal file
266
src/routes/admin/invoices-pdf.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
373
src/routes/admin/invoices.ts
Normal file
373
src/routes/admin/invoices.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
238
src/routes/admin/leave-requests.ts
Normal file
238
src/routes/admin/leave-requests.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
721
src/routes/admin/offers-pdf.ts
Normal file
721
src/routes/admin/offers-pdf.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 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 with regular space (outside of tags)
|
||||
s = s.replace(/( )/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
526
src/routes/admin/orders.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
53
src/routes/admin/profile.ts
Normal file
53
src/routes/admin/profile.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
166
src/routes/admin/projects.ts
Normal file
166
src/routes/admin/projects.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
326
src/routes/admin/quotations.ts
Normal file
326
src/routes/admin/quotations.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
284
src/routes/admin/received-invoices.ts
Normal file
284
src/routes/admin/received-invoices.ts
Normal 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
130
src/routes/admin/roles.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
148
src/routes/admin/scope-templates.ts
Normal file
148
src/routes/admin/scope-templates.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
106
src/routes/admin/sessions.ts
Normal file
106
src/routes/admin/sessions.ts
Normal 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
237
src/routes/admin/totp.ts
Normal 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
Reference in New Issue
Block a user