initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user