refactor: P3 dekompozice velkych komponent
Dashboard.jsx (1346 -> 378 LOC): - DashKpiCards, DashQuickActions, DashActivityFeed, DashAttendanceToday, DashProfile, DashSessions - dashboardHelpers.js (konstanty + helper funkce) OfferDetail.jsx (1061 -> ~530 LOC): - useOfferForm hook (form state, draft, items/sections, submit) - OfferCustomerPicker (customer search/select dropdown) AttendanceAdmin.jsx (1036 -> ~275 LOC): - useAttendanceAdmin hook (data fetching, filters, CRUD, print) - AttendanceShiftTable (shift records table) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
146
src/admin/components/AttendanceShiftTable.jsx
Normal file
146
src/admin/components/AttendanceShiftTable.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
formatDate, formatDatetime, formatTime,
|
||||||
|
calculateWorkMinutes, formatMinutes,
|
||||||
|
getLeaveTypeName, getLeaveTypeBadgeClass
|
||||||
|
} from '../utils/attendanceHelpers'
|
||||||
|
|
||||||
|
function formatBreak(record) {
|
||||||
|
if (record.break_start && record.break_end) {
|
||||||
|
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
|
||||||
|
}
|
||||||
|
if (record.break_start) {
|
||||||
|
return `${formatTime(record.break_start)} - ?`
|
||||||
|
}
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectCell(record) {
|
||||||
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
|
||||||
|
{record.project_logs.map((log, i) => {
|
||||||
|
let h, m, isActive = false
|
||||||
|
if (log.hours !== null && log.hours !== undefined) {
|
||||||
|
h = parseInt(log.hours) || 0
|
||||||
|
m = parseInt(log.minutes) || 0
|
||||||
|
} else {
|
||||||
|
isActive = !log.ended_at
|
||||||
|
const end = log.ended_at ? new Date(log.ended_at) : new Date()
|
||||||
|
const mins = Math.floor((end - new Date(log.started_at)) / 60000)
|
||||||
|
h = Math.floor(mins / 60)
|
||||||
|
m = mins % 60
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
|
||||||
|
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (record.project_name) {
|
||||||
|
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
|
||||||
|
}
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttendanceShiftTable({ records, onEdit, onDelete }) {
|
||||||
|
if (records.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="admin-empty-state">
|
||||||
|
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-table-responsive">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Zaměstnanec</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Příchod</th>
|
||||||
|
<th>Pauza</th>
|
||||||
|
<th>Odchod</th>
|
||||||
|
<th>Hodiny</th>
|
||||||
|
<th>Projekt</th>
|
||||||
|
<th>GPS</th>
|
||||||
|
<th>Poznámka</th>
|
||||||
|
<th>Akce</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.map((record) => {
|
||||||
|
const leaveType = record.leave_type || 'work'
|
||||||
|
const isLeave = leaveType !== 'work'
|
||||||
|
const workMinutes = isLeave
|
||||||
|
? (record.leave_hours || 8) * 60
|
||||||
|
: calculateWorkMinutes(record)
|
||||||
|
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={record.id}>
|
||||||
|
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
||||||
|
<td>{record.user_name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
|
||||||
|
{getLeaveTypeName(leaveType)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
|
||||||
|
<td className="admin-mono">
|
||||||
|
{isLeave ? '—' : formatBreak(record)}
|
||||||
|
</td>
|
||||||
|
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
|
||||||
|
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '—'}</td>
|
||||||
|
<td>
|
||||||
|
{renderProjectCell(record)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{hasLocation ? (
|
||||||
|
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
|
||||||
|
📍
|
||||||
|
</Link>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
|
||||||
|
{record.notes || ''}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="admin-table-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(record)}
|
||||||
|
className="admin-btn-icon"
|
||||||
|
title="Upravit"
|
||||||
|
aria-label="Upravit"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(record)}
|
||||||
|
className="admin-btn-icon danger"
|
||||||
|
title="Smazat"
|
||||||
|
aria-label="Smazat"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/admin/components/OfferCustomerPicker.jsx
Normal file
90
src/admin/components/OfferCustomerPicker.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
|
export default function OfferCustomerPicker({
|
||||||
|
customers,
|
||||||
|
customerId,
|
||||||
|
customerName,
|
||||||
|
onSelect,
|
||||||
|
onClear,
|
||||||
|
error,
|
||||||
|
readOnly
|
||||||
|
}) {
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('')
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => setShowDropdown(false)
|
||||||
|
if (showDropdown) {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [showDropdown])
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
const handleSelect = (customer) => {
|
||||||
|
onSelect(customer)
|
||||||
|
setCustomerSearch('')
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`admin-form-group${error ? ' has-error' : ''}`}>
|
||||||
|
<label className="admin-form-label required">Zákazník</label>
|
||||||
|
{customerId && (
|
||||||
|
<div className="offers-customer-selected">
|
||||||
|
<span>{customerName}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<button type="button" onClick={onClear} 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>
|
||||||
|
)}
|
||||||
|
{!customerId && !readOnly && (
|
||||||
|
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => { setCustomerSearch(e.target.value); setShowDropdown(true) }}
|
||||||
|
onFocus={() => setShowDropdown(true)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Hledat zákazníka..."
|
||||||
|
/>
|
||||||
|
{showDropdown && (
|
||||||
|
<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={() => handleSelect(c)}
|
||||||
|
>
|
||||||
|
<div>{c.name}</div>
|
||||||
|
{c.city && <div>{c.city}</div>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <span className="admin-form-error">{error}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/admin/components/dashboard/DashActivityFeed.jsx
Normal file
65
src/admin/components/dashboard/DashActivityFeed.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers'
|
||||||
|
|
||||||
|
function getActivityIcon(action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'create':
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'update':
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'delete':
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'login':
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashActivityFeed({ activities }) {
|
||||||
|
if (!activities) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-card dash-activity-card">
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h2 className="admin-card-title">Poslední aktivita</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||||
|
{activities.map((act) => (
|
||||||
|
<div key={act.id} className="dash-activity-row">
|
||||||
|
<div className={`dash-activity-icon ${getActivityIconClass(act.action)}`}>
|
||||||
|
{getActivityIcon(act.action)}
|
||||||
|
</div>
|
||||||
|
<div className="dash-activity-main">
|
||||||
|
<div className="dash-activity-text">{act.description}</div>
|
||||||
|
<div className="dash-activity-sub">{act.username || 'Systém'} · {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dash-activity-time admin-mono">{formatActivityTime(act.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/admin/components/dashboard/DashAttendanceToday.jsx
Normal file
33
src/admin/components/dashboard/DashAttendanceToday.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { LEAVE_TYPE_LABELS, STATUS_DOT_CLASS, STATUS_LABELS } from '../../utils/dashboardHelpers'
|
||||||
|
|
||||||
|
export default function DashAttendanceToday({ attendance }) {
|
||||||
|
if (!attendance) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-card dash-attendance-card">
|
||||||
|
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<h2 className="admin-card-title">Docházka dnes</h2>
|
||||||
|
<Link to="/attendance/admin" className="admin-btn admin-btn-primary admin-btn-sm">Detail →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||||
|
{attendance.users.map((u) => (
|
||||||
|
<div key={u.user_id} className="dash-presence-row">
|
||||||
|
<div className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}>
|
||||||
|
{u.initials || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="dash-presence-name">{u.name}</div>
|
||||||
|
<div className="dash-presence-end">
|
||||||
|
<span className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}>
|
||||||
|
{u.status === 'leave' ? (LEAVE_TYPE_LABELS[u.leave_type] || 'Nepřítomen') : STATUS_LABELS[u.status]}
|
||||||
|
</span>
|
||||||
|
{u.arrived_at && <span className="admin-mono dash-presence-time">{u.arrived_at}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/admin/components/dashboard/DashKpiCards.jsx
Normal file
91
src/admin/components/dashboard/DashKpiCards.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { formatCurrency } from '../../utils/formatters'
|
||||||
|
|
||||||
|
function buildKpiCards(dashData) {
|
||||||
|
const cards = []
|
||||||
|
if (dashData?.attendance) {
|
||||||
|
cards.push({
|
||||||
|
label: 'Přítomní dnes',
|
||||||
|
value: `${dashData.attendance.present_today}`,
|
||||||
|
sub: `/ ${dashData.attendance.total_active}`,
|
||||||
|
color: 'success',
|
||||||
|
footer: dashData.attendance.on_leave > 0 ? `${dashData.attendance.on_leave} nepřítomných` : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dashData?.offers) {
|
||||||
|
cards.push({
|
||||||
|
label: 'Otevřené nabídky',
|
||||||
|
value: `${dashData.offers.open_count}`,
|
||||||
|
color: 'info',
|
||||||
|
footer: dashData.offers.created_this_month > 0 ? `${dashData.offers.created_this_month} tento měsíc` : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dashData?.invoices) {
|
||||||
|
cards.push(buildInvoiceKpi(dashData.invoices))
|
||||||
|
}
|
||||||
|
if (dashData?.leave_pending) {
|
||||||
|
cards.push({
|
||||||
|
label: 'Žádosti o volno',
|
||||||
|
value: `${dashData.leave_pending.count}`,
|
||||||
|
color: 'danger',
|
||||||
|
footer: dashData.leave_pending.count > 0 ? 'čeká na schválení' : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInvoiceKpi(invoices) {
|
||||||
|
const rev = invoices.revenue_this_month || []
|
||||||
|
const hasForeign = rev.some(r => r.currency !== 'CZK')
|
||||||
|
const hasCzkTotal = hasForeign && invoices.revenue_czk !== null && invoices.revenue_czk !== undefined
|
||||||
|
const fallbackText = rev.length > 0
|
||||||
|
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
|
||||||
|
: '0 Kč'
|
||||||
|
const revenueText = hasCzkTotal
|
||||||
|
? formatCurrency(invoices.revenue_czk, 'CZK')
|
||||||
|
: fallbackText
|
||||||
|
const detailText = hasForeign && rev.length > 0
|
||||||
|
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
|
||||||
|
: null
|
||||||
|
const unpaidText = invoices.unpaid_count > 0
|
||||||
|
? `${invoices.unpaid_count} neuhrazených`
|
||||||
|
: null
|
||||||
|
const footerParts = [detailText, unpaidText].filter(Boolean)
|
||||||
|
return {
|
||||||
|
label: 'Tržby (měsíc)',
|
||||||
|
value: revenueText,
|
||||||
|
color: 'warning',
|
||||||
|
footer: footerParts.length > 0 ? footerParts.join(' · ') : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KPI_CLASS_MAP = { 4: 'dash-kpi-4', 3: 'dash-kpi-3', 2: 'dash-kpi-2', 1: 'dash-kpi-1' }
|
||||||
|
|
||||||
|
export default function DashKpiCards({ dashData }) {
|
||||||
|
const kpiCards = buildKpiCards(dashData)
|
||||||
|
if (kpiCards.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || 'dash-kpi-4'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`dash-kpi-grid ${kpiClass}`}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{kpiCards.map((kpi) => (
|
||||||
|
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
|
||||||
|
<div className="admin-stat-label">{kpi.label}</div>
|
||||||
|
<div className="admin-stat-value admin-mono">
|
||||||
|
{kpi.value}
|
||||||
|
{kpi.sub && <small className="text-muted" style={{ fontSize: '0.75em', fontWeight: 500, marginLeft: '0.25rem' }}>{kpi.sub}</small>}
|
||||||
|
</div>
|
||||||
|
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
310
src/admin/components/dashboard/DashProfile.jsx
Normal file
310
src/admin/components/dashboard/DashProfile.jsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { useAlert } from '../../context/AlertContext'
|
||||||
|
import useModalLock from '../../hooks/useModalLock'
|
||||||
|
import apiFetch from '../../utils/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
export default function DashProfile({
|
||||||
|
totpEnabled, totpLoading, totpSubmitting,
|
||||||
|
onStart2FASetup, onConfirm2FA, onDisable2FA,
|
||||||
|
totpSecret, totpQrUri, totpCode, setTotpCode,
|
||||||
|
backupCodes, setBackupCodes,
|
||||||
|
show2FASetup, setShow2FASetup,
|
||||||
|
show2FADisable, setShow2FADisable,
|
||||||
|
disableCode, setDisableCode,
|
||||||
|
}) {
|
||||||
|
const { user, updateUser } = useAuth()
|
||||||
|
const alert = useAlert()
|
||||||
|
const totpSetupRef = useRef(null)
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '', email: '', password: '', first_name: '', last_name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
useModalLock(showModal)
|
||||||
|
|
||||||
|
const openEditModal = () => {
|
||||||
|
const nameParts = (user?.fullName || '').split(' ')
|
||||||
|
setFormData({
|
||||||
|
username: user?.username || '',
|
||||||
|
email: user?.email || '',
|
||||||
|
password: '',
|
||||||
|
first_name: nameParts[0] || '',
|
||||||
|
last_name: nameParts.slice(1).join(' ') || ''
|
||||||
|
})
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
const dataToSave = { ...formData }
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/profile.php`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(dataToSave)
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
updateUser({
|
||||||
|
username: dataToSave.username,
|
||||||
|
email: dataToSave.email,
|
||||||
|
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
|
||||||
|
})
|
||||||
|
setShowModal(false)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
alert.success('Profil byl upraven')
|
||||||
|
} else {
|
||||||
|
alert.error(data.error || 'Nepodařilo se uložit profil')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotpStatusText() {
|
||||||
|
if (totpLoading) {
|
||||||
|
return 'Načítání...'
|
||||||
|
}
|
||||||
|
return totpEnabled ? 'Aktivní' : 'Neaktivní'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.25 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<h2 className="admin-card-title">Váš účet</h2>
|
||||||
|
<button onClick={openEditModal} className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
Upravit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body">
|
||||||
|
<div className="dash-profile-grid">
|
||||||
|
<div className="dash-profile-item">
|
||||||
|
<span className="dash-profile-label">Uživatel</span>
|
||||||
|
<span className="dash-profile-value">{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dash-profile-item">
|
||||||
|
<span className="dash-profile-label">E-mail</span>
|
||||||
|
<span className="dash-profile-value">{user?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dash-profile-item">
|
||||||
|
<span className="dash-profile-label">Jméno</span>
|
||||||
|
<span className="dash-profile-value">{user?.fullName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dash-profile-item">
|
||||||
|
<span className="dash-profile-label">Role</span>
|
||||||
|
<span className="dash-profile-value">{user?.roleDisplay || user?.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2FA Section */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border-color)', marginTop: '1rem', paddingTop: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: totpEnabled ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
|
||||||
|
color: totpEnabled ? 'var(--success)' : 'var(--text-secondary)'
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>Dvoufaktorové ověření (2FA)</div>
|
||||||
|
<div className={totpEnabled ? 'text-success' : 'text-secondary'} style={{ fontSize: '0.75rem' }}>
|
||||||
|
{getTotpStatusText()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!totpLoading && (
|
||||||
|
totpEnabled ? (
|
||||||
|
<button onClick={() => { setDisableCode(''); setShow2FADisable(true) }} className="admin-btn admin-btn-primary admin-btn-sm">
|
||||||
|
Deaktivovat
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={onStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary admin-btn-sm">
|
||||||
|
{totpSubmitting ? 'Generuji...' : 'Aktivovat'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Edit Profile Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
|
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
|
||||||
|
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
||||||
|
<div className="admin-modal-header"><h2 className="admin-modal-title">Upravit profil</h2></div>
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Jméno</label>
|
||||||
|
<input type="text" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" />
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Příjmení</label>
|
||||||
|
<input type="text" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Uživatelské jméno</label>
|
||||||
|
<input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" />
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">E-mail</label>
|
||||||
|
<input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" />
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
|
||||||
|
<input type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} className="admin-form-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary">Zrušit</button>
|
||||||
|
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">Uložit změny</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 2FA Setup Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{show2FASetup && (
|
||||||
|
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
|
<div className="admin-modal-backdrop" onClick={() => { if (!backupCodes) { setShow2FASetup(false) } }} />
|
||||||
|
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
||||||
|
<div className="admin-modal-header">
|
||||||
|
<h2 className="admin-modal-title">{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
{backupCodes ? (
|
||||||
|
<div>
|
||||||
|
<div className="admin-role-locked-notice" style={{ marginBottom: '1rem' }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
|
||||||
|
{backupCodes.map((code) => (
|
||||||
|
<div key={code} style={{ padding: '0.25rem 0.5rem', textAlign: 'center', color: 'var(--text-primary)' }}>{code}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<button onClick={() => { navigator.clipboard?.writeText(backupCodes.join('\n')); alert.success('Kódy zkopírovány') }} className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
Kopírovat kódy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-secondary" style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||||
|
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.)
|
||||||
|
</p>
|
||||||
|
{totpQrUri && (
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
|
||||||
|
<canvas
|
||||||
|
ref={(canvas) => {
|
||||||
|
if (canvas && totpQrUri) {
|
||||||
|
import('../../../utils/qrcode.js').then(({ renderQR }) => renderQR(canvas, totpQrUri))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totpSecret && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Nebo zadejte klíč ručně:</label>
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem', background: 'var(--bg-secondary)', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem', wordBreak: 'break-all', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||||||
|
<span>{totpSecret}</span>
|
||||||
|
<button onClick={() => { navigator.clipboard?.writeText(totpSecret); alert.success('Klíč zkopírován') }} className="admin-btn-icon" title="Kopírovat" aria-label="Kopírovat" style={{ flexShrink: 0 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Ověřovací kód z aplikace</label>
|
||||||
|
<input ref={totpSetupRef} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
{backupCodes ? (
|
||||||
|
<button onClick={() => { setShow2FASetup(false); setBackupCodes(null) }} className="admin-btn admin-btn-primary">
|
||||||
|
Rozumím, uložil jsem si kódy
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setShow2FASetup(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
|
||||||
|
<button onClick={onConfirm2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || totpCode.length !== 6}>
|
||||||
|
{totpSubmitting ? 'Ověřuji...' : 'Aktivovat 2FA'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 2FA Disable Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{show2FADisable && (
|
||||||
|
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
|
<div className="admin-modal-backdrop" onClick={() => setShow2FADisable(false)} />
|
||||||
|
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
||||||
|
<div className="admin-modal-header"><h2 className="admin-modal-title">Deaktivovat 2FA</h2></div>
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||||
|
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace.
|
||||||
|
</p>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Ověřovací kód</label>
|
||||||
|
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={disableCode} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
<button onClick={() => setShow2FADisable(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
|
||||||
|
<button onClick={onDisable2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || disableCode.length !== 6}>
|
||||||
|
{totpSubmitting ? 'Deaktivuji...' : 'Deaktivovat 2FA'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
335
src/admin/components/dashboard/DashQuickActions.jsx
Normal file
335
src/admin/components/dashboard/DashQuickActions.jsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { useAlert } from '../../context/AlertContext'
|
||||||
|
import { formatKm } from '../../utils/formatters'
|
||||||
|
import AdminDatePicker from '../AdminDatePicker'
|
||||||
|
import apiFetch from '../../utils/api'
|
||||||
|
import useModalLock from '../../hooks/useModalLock'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
export default function DashQuickActions({ dashData, punching, onPunch }) {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const alert = useAlert()
|
||||||
|
|
||||||
|
const [showTripModal, setShowTripModal] = useState(false)
|
||||||
|
const [tripSubmitting, setTripSubmitting] = useState(false)
|
||||||
|
const [tripVehicles, setTripVehicles] = useState([])
|
||||||
|
const [tripForm, setTripForm] = useState({
|
||||||
|
vehicle_id: '', trip_date: '', start_km: '', end_km: '',
|
||||||
|
route_from: '', route_to: '', is_business: 1, notes: ''
|
||||||
|
})
|
||||||
|
const [tripErrors, setTripErrors] = useState({})
|
||||||
|
|
||||||
|
useModalLock(showTripModal)
|
||||||
|
|
||||||
|
const openTripModal = async () => {
|
||||||
|
setTripForm({
|
||||||
|
vehicle_id: '', trip_date: new Date().toISOString().split('T')[0],
|
||||||
|
start_km: '', end_km: '', route_from: '', route_to: '',
|
||||||
|
is_business: 1, notes: ''
|
||||||
|
})
|
||||||
|
setTripErrors({})
|
||||||
|
setShowTripModal(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/trips.php?action=active_vehicles`)
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setTripVehicles(result.data.vehicles || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// vozidla se nenacetla
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTripVehicleChange = async (vehicleId) => {
|
||||||
|
setTripForm(prev => ({ ...prev, vehicle_id: vehicleId }))
|
||||||
|
if (!vehicleId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/trips.php?action=last_km&vehicle_id=${vehicleId}`)
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setTripForm(prev => ({ ...prev, start_km: result.data.last_km }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// last_km se nenacetlo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTripSubmit = async () => {
|
||||||
|
const errs = {}
|
||||||
|
if (!tripForm.vehicle_id) {
|
||||||
|
errs.vehicle_id = 'Vyberte vozidlo'
|
||||||
|
}
|
||||||
|
if (!tripForm.trip_date) {
|
||||||
|
errs.trip_date = 'Zadejte datum'
|
||||||
|
}
|
||||||
|
if (!tripForm.start_km) {
|
||||||
|
errs.start_km = 'Zadejte počáteční km'
|
||||||
|
}
|
||||||
|
if (!tripForm.end_km) {
|
||||||
|
errs.end_km = 'Zadejte konečný km'
|
||||||
|
}
|
||||||
|
if (tripForm.start_km && tripForm.end_km && parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)) {
|
||||||
|
errs.end_km = 'Musí být větší než počáteční'
|
||||||
|
}
|
||||||
|
if (!tripForm.route_from) {
|
||||||
|
errs.route_from = 'Zadejte místo odjezdu'
|
||||||
|
}
|
||||||
|
if (!tripForm.route_to) {
|
||||||
|
errs.route_to = 'Zadejte místo příjezdu'
|
||||||
|
}
|
||||||
|
setTripErrors(errs)
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTripSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/trips.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(tripForm)
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setShowTripModal(false)
|
||||||
|
alert.success(result.message)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setTripSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripDistance = () => {
|
||||||
|
const s = parseInt(tripForm.start_km) || 0
|
||||||
|
const e = parseInt(tripForm.end_km) || 0
|
||||||
|
return e > s ? e - s : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOngoingShift = dashData?.my_shift?.has_ongoing
|
||||||
|
const punchLabel = hasOngoingShift ? 'Zaznamenat odchod' : 'Zaznamenat příchod'
|
||||||
|
const quickActions = []
|
||||||
|
|
||||||
|
if (hasPermission('attendance.record')) {
|
||||||
|
quickActions.push({
|
||||||
|
label: punching ? 'Odesílám...' : punchLabel,
|
||||||
|
color: hasOngoingShift ? 'danger' : 'success',
|
||||||
|
icon: hasOngoingShift
|
||||||
|
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>
|
||||||
|
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 12l2 2 4-4" /><circle cx="12" cy="12" r="10" /></svg>,
|
||||||
|
onClick: onPunch,
|
||||||
|
disabled: punching,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (hasPermission('offers.create')) {
|
||||||
|
quickActions.push({ label: 'Nová nabídka', path: '/offers/new', color: 'info', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg> })
|
||||||
|
}
|
||||||
|
if (hasPermission('trips.record')) {
|
||||||
|
quickActions.push({
|
||||||
|
label: 'Přidat jízdu',
|
||||||
|
color: 'warning',
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="1" y="3" width="15" height="13" rx="2" /><circle cx="8.5" cy="16" r="2.5" /><circle cx="18.5" cy="16" r="2.5" /><path d="M16 8h4l3 5v3h-7" /></svg>,
|
||||||
|
onClick: openTripModal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (hasPermission('invoices.create')) {
|
||||||
|
quickActions.push({ label: 'Vystavit fakturu', path: '/invoices/new', color: 'danger', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></svg> })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="dash-quick-actions"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.15 }}
|
||||||
|
>
|
||||||
|
{quickActions.map((action) => action.onClick ? (
|
||||||
|
<button
|
||||||
|
key={action.label}
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled}
|
||||||
|
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link key={action.label} to={action.path} className={`dash-quick-btn dash-quick-btn-${action.color}`}>
|
||||||
|
{action.icon}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showTripModal && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-backdrop" onClick={() => setShowTripModal(false)} />
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal admin-modal-lg"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-header">
|
||||||
|
<h2 className="admin-modal-title">Přidat jízdu</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className={`admin-form-group${tripErrors.vehicle_id ? ' has-error' : ''}`}>
|
||||||
|
<label className="admin-form-label required">Vozidlo</label>
|
||||||
|
<select
|
||||||
|
value={tripForm.vehicle_id}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleTripVehicleChange(e.target.value)
|
||||||
|
setTripErrors(prev => ({ ...prev, vehicle_id: undefined }))
|
||||||
|
}}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
<option value="">Vyberte vozidlo</option>
|
||||||
|
{tripVehicles.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>{v.spz} - {v.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{tripErrors.vehicle_id && <span className="admin-form-error">{tripErrors.vehicle_id}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={`admin-form-group${tripErrors.trip_date ? ' has-error' : ''}`}>
|
||||||
|
<label className="admin-form-label required">Datum jízdy</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={tripForm.trip_date}
|
||||||
|
onChange={(val) => {
|
||||||
|
setTripForm(prev => ({ ...prev, trip_date: val }))
|
||||||
|
setTripErrors(prev => ({ ...prev, trip_date: undefined }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tripErrors.trip_date && <span className="admin-form-error">{tripErrors.trip_date}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-row admin-form-row-3">
|
||||||
|
<div className={`admin-form-group${tripErrors.start_km ? ' has-error' : ''}`}>
|
||||||
|
<label className="admin-form-label required">Počáteční stav km</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={tripForm.start_km}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTripForm(prev => ({ ...prev, start_km: e.target.value }))
|
||||||
|
setTripErrors(prev => ({ ...prev, start_km: undefined }))
|
||||||
|
}}
|
||||||
|
className="admin-form-input"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
{tripErrors.start_km && <span className="admin-form-error">{tripErrors.start_km}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={`admin-form-group${tripErrors.end_km ? ' has-error' : ''}`}>
|
||||||
|
<label className="admin-form-label required">Konečný stav km</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={tripForm.end_km}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTripForm(prev => ({ ...prev, end_km: e.target.value }))
|
||||||
|
setTripErrors(prev => ({ ...prev, end_km: undefined }))
|
||||||
|
}}
|
||||||
|
className="admin-form-input"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
{tripErrors.end_km && <span className="admin-form-error">{tripErrors.end_km}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Vzdálenost</label>
|
||||||
|
<input type="text" value={`${formatKm(tripDistance())} km`} className="admin-form-input" readOnly disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className={`admin-form-group${tripErrors.route_from ? ' has-error' : ''}`}>
|
||||||
|
<label className="admin-form-label required">Místo odjezdu</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tripForm.route_from}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTripForm(prev => ({ ...prev, route_from: e.target.value }))
|
||||||
|
setTripErrors(prev => ({ ...prev, route_from: undefined }))
|
||||||
|
}}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Např. Praha"
|
||||||
|
/>
|
||||||
|
{tripErrors.route_from && <span className="admin-form-error">{tripErrors.route_from}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={`admin-form-group${tripErrors.route_to ? ' has-error' : ''}`}>
|
||||||
|
<label className="admin-form-label required">Místo příjezdu</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tripForm.route_to}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTripForm(prev => ({ ...prev, route_to: e.target.value }))
|
||||||
|
setTripErrors(prev => ({ ...prev, route_to: undefined }))
|
||||||
|
}}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Např. Brno"
|
||||||
|
/>
|
||||||
|
{tripErrors.route_to && <span className="admin-form-error">{tripErrors.route_to}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Typ jízdy</label>
|
||||||
|
<select
|
||||||
|
value={tripForm.is_business}
|
||||||
|
onChange={(e) => setTripForm(prev => ({ ...prev, is_business: parseInt(e.target.value) }))}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
<option value={1}>Služební</option>
|
||||||
|
<option value={0}>Soukromá</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Poznámky</label>
|
||||||
|
<textarea
|
||||||
|
value={tripForm.notes}
|
||||||
|
onChange={(e) => setTripForm(prev => ({ ...prev, notes: e.target.value }))}
|
||||||
|
className="admin-form-textarea"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Volitelné poznámky..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
<button type="button" onClick={() => setShowTripModal(false)} className="admin-btn admin-btn-secondary" disabled={tripSubmitting}>
|
||||||
|
Zrušit
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleTripSubmit} className="admin-btn admin-btn-primary" disabled={tripSubmitting}>
|
||||||
|
{tripSubmitting ? 'Ukládám...' : 'Uložit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
src/admin/components/dashboard/DashSessions.jsx
Normal file
199
src/admin/components/dashboard/DashSessions.jsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useAlert } from '../../context/AlertContext'
|
||||||
|
import ConfirmModal from '../ConfirmModal'
|
||||||
|
import useModalLock from '../../hooks/useModalLock'
|
||||||
|
import apiFetch from '../../utils/api'
|
||||||
|
import { formatSessionDate } from '../../utils/dashboardHelpers'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
function getDeviceIcon(iconType) {
|
||||||
|
switch (iconType) {
|
||||||
|
case 'smartphone':
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'tablet':
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashSessions() {
|
||||||
|
const alert = useAlert()
|
||||||
|
|
||||||
|
const [sessions, setSessions] = useState([])
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||||
|
const [deleteModal, setDeleteModal] = useState({ isOpen: false, session: null })
|
||||||
|
const [deleteAllModal, setDeleteAllModal] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
useModalLock(deleteAllModal)
|
||||||
|
|
||||||
|
const fetchSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/sessions.php`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setSessions(data.data.sessions || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// session fetch failed silently
|
||||||
|
} finally {
|
||||||
|
setSessionsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions()
|
||||||
|
}, [fetchSessions])
|
||||||
|
|
||||||
|
const handleDeleteSession = async () => {
|
||||||
|
if (!deleteModal.session) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sessionId = deleteModal.session.id
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/sessions.php?id=${sessionId}`, { method: 'DELETE' })
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setDeleteModal({ isOpen: false, session: null })
|
||||||
|
setSessions(prev => prev.filter(s => s.id !== sessionId))
|
||||||
|
alert.success('Relace byla ukončena')
|
||||||
|
} else {
|
||||||
|
alert.error(data.error || 'Nepodařilo se ukončit relaci')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAllSessions = async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/sessions.php?action=all`, { method: 'DELETE' })
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setDeleteAllModal(false)
|
||||||
|
setSessions(prev => prev.filter(s => s.is_current))
|
||||||
|
alert.success(data.message || 'Ostatní relace byly ukončeny')
|
||||||
|
} else {
|
||||||
|
alert.error(data.error || 'Nepodařilo se ukončit relace')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
|
<h2 className="admin-card-title">Přihlášená zařízení</h2>
|
||||||
|
{sessions.filter(s => !s.is_current).length > 0 && (
|
||||||
|
<button onClick={() => setDeleteAllModal(true)} className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||||
|
Odhlásit ostatní
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||||
|
{sessionsLoading && (
|
||||||
|
<div className="admin-skeleton" style={{ padding: '1rem', gap: '1rem' }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div key={i} className="admin-skeleton-row">
|
||||||
|
<div className="admin-skeleton-line circle" />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!sessionsLoading && sessions.length === 0 && (
|
||||||
|
<div className="text-secondary" style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.875rem' }}>
|
||||||
|
Žádné aktivní relace
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!sessionsLoading && sessions.length > 0 && (
|
||||||
|
<div className="sessions-list">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div key={session.id} className={`session-item ${session.is_current ? 'session-item-current' : ''}`}>
|
||||||
|
<div className="session-icon">{getDeviceIcon(session.device_info?.icon)}</div>
|
||||||
|
<div className="session-info">
|
||||||
|
<div className="session-device">
|
||||||
|
{session.device_info?.browser} na {session.device_info?.os}
|
||||||
|
{session.is_current && (
|
||||||
|
<span className="admin-badge admin-badge-success" style={{ marginLeft: '0.5rem' }}>Aktuální</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="session-meta">
|
||||||
|
<span>{session.ip_address}</span>
|
||||||
|
<span className="session-meta-separator">|</span>
|
||||||
|
<span>{formatSessionDate(session.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
{!session.is_current && (
|
||||||
|
<button onClick={() => setDeleteModal({ isOpen: true, session })} className="admin-btn-icon danger" title="Ukončit relaci" aria-label="Ukončit relaci">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={deleteModal.isOpen}
|
||||||
|
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
||||||
|
onConfirm={handleDeleteSession}
|
||||||
|
title="Ukončit relaci"
|
||||||
|
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
|
||||||
|
confirmText="Ukončit"
|
||||||
|
cancelText="Zrušit"
|
||||||
|
type="danger"
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={deleteAllModal}
|
||||||
|
onClose={() => setDeleteAllModal(false)}
|
||||||
|
onConfirm={handleDeleteAllSessions}
|
||||||
|
title="Odhlásit ostatní zařízení"
|
||||||
|
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
||||||
|
confirmText="Odhlásit vše"
|
||||||
|
cancelText="Zrušit"
|
||||||
|
type="warning"
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
627
src/admin/hooks/useAttendanceAdmin.js
Normal file
627
src/admin/hooks/useAttendanceAdmin.js
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import apiFetch from '../utils/api'
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatMinutes,
|
||||||
|
getLeaveTypeName, getLeaveTypeBadgeClass,
|
||||||
|
getDatePart, getTimePart,
|
||||||
|
calcProjectMinutesTotal, calcFormWorkMinutes,
|
||||||
|
formatTimeOrDatetimePrint, calculateWorkMinutesPrint
|
||||||
|
} from '../utils/attendanceHelpers'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
function renderFundStatus(userData) {
|
||||||
|
if (userData.overtime > 0) {
|
||||||
|
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`
|
||||||
|
}
|
||||||
|
if (userData.missing > 0) {
|
||||||
|
return `<span style="color:#dc2626">−${userData.missing}h</span>`
|
||||||
|
}
|
||||||
|
return '<span style="color:#16a34a">splněno</span>'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectLogsHtml(record) {
|
||||||
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
|
return record.project_logs.map(log => {
|
||||||
|
let h, m
|
||||||
|
if (log.hours !== null && log.hours !== undefined) {
|
||||||
|
h = parseInt(log.hours) || 0
|
||||||
|
m = parseInt(log.minutes) || 0
|
||||||
|
} else if (log.started_at && log.ended_at) {
|
||||||
|
const mins = Math.max(0, Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000))
|
||||||
|
h = Math.floor(mins / 60)
|
||||||
|
m = mins % 60
|
||||||
|
} else {
|
||||||
|
h = 0
|
||||||
|
m = 0
|
||||||
|
}
|
||||||
|
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, '0')}h)</div>`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
return record.project_name || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserSectionHtml(userId, userData, printData) {
|
||||||
|
const leaveHtml = printData.leave_balances[userId]
|
||||||
|
? buildLeaveSummaryHtml(userId, userData, printData)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const recordRows = userData.records.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
|
||||||
|
const breakCell = (isLeave || !record.break_start || !record.break_end)
|
||||||
|
? '—'
|
||||||
|
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td>${formatDate(record.shift_date)}</td>
|
||||||
|
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
|
||||||
|
<td class="text-center">${isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
|
||||||
|
<td class="text-center">${breakCell}</td>
|
||||||
|
<td class="text-center">${isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
|
||||||
|
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
|
||||||
|
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
||||||
|
<td>${record.notes || ''}</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const fundRow = userData.fund !== null
|
||||||
|
? `<tr>
|
||||||
|
<td colspan="6" class="text-right">Fond měsíce:</td>
|
||||||
|
<td class="text-center">${userData.covered}h / ${userData.fund}h</td>
|
||||||
|
<td colspan="2">${renderFundStatus(userData)}</td>
|
||||||
|
</tr>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return `<div class="user-section">
|
||||||
|
<div class="user-header">
|
||||||
|
<h3>${userData.name}</h3>
|
||||||
|
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
|
||||||
|
</div>
|
||||||
|
${leaveHtml}
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:70px">Datum</th>
|
||||||
|
<th style="width:70px">Typ</th>
|
||||||
|
<th class="text-center" style="width:70px">Příchod</th>
|
||||||
|
<th class="text-center" style="width:90px">Pauza</th>
|
||||||
|
<th class="text-center" style="width:70px">Odchod</th>
|
||||||
|
<th class="text-center" style="width:80px">Hodiny</th>
|
||||||
|
<th>Projekty</th>
|
||||||
|
<th>Poznámka</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${recordRows}</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-right">Odpracováno:</td>
|
||||||
|
<td class="text-center">${formatMinutes(userData.minutes)} h</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
${fundRow}
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLeaveSummaryHtml(userId, userData, printData) {
|
||||||
|
const bal = printData.leave_balances[userId]
|
||||||
|
let parts = `<strong>Dovolená ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`
|
||||||
|
if (userData.vacation_hours > 0) {
|
||||||
|
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`
|
||||||
|
}
|
||||||
|
if (userData.sick_hours > 0) {
|
||||||
|
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`
|
||||||
|
}
|
||||||
|
if (userData.holiday_hours > 0) {
|
||||||
|
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`
|
||||||
|
}
|
||||||
|
if (userData.overtime > 0) {
|
||||||
|
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`
|
||||||
|
}
|
||||||
|
return `<div class="leave-summary">${parts}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useAttendanceAdmin({ alert }) {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [month, setMonth] = useState(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
const [filterUserId, setFilterUserId] = useState('')
|
||||||
|
const [data, setData] = useState({
|
||||||
|
records: [],
|
||||||
|
users: [],
|
||||||
|
user_totals: {},
|
||||||
|
leave_balances: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [showBulkModal, setShowBulkModal] = useState(false)
|
||||||
|
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
||||||
|
const [bulkForm, setBulkForm] = useState({
|
||||||
|
month: '',
|
||||||
|
user_ids: [],
|
||||||
|
arrival_time: '08:00',
|
||||||
|
departure_time: '16:30',
|
||||||
|
break_start_time: '12:00',
|
||||||
|
break_end_time: '12:30'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
user_id: '',
|
||||||
|
shift_date: today,
|
||||||
|
leave_type: 'work',
|
||||||
|
leave_hours: 8,
|
||||||
|
arrival_date: today,
|
||||||
|
arrival_time: '',
|
||||||
|
break_start_date: today,
|
||||||
|
break_start_time: '',
|
||||||
|
break_end_date: today,
|
||||||
|
break_end_time: '',
|
||||||
|
departure_date: today,
|
||||||
|
departure_time: '',
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [editingRecord, setEditingRecord] = useState(null)
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
shift_date: '',
|
||||||
|
leave_type: 'work',
|
||||||
|
leave_hours: 8,
|
||||||
|
arrival_date: '',
|
||||||
|
arrival_time: '',
|
||||||
|
break_start_date: '',
|
||||||
|
break_start_time: '',
|
||||||
|
break_end_date: '',
|
||||||
|
break_end_time: '',
|
||||||
|
departure_date: '',
|
||||||
|
departure_time: '',
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, record: null })
|
||||||
|
const [projectList, setProjectList] = useState([])
|
||||||
|
const [createProjectLogs, setCreateProjectLogs] = useState([])
|
||||||
|
const [editProjectLogs, setEditProjectLogs] = useState([])
|
||||||
|
const printRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?action=projects`)
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) setProjectList(result.data.projects || [])
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
loadProjects()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (showLoading = true) => {
|
||||||
|
if (showLoading) setLoading(true)
|
||||||
|
try {
|
||||||
|
let url = `${API_BASE}/attendance.php?action=admin&month=${month}`
|
||||||
|
if (filterUserId) {
|
||||||
|
url += `&user_id=${filterUserId}`
|
||||||
|
}
|
||||||
|
const response = await apiFetch(url)
|
||||||
|
if (response.status === 401) return
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Nepodařilo se načíst data')
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [month, filterUserId, alert])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
// --- Create modal ---
|
||||||
|
const openCreateModal = () => {
|
||||||
|
const todayDate = new Date().toISOString().split('T')[0]
|
||||||
|
setCreateForm({
|
||||||
|
user_id: '',
|
||||||
|
shift_date: todayDate,
|
||||||
|
leave_type: 'work',
|
||||||
|
leave_hours: 8,
|
||||||
|
arrival_date: todayDate,
|
||||||
|
arrival_time: '',
|
||||||
|
break_start_date: todayDate,
|
||||||
|
break_start_time: '',
|
||||||
|
break_end_date: todayDate,
|
||||||
|
break_end_time: '',
|
||||||
|
departure_date: todayDate,
|
||||||
|
departure_time: '',
|
||||||
|
notes: '',
|
||||||
|
project_id: ''
|
||||||
|
})
|
||||||
|
setCreateProjectLogs([])
|
||||||
|
setShowCreateModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateShiftDateChange = (newDate) => {
|
||||||
|
setCreateForm({
|
||||||
|
...createForm,
|
||||||
|
shift_date: newDate,
|
||||||
|
arrival_date: newDate,
|
||||||
|
break_start_date: newDate,
|
||||||
|
break_end_date: newDate,
|
||||||
|
departure_date: newDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateSubmit = async () => {
|
||||||
|
if (!createForm.user_id || !createForm.shift_date) {
|
||||||
|
alert.error('Vyplňte zaměstnance a datum směny')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCreateLogs = createProjectLogs.filter(l => l.project_id)
|
||||||
|
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
|
||||||
|
if (!validateProjectLogs(filteredCreateLogs, createForm)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { ...createForm }
|
||||||
|
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
|
||||||
|
payload.project_logs = filteredCreateLogs
|
||||||
|
}
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?action=create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
await fetchData(false)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
alert.success(result.message)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateProjectLogs = (logs, formData) => {
|
||||||
|
const totalWork = calcFormWorkMinutes(formData)
|
||||||
|
const totalProject = calcProjectMinutesTotal(logs)
|
||||||
|
if (totalWork > 0 && totalProject !== totalWork) {
|
||||||
|
const wH = Math.floor(totalWork / 60)
|
||||||
|
const wM = totalWork % 60
|
||||||
|
const pH = Math.floor(totalProject / 60)
|
||||||
|
const pM = totalProject % 60
|
||||||
|
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bulk modal ---
|
||||||
|
const openBulkModal = () => {
|
||||||
|
setBulkForm({
|
||||||
|
month: month,
|
||||||
|
user_ids: data.users.map(u => String(u.id)),
|
||||||
|
arrival_time: '08:00',
|
||||||
|
departure_time: '16:30',
|
||||||
|
break_start_time: '12:00',
|
||||||
|
break_end_time: '12:30'
|
||||||
|
})
|
||||||
|
setShowBulkModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleBulkUser = (userId) => {
|
||||||
|
const uid = String(userId)
|
||||||
|
setBulkForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
user_ids: prev.user_ids.includes(uid)
|
||||||
|
? prev.user_ids.filter(u => u !== uid)
|
||||||
|
: [...prev.user_ids, uid]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAllBulkUsers = () => {
|
||||||
|
const allIds = data.users.map(u => String(u.id))
|
||||||
|
setBulkForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
user_ids: prev.user_ids.length === allIds.length ? [] : allIds
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkSubmit = async () => {
|
||||||
|
if (!bulkForm.month) {
|
||||||
|
alert.error('Vyberte měsíc')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (bulkForm.user_ids.length === 0) {
|
||||||
|
alert.error('Vyberte alespoň jednoho zaměstnance')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBulkSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?action=bulk_attendance`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(bulkForm)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setShowBulkModal(false)
|
||||||
|
await fetchData(false)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
alert.success(result.message)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setBulkSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edit modal ---
|
||||||
|
const openEditModal = (record) => {
|
||||||
|
setEditingRecord(record)
|
||||||
|
setEditForm({
|
||||||
|
shift_date: record.shift_date,
|
||||||
|
leave_type: record.leave_type || 'work',
|
||||||
|
leave_hours: record.leave_hours || 8,
|
||||||
|
arrival_date: getDatePart(record.arrival_time) || record.shift_date,
|
||||||
|
arrival_time: getTimePart(record.arrival_time),
|
||||||
|
break_start_date: getDatePart(record.break_start) || record.shift_date,
|
||||||
|
break_start_time: getTimePart(record.break_start),
|
||||||
|
break_end_date: getDatePart(record.break_end) || record.shift_date,
|
||||||
|
break_end_time: getTimePart(record.break_end),
|
||||||
|
departure_date: getDatePart(record.departure_time) || record.shift_date,
|
||||||
|
departure_time: getTimePart(record.departure_time),
|
||||||
|
notes: record.notes || '',
|
||||||
|
project_id: record.project_id || ''
|
||||||
|
})
|
||||||
|
const logs = (record.project_logs || []).map(l => {
|
||||||
|
if (l.hours !== null && l.hours !== undefined) {
|
||||||
|
return {
|
||||||
|
project_id: String(l.project_id),
|
||||||
|
hours: String(l.hours),
|
||||||
|
minutes: String(l.minutes || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (l.started_at && l.ended_at) {
|
||||||
|
const mins = Math.max(0, Math.floor((new Date(l.ended_at) - new Date(l.started_at)) / 60000))
|
||||||
|
return {
|
||||||
|
project_id: String(l.project_id),
|
||||||
|
hours: String(Math.floor(mins / 60)),
|
||||||
|
minutes: String(mins % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { project_id: String(l.project_id), hours: '', minutes: '' }
|
||||||
|
})
|
||||||
|
setEditProjectLogs(logs)
|
||||||
|
setShowEditModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditSubmit = async () => {
|
||||||
|
const isWork = (editForm.leave_type || 'work') === 'work'
|
||||||
|
const filteredEditLogs = isWork ? editProjectLogs.filter(l => l.project_id) : []
|
||||||
|
if (filteredEditLogs.length > 0) {
|
||||||
|
if (!validateProjectLogs(filteredEditLogs, editForm)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { ...editForm }
|
||||||
|
payload.project_logs = filteredEditLogs
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?id=${editingRecord.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setShowEditModal(false)
|
||||||
|
await fetchData(false)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
alert.success(result.message)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteConfirm.record) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?id=${deleteConfirm.record.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setDeleteConfirm({ show: false, record: null })
|
||||||
|
await fetchData(false)
|
||||||
|
alert.success(result.message)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Print ---
|
||||||
|
const handlePrint = async () => {
|
||||||
|
try {
|
||||||
|
let url = `${API_BASE}/attendance.php?action=print&month=${month}`
|
||||||
|
if (filterUserId) {
|
||||||
|
url += `&user_id=${filterUserId}`
|
||||||
|
}
|
||||||
|
const response = await apiFetch(url)
|
||||||
|
if (response.status === 401) return
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
openPrintWindow(result.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Nepodařilo se připravit tisk')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPrintWindow = (pData) => {
|
||||||
|
const userSections = Object.entries(pData.user_totals)
|
||||||
|
.map(([uid, uData]) => buildUserSectionHtml(uid, uData, pData))
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const emptyMsg = Object.keys(pData.user_totals).length === 0
|
||||||
|
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const filterNote = pData.selected_user_name
|
||||||
|
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const bodyContent = buildPrintHtml(pData, userSections, emptyMsg, filterNote)
|
||||||
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.document.open()
|
||||||
|
printWindow.document.write(DOMPurify.sanitize(bodyContent, { WHOLE_DOCUMENT: true }))
|
||||||
|
printWindow.document.close()
|
||||||
|
printWindow.onload = () => printWindow.print()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = Object.keys(data.user_totals).length > 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading, month, setMonth,
|
||||||
|
filterUserId, setFilterUserId,
|
||||||
|
data, hasData,
|
||||||
|
showBulkModal, setShowBulkModal,
|
||||||
|
bulkSubmitting, bulkForm, setBulkForm,
|
||||||
|
showCreateModal, setShowCreateModal,
|
||||||
|
createForm, setCreateForm,
|
||||||
|
showEditModal, setShowEditModal,
|
||||||
|
editingRecord, editForm, setEditForm,
|
||||||
|
deleteConfirm, setDeleteConfirm,
|
||||||
|
projectList,
|
||||||
|
createProjectLogs, setCreateProjectLogs,
|
||||||
|
editProjectLogs, setEditProjectLogs,
|
||||||
|
printRef,
|
||||||
|
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
|
||||||
|
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
|
||||||
|
openEditModal, handleEditSubmit,
|
||||||
|
handleDelete, handlePrint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrintHtml(pData, userSections, emptyMsg, filterNote) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Docházka - ${pData.month_name}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
|
||||||
|
}
|
||||||
|
.print-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; }
|
||||||
|
.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; }
|
||||||
|
.leave-summary {
|
||||||
|
margin-top: 10px; padding: 8px 15px; background: #f9f9f9;
|
||||||
|
border: 1px solid #ddd; font-size: 10px;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 0; margin: 0; }
|
||||||
|
@page { size: A4 portrait; margin: 10mm; }
|
||||||
|
.user-section { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table class="print-wrapper-table">
|
||||||
|
<thead><tr><td>
|
||||||
|
<div class="print-header">
|
||||||
|
<div class="print-header-left">
|
||||||
|
<img src="/images/logo-light.png" alt="BOHA" class="print-logo" />
|
||||||
|
<div class="print-header-text">
|
||||||
|
<h1>EVIDENCE DOCHÁZKY</h1>
|
||||||
|
<div class="company">BOHA Automation s.r.o.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="print-header-right">
|
||||||
|
<div class="period">${pData.month_name}</div>
|
||||||
|
${filterNote}
|
||||||
|
<div class="generated">Vygenerováno: ${new Date().toLocaleString('cs-CZ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td></tr></thead>
|
||||||
|
<tbody><tr><td>
|
||||||
|
${userSections}
|
||||||
|
${emptyMsg}
|
||||||
|
</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
437
src/admin/hooks/useOfferForm.js
Normal file
437
src/admin/hooks/useOfferForm.js
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
|
import apiFetch from '../utils/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
let _keyCounter = 0
|
||||||
|
|
||||||
|
export const emptyItem = () => ({
|
||||||
|
_key: `item-${++_keyCounter}`,
|
||||||
|
description: '',
|
||||||
|
item_description: '',
|
||||||
|
quantity: 1,
|
||||||
|
unit: '',
|
||||||
|
unit_price: 0,
|
||||||
|
is_included_in_total: true
|
||||||
|
})
|
||||||
|
|
||||||
|
export const emptySection = () => ({
|
||||||
|
_key: `sec-${++_keyCounter}`,
|
||||||
|
title: '',
|
||||||
|
title_cz: '',
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export function assignItemKeys(items) {
|
||||||
|
return items.map(i => ({ ...i, _key: i._key || `item-${++_keyCounter}` }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignSectionKeys(sections) {
|
||||||
|
return sections.map(s => ({ ...s, _key: s._key || `sec-${++_keyCounter}` }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAFT_KEY = 'boha_offer_draft'
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
quotation_number: '',
|
||||||
|
project_code: '',
|
||||||
|
customer_id: null,
|
||||||
|
customer_name: '',
|
||||||
|
created_at: new Date().toISOString().split('T')[0],
|
||||||
|
valid_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||||
|
currency: 'EUR',
|
||||||
|
language: 'EN',
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: false,
|
||||||
|
exchange_rate: '',
|
||||||
|
exchange_rate_date: '',
|
||||||
|
scope_title: '',
|
||||||
|
scope_description: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useOfferForm({ id, isEdit, alert, navigate }) {
|
||||||
|
const [loading, setLoading] = useState(isEdit)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [errors, setErrors] = useState({})
|
||||||
|
const [customers, setCustomers] = useState([])
|
||||||
|
const [itemTemplates, setItemTemplates] = useState([])
|
||||||
|
const [scopeTemplates, setScopeTemplates] = useState([])
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ ...initialForm })
|
||||||
|
const [items, setItems] = useState([emptyItem()])
|
||||||
|
const [sections, setSections] = useState([])
|
||||||
|
|
||||||
|
const [orderInfo, setOrderInfo] = useState(null)
|
||||||
|
const [offerStatus, setOfferStatus] = useState('active')
|
||||||
|
const [draftSavedAt, setDraftSavedAt] = useState(null)
|
||||||
|
|
||||||
|
const draftDataRef = useRef({ form, items, sections })
|
||||||
|
const draftRestoredRef = useRef(false)
|
||||||
|
|
||||||
|
// Fetch customers + templates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMeta = async () => {
|
||||||
|
try {
|
||||||
|
const [custRes, itemTplRes, scopeTplRes] = await Promise.all([
|
||||||
|
apiFetch(`${API_BASE}/customers.php`),
|
||||||
|
apiFetch(`${API_BASE}/offers-templates.php?action=items`),
|
||||||
|
apiFetch(`${API_BASE}/offers-templates.php?action=scopes`)
|
||||||
|
])
|
||||||
|
const custData = await custRes.json()
|
||||||
|
const itemTplData = await itemTplRes.json()
|
||||||
|
const scopeTplData = await scopeTplRes.json()
|
||||||
|
|
||||||
|
if (custData.success) setCustomers(custData.data.customers)
|
||||||
|
if (itemTplData.success) setItemTemplates(itemTplData.data.templates)
|
||||||
|
if (scopeTplData.success) setScopeTemplates(scopeTplData.data.templates)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
fetchMeta()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Restore draft on mount (new offers only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) return
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DRAFT_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const draft = JSON.parse(raw)
|
||||||
|
if (!draft || typeof draft !== 'object' || !draft.form || !Array.isArray(draft.items)) {
|
||||||
|
localStorage.removeItem(DRAFT_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { form: dForm, items: dItems, sections: dSections, savedAt } = draft
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
project_code: dForm.project_code ?? prev.project_code,
|
||||||
|
customer_id: dForm.customer_id ?? prev.customer_id,
|
||||||
|
customer_name: dForm.customer_name ?? prev.customer_name,
|
||||||
|
created_at: dForm.created_at ?? prev.created_at,
|
||||||
|
valid_until: dForm.valid_until ?? prev.valid_until,
|
||||||
|
currency: dForm.currency ?? prev.currency,
|
||||||
|
language: dForm.language ?? prev.language,
|
||||||
|
vat_rate: dForm.vat_rate ?? prev.vat_rate,
|
||||||
|
apply_vat: dForm.apply_vat ?? prev.apply_vat,
|
||||||
|
exchange_rate: dForm.exchange_rate ?? prev.exchange_rate,
|
||||||
|
exchange_rate_date: dForm.exchange_rate_date ?? prev.exchange_rate_date,
|
||||||
|
scope_title: dForm.scope_title ?? prev.scope_title,
|
||||||
|
scope_description: dForm.scope_description ?? prev.scope_description,
|
||||||
|
}))
|
||||||
|
if (dItems.length) setItems(assignItemKeys(dItems))
|
||||||
|
if (Array.isArray(dSections) && dSections.length) setSections(assignSectionKeys(dSections))
|
||||||
|
draftRestoredRef.current = true
|
||||||
|
if (savedAt) setDraftSavedAt(new Date(savedAt))
|
||||||
|
} catch {
|
||||||
|
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, [isEdit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftDataRef.current = { form, items, sections }
|
||||||
|
}, [form, items, sections])
|
||||||
|
|
||||||
|
// Auto-save draft (jen nove nabidky)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) return
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const { form: f, items: it, sections: sc } = draftDataRef.current
|
||||||
|
const { quotation_number: _qn, ...formWithoutNumber } = f
|
||||||
|
const savedAt = new Date().toISOString()
|
||||||
|
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: formWithoutNumber, items: it, sections: sc, savedAt }))
|
||||||
|
setDraftSavedAt(new Date(savedAt))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [form, items, sections, isEdit])
|
||||||
|
|
||||||
|
// Fetch next number + defaults / detail
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) {
|
||||||
|
const fetchNextNumber = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/offers.php?action=next_number`)
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setForm(prev => ({ ...prev, quotation_number: result.data.number }))
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDefaults = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/company-settings.php`)
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success && !draftRestoredRef.current) {
|
||||||
|
const s = result.data
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
currency: s.default_currency || prev.currency,
|
||||||
|
vat_rate: s.default_vat_rate || prev.vat_rate
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNextNumber()
|
||||||
|
fetchDefaults()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/offers.php?action=detail&id=${id}`)
|
||||||
|
if (response.status === 401) return
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
populateFromDetail(result.data)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error || 'Nepodařilo se načíst nabídku')
|
||||||
|
navigate('/offers')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
navigate('/offers')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDetail()
|
||||||
|
}, [isEdit, id, alert, navigate])
|
||||||
|
|
||||||
|
const populateFromDetail = (q) => {
|
||||||
|
setForm({
|
||||||
|
quotation_number: q.quotation_number || '',
|
||||||
|
project_code: q.project_code || '',
|
||||||
|
customer_id: q.customer_id || null,
|
||||||
|
customer_name: q.customer_name || '',
|
||||||
|
created_at: (q.created_at || '').substring(0, 10),
|
||||||
|
valid_until: (q.valid_until || '').substring(0, 10),
|
||||||
|
currency: q.currency || 'EUR',
|
||||||
|
language: q.language || 'EN',
|
||||||
|
vat_rate: q.vat_rate || 21,
|
||||||
|
apply_vat: Boolean(q.apply_vat),
|
||||||
|
exchange_rate: q.exchange_rate || '',
|
||||||
|
exchange_rate_date: q.exchange_rate_date || '',
|
||||||
|
scope_title: q.scope_title || '',
|
||||||
|
scope_description: q.scope_description || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
if (q.items?.length) {
|
||||||
|
setItems(q.items.map(item => ({
|
||||||
|
_key: `item-${++_keyCounter}`,
|
||||||
|
description: item.description || '',
|
||||||
|
item_description: item.item_description || '',
|
||||||
|
quantity: Number(item.quantity) || 1,
|
||||||
|
unit: item.unit || '',
|
||||||
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
is_included_in_total: Boolean(item.is_included_in_total)
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.sections?.length) {
|
||||||
|
setItems(prev => prev) // no-op, keep items
|
||||||
|
setSections(q.sections.map(s => ({
|
||||||
|
_key: `sec-${++_keyCounter}`,
|
||||||
|
title: s.title || '',
|
||||||
|
title_cz: s.title_cz || '',
|
||||||
|
content: s.content || ''
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderInfo(q.order || null)
|
||||||
|
setOfferStatus(q.status || 'active')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculated totals
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const subtotal = items.reduce((sum, item) => {
|
||||||
|
if (item.is_included_in_total) {
|
||||||
|
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}, 0)
|
||||||
|
const vatAmount = form.apply_vat ? subtotal * ((Number(form.vat_rate) || 0) / 100) : 0
|
||||||
|
return { subtotal, vatAmount, total: subtotal + vatAmount }
|
||||||
|
}, [items, form.apply_vat, form.vat_rate])
|
||||||
|
|
||||||
|
// Draft helpers
|
||||||
|
const clearDraft = useCallback(() => {
|
||||||
|
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
||||||
|
setDraftSavedAt(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const draftSavedAtLabel = useMemo(() => {
|
||||||
|
if (!draftSavedAt) return null
|
||||||
|
return draftSavedAt.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}, [draftSavedAt])
|
||||||
|
|
||||||
|
// Form handlers
|
||||||
|
const updateForm = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
|
const selectCustomer = (customer) => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
customer_id: customer.id,
|
||||||
|
customer_name: customer.name
|
||||||
|
}))
|
||||||
|
setErrors(prev => ({ ...prev, customer_id: undefined }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCustomer = () => {
|
||||||
|
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items handlers
|
||||||
|
const updateItem = (index, field, value) => {
|
||||||
|
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addItem = () => setItems(prev => [...prev, emptyItem()])
|
||||||
|
|
||||||
|
const removeItem = (index) => {
|
||||||
|
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addItemFromTemplate = (template) => {
|
||||||
|
setItems(prev => [...prev, {
|
||||||
|
_key: `item-${++_keyCounter}`,
|
||||||
|
description: template.name || '',
|
||||||
|
item_description: template.description || '',
|
||||||
|
quantity: 1,
|
||||||
|
unit: '',
|
||||||
|
unit_price: Number(template.default_price) || 0,
|
||||||
|
is_included_in_total: true
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections handlers
|
||||||
|
const addSection = () => setSections(prev => [...prev, emptySection()])
|
||||||
|
|
||||||
|
const removeSection = (index) => {
|
||||||
|
setSections(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSection = (index, field, value) => {
|
||||||
|
setSections(prev => prev.map((s, i) => i === index ? { ...s, [field]: value } : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveSection = (index, direction) => {
|
||||||
|
setSections(prev => {
|
||||||
|
const newSections = [...prev]
|
||||||
|
const target = index + direction
|
||||||
|
if (target < 0 || target >= newSections.length) return prev
|
||||||
|
;[newSections[index], newSections[target]] = [newSections[target], newSections[index]]
|
||||||
|
return newSections
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadScopeTemplate = async (template) => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope_detail&id=${template.id}`)
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
const tpl = result.data
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
scope_description: tpl.description || prev.scope_description
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (tpl.sections) {
|
||||||
|
const newSections = tpl.sections.map(s => ({
|
||||||
|
_key: `sec-${++_keyCounter}`,
|
||||||
|
title: s.title || '',
|
||||||
|
title_cz: s.title_cz || '',
|
||||||
|
content: s.content || ''
|
||||||
|
}))
|
||||||
|
setSections(prev => [...prev, ...newSections])
|
||||||
|
}
|
||||||
|
alert.success(`Načtena šablona "${template.name}"`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Nepodařilo se načíst šablonu')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const handleSave = async () => {
|
||||||
|
const newErrors = {}
|
||||||
|
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
|
||||||
|
if (!form.created_at) newErrors.created_at = 'Zadejte datum'
|
||||||
|
if (!form.valid_until) newErrors.valid_until = 'Zadejte datum'
|
||||||
|
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 payload = buildPayload()
|
||||||
|
const url = isEdit
|
||||||
|
? `${API_BASE}/offers.php?id=${id}`
|
||||||
|
: `${API_BASE}/offers.php`
|
||||||
|
|
||||||
|
const response = await apiFetch(url, {
|
||||||
|
method: isEdit ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert.success(result.message || (isEdit ? 'Nabídka byla uložena' : 'Nabídka byla vytvořena'))
|
||||||
|
if (!isEdit && result.data?.id) {
|
||||||
|
clearDraft()
|
||||||
|
const newId = result.data.id
|
||||||
|
setTimeout(() => navigate(`/offers/${newId}`, { replace: true }), 300)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert.error(result.error || 'Nepodařilo se uložit nabídku')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
quotation: {
|
||||||
|
project_code: form.project_code,
|
||||||
|
customer_id: form.customer_id,
|
||||||
|
created_at: form.created_at,
|
||||||
|
valid_until: form.valid_until,
|
||||||
|
currency: form.currency,
|
||||||
|
language: form.language,
|
||||||
|
vat_rate: form.vat_rate,
|
||||||
|
apply_vat: form.apply_vat,
|
||||||
|
exchange_rate: form.exchange_rate || null,
|
||||||
|
exchange_rate_date: form.exchange_rate_date || null,
|
||||||
|
scope_title: form.scope_title,
|
||||||
|
scope_description: form.scope_description
|
||||||
|
},
|
||||||
|
items: items.map((item, i) => ({
|
||||||
|
...item,
|
||||||
|
position: i + 1
|
||||||
|
})),
|
||||||
|
sections: sections.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
position: i + 1
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading, saving, errors, setErrors,
|
||||||
|
form, updateForm, items, setItems, sections,
|
||||||
|
customers, itemTemplates, scopeTemplates,
|
||||||
|
orderInfo, offerStatus, setOfferStatus,
|
||||||
|
totals, draftSavedAtLabel, clearDraft,
|
||||||
|
selectCustomer, clearCustomer,
|
||||||
|
updateItem, addItem, removeItem, addItemFromTemplate,
|
||||||
|
addSection, removeSection, updateSection, moveSection,
|
||||||
|
loadScopeTemplate, handleSave
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import { useAlert } from '../context/AlertContext'
|
import { useAlert } from '../context/AlertContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import Forbidden from '../components/Forbidden'
|
import Forbidden from '../components/Forbidden'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
import AdminDatePicker from '../components/AdminDatePicker'
|
import AdminDatePicker from '../components/AdminDatePicker'
|
||||||
import BulkAttendanceModal from '../components/BulkAttendanceModal'
|
import BulkAttendanceModal from '../components/BulkAttendanceModal'
|
||||||
import ShiftFormModal from '../components/ShiftFormModal'
|
import ShiftFormModal from '../components/ShiftFormModal'
|
||||||
|
import AttendanceShiftTable from '../components/AttendanceShiftTable'
|
||||||
import useModalLock from '../hooks/useModalLock'
|
import useModalLock from '../hooks/useModalLock'
|
||||||
import apiFetch from '../utils/api'
|
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
|
||||||
import {
|
import { formatMinutes } from '../utils/attendanceHelpers'
|
||||||
formatDate, formatDatetime, formatTime,
|
|
||||||
calculateWorkMinutes, formatMinutes,
|
|
||||||
getLeaveTypeName, getLeaveTypeBadgeClass,
|
|
||||||
getDatePart, getTimePart,
|
|
||||||
calcProjectMinutesTotal, calcFormWorkMinutes,
|
|
||||||
formatTimeOrDatetimePrint, calculateWorkMinutesPrint
|
|
||||||
} from '../utils/attendanceHelpers'
|
|
||||||
|
|
||||||
const API_BASE = '/api/admin'
|
|
||||||
|
|
||||||
function getFundBarBackground(data) {
|
function getFundBarBackground(data) {
|
||||||
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
|
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
|
||||||
@@ -28,160 +17,29 @@ function getFundBarBackground(data) {
|
|||||||
return 'var(--gradient)'
|
return 'var(--gradient)'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBreak(record) {
|
|
||||||
if (record.break_start && record.break_end) {
|
|
||||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
|
|
||||||
}
|
|
||||||
if (record.break_start) {
|
|
||||||
return `${formatTime(record.break_start)} - ?`
|
|
||||||
}
|
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProjectCell(record) {
|
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
|
|
||||||
{record.project_logs.map((log, i) => {
|
|
||||||
let h, m, isActive = false
|
|
||||||
if (log.hours !== null && log.hours !== undefined) {
|
|
||||||
h = parseInt(log.hours) || 0
|
|
||||||
m = parseInt(log.minutes) || 0
|
|
||||||
} else {
|
|
||||||
isActive = !log.ended_at
|
|
||||||
const end = log.ended_at ? new Date(log.ended_at) : new Date()
|
|
||||||
const mins = Math.floor((end - new Date(log.started_at)) / 60000)
|
|
||||||
h = Math.floor(mins / 60)
|
|
||||||
m = mins % 60
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
|
|
||||||
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (record.project_name) {
|
|
||||||
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
|
|
||||||
}
|
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFundStatus(userData) {
|
|
||||||
if (userData.overtime > 0) {
|
|
||||||
return <span className="leave-badge badge-overtime">+{userData.overtime}h přesčas</span>
|
|
||||||
}
|
|
||||||
if (userData.missing > 0) {
|
|
||||||
return <span style={{ color: '#dc2626' }}>−{userData.missing}h</span>
|
|
||||||
}
|
|
||||||
return <span style={{ color: '#16a34a' }}>splněno</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AttendanceAdmin() {
|
export default function AttendanceAdmin() {
|
||||||
const alert = useAlert()
|
const alert = useAlert()
|
||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [month, setMonth] = useState(() => {
|
|
||||||
const now = new Date()
|
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
||||||
})
|
|
||||||
const [filterUserId, setFilterUserId] = useState('')
|
|
||||||
const [data, setData] = useState({
|
|
||||||
records: [],
|
|
||||||
users: [],
|
|
||||||
user_totals: {},
|
|
||||||
leave_balances: {}
|
|
||||||
})
|
|
||||||
const [printData, setPrintData] = useState(null)
|
|
||||||
const printRef = useRef(null)
|
|
||||||
|
|
||||||
const [showBulkModal, setShowBulkModal] = useState(false)
|
const {
|
||||||
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
loading, month, setMonth,
|
||||||
const [bulkForm, setBulkForm] = useState({
|
filterUserId, setFilterUserId,
|
||||||
month: '',
|
data, hasData,
|
||||||
user_ids: [],
|
showBulkModal, setShowBulkModal,
|
||||||
arrival_time: '08:00',
|
bulkSubmitting, bulkForm, setBulkForm,
|
||||||
departure_time: '16:30',
|
showCreateModal, setShowCreateModal,
|
||||||
break_start_time: '12:00',
|
createForm, setCreateForm,
|
||||||
break_end_time: '12:30'
|
showEditModal, setShowEditModal,
|
||||||
})
|
editingRecord, editForm, setEditForm,
|
||||||
|
deleteConfirm, setDeleteConfirm,
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
projectList,
|
||||||
const today = new Date().toISOString().split('T')[0]
|
createProjectLogs, setCreateProjectLogs,
|
||||||
const [createForm, setCreateForm] = useState({
|
editProjectLogs, setEditProjectLogs,
|
||||||
user_id: '',
|
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
|
||||||
shift_date: today,
|
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
|
||||||
leave_type: 'work',
|
openEditModal, handleEditSubmit,
|
||||||
leave_hours: 8,
|
handleDelete, handlePrint
|
||||||
arrival_date: today,
|
} = useAttendanceAdmin({ alert })
|
||||||
arrival_time: '',
|
|
||||||
break_start_date: today,
|
|
||||||
break_start_time: '',
|
|
||||||
break_end_date: today,
|
|
||||||
break_end_time: '',
|
|
||||||
departure_date: today,
|
|
||||||
departure_time: '',
|
|
||||||
notes: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
|
||||||
const [editingRecord, setEditingRecord] = useState(null)
|
|
||||||
const [editForm, setEditForm] = useState({
|
|
||||||
shift_date: '',
|
|
||||||
leave_type: 'work',
|
|
||||||
leave_hours: 8,
|
|
||||||
arrival_date: '',
|
|
||||||
arrival_time: '',
|
|
||||||
break_start_date: '',
|
|
||||||
break_start_time: '',
|
|
||||||
break_end_date: '',
|
|
||||||
break_end_time: '',
|
|
||||||
departure_date: '',
|
|
||||||
departure_time: '',
|
|
||||||
notes: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, record: null })
|
|
||||||
const [projectList, setProjectList] = useState([])
|
|
||||||
const [createProjectLogs, setCreateProjectLogs] = useState([])
|
|
||||||
const [editProjectLogs, setEditProjectLogs] = useState([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadProjects = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_BASE}/attendance.php?action=projects`)
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.success) setProjectList(result.data.projects || [])
|
|
||||||
} catch { /* silent */ }
|
|
||||||
}
|
|
||||||
loadProjects()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchData = useCallback(async (showLoading = true) => {
|
|
||||||
if (showLoading) setLoading(true)
|
|
||||||
try {
|
|
||||||
let url = `${API_BASE}/attendance.php?action=admin&month=${month}`
|
|
||||||
if (filterUserId) {
|
|
||||||
url += `&user_id=${filterUserId}`
|
|
||||||
}
|
|
||||||
const response = await apiFetch(url)
|
|
||||||
if (response.status === 401) return
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.success) {
|
|
||||||
setData(result.data)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Nepodařilo se načíst data')
|
|
||||||
} finally {
|
|
||||||
if (showLoading) setLoading(false)
|
|
||||||
}
|
|
||||||
}, [month, filterUserId, alert])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData()
|
|
||||||
}, [fetchData])
|
|
||||||
|
|
||||||
useModalLock(showBulkModal)
|
useModalLock(showBulkModal)
|
||||||
useModalLock(showEditModal)
|
useModalLock(showEditModal)
|
||||||
@@ -189,388 +47,6 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
if (!hasPermission('attendance.admin')) return <Forbidden />
|
if (!hasPermission('attendance.admin')) return <Forbidden />
|
||||||
|
|
||||||
// --- Create modal ---
|
|
||||||
const openCreateModal = () => {
|
|
||||||
const todayDate = new Date().toISOString().split('T')[0]
|
|
||||||
setCreateForm({
|
|
||||||
user_id: '',
|
|
||||||
shift_date: todayDate,
|
|
||||||
leave_type: 'work',
|
|
||||||
leave_hours: 8,
|
|
||||||
arrival_date: todayDate,
|
|
||||||
arrival_time: '',
|
|
||||||
break_start_date: todayDate,
|
|
||||||
break_start_time: '',
|
|
||||||
break_end_date: todayDate,
|
|
||||||
break_end_time: '',
|
|
||||||
departure_date: todayDate,
|
|
||||||
departure_time: '',
|
|
||||||
notes: '',
|
|
||||||
project_id: ''
|
|
||||||
})
|
|
||||||
setCreateProjectLogs([])
|
|
||||||
setShowCreateModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateShiftDateChange = (newDate) => {
|
|
||||||
setCreateForm({
|
|
||||||
...createForm,
|
|
||||||
shift_date: newDate,
|
|
||||||
arrival_date: newDate,
|
|
||||||
break_start_date: newDate,
|
|
||||||
break_end_date: newDate,
|
|
||||||
departure_date: newDate
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateSubmit = async () => {
|
|
||||||
if (!createForm.user_id || !createForm.shift_date) {
|
|
||||||
alert.error('Vyplňte zaměstnance a datum směny')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredCreateLogs = createProjectLogs.filter(l => l.project_id)
|
|
||||||
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
|
|
||||||
const totalWork = calcFormWorkMinutes(createForm)
|
|
||||||
const totalProject = calcProjectMinutesTotal(filteredCreateLogs)
|
|
||||||
if (totalWork > 0 && totalProject !== totalWork) {
|
|
||||||
const wH = Math.floor(totalWork / 60)
|
|
||||||
const wM = totalWork % 60
|
|
||||||
const pH = Math.floor(totalProject / 60)
|
|
||||||
const pM = totalProject % 60
|
|
||||||
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = { ...createForm }
|
|
||||||
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
|
|
||||||
payload.project_logs = filteredCreateLogs
|
|
||||||
}
|
|
||||||
const response = await apiFetch(`${API_BASE}/attendance.php?action=create`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setShowCreateModal(false)
|
|
||||||
await fetchData(false)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300))
|
|
||||||
alert.success(result.message)
|
|
||||||
} else {
|
|
||||||
alert.error(result.error)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Chyba připojení')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bulk modal ---
|
|
||||||
const openBulkModal = () => {
|
|
||||||
setBulkForm({
|
|
||||||
month: month,
|
|
||||||
user_ids: data.users.map(u => String(u.id)),
|
|
||||||
arrival_time: '08:00',
|
|
||||||
departure_time: '16:30',
|
|
||||||
break_start_time: '12:00',
|
|
||||||
break_end_time: '12:30'
|
|
||||||
})
|
|
||||||
setShowBulkModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleBulkUser = (userId) => {
|
|
||||||
const id = String(userId)
|
|
||||||
setBulkForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
user_ids: prev.user_ids.includes(id)
|
|
||||||
? prev.user_ids.filter(u => u !== id)
|
|
||||||
: [...prev.user_ids, id]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAllBulkUsers = () => {
|
|
||||||
const allIds = data.users.map(u => String(u.id))
|
|
||||||
setBulkForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
user_ids: prev.user_ids.length === allIds.length ? [] : allIds
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkSubmit = async () => {
|
|
||||||
if (!bulkForm.month) {
|
|
||||||
alert.error('Vyberte měsíc')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (bulkForm.user_ids.length === 0) {
|
|
||||||
alert.error('Vyberte alespoň jednoho zaměstnance')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setBulkSubmitting(true)
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_BASE}/attendance.php?action=bulk_attendance`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(bulkForm)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setShowBulkModal(false)
|
|
||||||
await fetchData(false)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300))
|
|
||||||
alert.success(result.message)
|
|
||||||
} else {
|
|
||||||
alert.error(result.error)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Chyba připojení')
|
|
||||||
} finally {
|
|
||||||
setBulkSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Edit modal ---
|
|
||||||
const openEditModal = (record) => {
|
|
||||||
setEditingRecord(record)
|
|
||||||
setEditForm({
|
|
||||||
shift_date: record.shift_date,
|
|
||||||
leave_type: record.leave_type || 'work',
|
|
||||||
leave_hours: record.leave_hours || 8,
|
|
||||||
arrival_date: getDatePart(record.arrival_time) || record.shift_date,
|
|
||||||
arrival_time: getTimePart(record.arrival_time),
|
|
||||||
break_start_date: getDatePart(record.break_start) || record.shift_date,
|
|
||||||
break_start_time: getTimePart(record.break_start),
|
|
||||||
break_end_date: getDatePart(record.break_end) || record.shift_date,
|
|
||||||
break_end_time: getTimePart(record.break_end),
|
|
||||||
departure_date: getDatePart(record.departure_time) || record.shift_date,
|
|
||||||
departure_time: getTimePart(record.departure_time),
|
|
||||||
notes: record.notes || '',
|
|
||||||
project_id: record.project_id || ''
|
|
||||||
})
|
|
||||||
const logs = (record.project_logs || []).map(l => {
|
|
||||||
if (l.hours !== null && l.hours !== undefined) {
|
|
||||||
return {
|
|
||||||
project_id: String(l.project_id),
|
|
||||||
hours: String(l.hours),
|
|
||||||
minutes: String(l.minutes || 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (l.started_at && l.ended_at) {
|
|
||||||
const mins = Math.max(0, Math.floor((new Date(l.ended_at) - new Date(l.started_at)) / 60000))
|
|
||||||
return {
|
|
||||||
project_id: String(l.project_id),
|
|
||||||
hours: String(Math.floor(mins / 60)),
|
|
||||||
minutes: String(mins % 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { project_id: String(l.project_id), hours: '', minutes: '' }
|
|
||||||
})
|
|
||||||
setEditProjectLogs(logs)
|
|
||||||
setShowEditModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditSubmit = async () => {
|
|
||||||
const isWork = (editForm.leave_type || 'work') === 'work'
|
|
||||||
const filteredEditLogs = isWork ? editProjectLogs.filter(l => l.project_id) : []
|
|
||||||
if (filteredEditLogs.length > 0) {
|
|
||||||
const totalWork = calcFormWorkMinutes(editForm)
|
|
||||||
const totalProject = calcProjectMinutesTotal(filteredEditLogs)
|
|
||||||
if (totalWork > 0 && totalProject !== totalWork) {
|
|
||||||
const wH = Math.floor(totalWork / 60)
|
|
||||||
const wM = totalWork % 60
|
|
||||||
const pH = Math.floor(totalProject / 60)
|
|
||||||
const pM = totalProject % 60
|
|
||||||
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = { ...editForm }
|
|
||||||
payload.project_logs = filteredEditLogs
|
|
||||||
const response = await apiFetch(`${API_BASE}/attendance.php?id=${editingRecord.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setShowEditModal(false)
|
|
||||||
await fetchData(false)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300))
|
|
||||||
alert.success(result.message)
|
|
||||||
} else {
|
|
||||||
alert.error(result.error)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Chyba připojení')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Delete ---
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteConfirm.record) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_BASE}/attendance.php?id=${deleteConfirm.record.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setDeleteConfirm({ show: false, record: null })
|
|
||||||
await fetchData(false)
|
|
||||||
alert.success(result.message)
|
|
||||||
} else {
|
|
||||||
alert.error(result.error)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Chyba připojení')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Print ---
|
|
||||||
const handlePrint = async () => {
|
|
||||||
try {
|
|
||||||
let url = `${API_BASE}/attendance.php?action=print&month=${month}`
|
|
||||||
if (filterUserId) {
|
|
||||||
url += `&user_id=${filterUserId}`
|
|
||||||
}
|
|
||||||
const response = await apiFetch(url)
|
|
||||||
if (response.status === 401) return
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.success) {
|
|
||||||
setPrintData(result.data)
|
|
||||||
setTimeout(() => {
|
|
||||||
if (printRef.current) {
|
|
||||||
const printWindow = window.open('', '_blank')
|
|
||||||
printWindow.document.write(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Docházka - ${result.data.month_name}</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #000;
|
|
||||||
background: #fff;
|
|
||||||
padding: 15mm;
|
|
||||||
}
|
|
||||||
.print-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; }
|
|
||||||
.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; }
|
|
||||||
.leave-summary {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 8px 15px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
body { padding: 0; margin: 0; }
|
|
||||||
@page { size: A4 portrait; margin: 10mm; }
|
|
||||||
.user-section { page-break-inside: avoid; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${DOMPurify.sanitize(printRef.current.innerHTML)}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`)
|
|
||||||
printWindow.document.close()
|
|
||||||
printWindow.onload = () => {
|
|
||||||
printWindow.print()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Nepodařilo se připravit tisk')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasData = Object.keys(data.user_totals).length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -761,98 +237,12 @@ export default function AttendanceAdmin() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && data.records.length === 0 && (
|
{!loading && (
|
||||||
<div className="admin-empty-state">
|
<AttendanceShiftTable
|
||||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
records={data.records}
|
||||||
</div>
|
onEdit={openEditModal}
|
||||||
)}
|
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||||
{!loading && data.records.length > 0 && (
|
/>
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>Zaměstnanec</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Příchod</th>
|
|
||||||
<th>Pauza</th>
|
|
||||||
<th>Odchod</th>
|
|
||||||
<th>Hodiny</th>
|
|
||||||
<th>Projekt</th>
|
|
||||||
<th>GPS</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.records.map((record) => {
|
|
||||||
const leaveType = record.leave_type || 'work'
|
|
||||||
const isLeave = leaveType !== 'work'
|
|
||||||
const workMinutes = isLeave
|
|
||||||
? (record.leave_hours || 8) * 60
|
|
||||||
: calculateWorkMinutes(record)
|
|
||||||
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={record.id}>
|
|
||||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
|
||||||
<td>{record.user_name}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
|
|
||||||
{getLeaveTypeName(leaveType)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{isLeave ? '—' : formatBreak(record)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
|
|
||||||
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '—'}</td>
|
|
||||||
<td>
|
|
||||||
{renderProjectCell(record)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{hasLocation ? (
|
|
||||||
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
|
|
||||||
📍
|
|
||||||
</Link>
|
|
||||||
) : '—'}
|
|
||||||
</td>
|
|
||||||
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
|
|
||||||
{record.notes || ''}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(record)}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm({ show: true, record })}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -906,131 +296,6 @@ export default function AttendanceAdmin() {
|
|||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hidden Print Content */}
|
|
||||||
{printData && (
|
|
||||||
<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">{printData.month_name}</div>
|
|
||||||
{printData.selected_user_name && <div className="filters">Zaměstnanec: {printData.selected_user_name}</div>}
|
|
||||||
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>
|
|
||||||
|
|
||||||
{Object.entries(printData.user_totals).map(([userId, userData]) => (
|
|
||||||
<div key={userId} className="user-section">
|
|
||||||
<div className="user-header">
|
|
||||||
<h3>{userData.name}</h3>
|
|
||||||
<span className="total">Odpracováno: {formatMinutes(userData.minutes)} h</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{printData.leave_balances[userId] && (
|
|
||||||
<div className="leave-summary">
|
|
||||||
<strong>Dovolená {printData.year}:</strong> Zbývá {printData.leave_balances[userId].vacation_remaining.toFixed(1)}h z {printData.leave_balances[userId].vacation_total}h
|
|
||||||
{userData.vacation_hours > 0 && <> | <span className="leave-badge badge-vacation">Tento měsíc: {userData.vacation_hours}h</span></>}
|
|
||||||
{userData.sick_hours > 0 && <> | <span className="leave-badge badge-sick">Nemoc: {userData.sick_hours}h</span></>}
|
|
||||||
{userData.holiday_hours > 0 && <> | <span className="leave-badge badge-holiday">Svátek: {userData.holiday_hours}h</span></>}
|
|
||||||
{userData.overtime > 0 && <> | <span className="leave-badge badge-overtime">Přesčas: +{userData.overtime}h</span></>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: '70px' }}>Datum</th>
|
|
||||||
<th style={{ width: '70px' }}>Typ</th>
|
|
||||||
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
|
|
||||||
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
|
|
||||||
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
|
|
||||||
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
|
|
||||||
<th>Projekty</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{userData.records.map((record) => {
|
|
||||||
const leaveType = record.leave_type || 'work'
|
|
||||||
const isLeave = leaveType !== 'work'
|
|
||||||
const workMinutes = calculateWorkMinutesPrint(record)
|
|
||||||
const hours = Math.floor(workMinutes / 60)
|
|
||||||
const mins = workMinutes % 60
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={record.id}>
|
|
||||||
<td>{formatDate(record.shift_date)}</td>
|
|
||||||
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
|
|
||||||
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
|
|
||||||
<td className="text-center">
|
|
||||||
{isLeave || !record.break_start || !record.break_end
|
|
||||||
? '—'
|
|
||||||
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
|
|
||||||
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
|
|
||||||
<td style={{ fontSize: '8px' }}>
|
|
||||||
{(record.project_logs && record.project_logs.length > 0)
|
|
||||||
? record.project_logs.map((log, i) => {
|
|
||||||
let h, m
|
|
||||||
if (log.hours !== null && log.hours !== undefined) {
|
|
||||||
h = parseInt(log.hours) || 0; m = parseInt(log.minutes) || 0
|
|
||||||
} else if (log.started_at && log.ended_at) {
|
|
||||||
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000))
|
|
||||||
h = Math.floor(mins2 / 60); m = mins2 % 60
|
|
||||||
} else { h = 0; m = 0 }
|
|
||||||
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
|
|
||||||
})
|
|
||||||
: record.project_name || '—'}
|
|
||||||
</td>
|
|
||||||
<td>{record.notes || ''}</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="text-right">Odpracováno:</td>
|
|
||||||
<td className="text-center">{formatMinutes(userData.minutes)} h</td>
|
|
||||||
<td colSpan={2}></td>
|
|
||||||
</tr>
|
|
||||||
{userData.fund !== null && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="text-right">Fond měsíce:</td>
|
|
||||||
<td className="text-center">{userData.covered}h / {userData.fund}h</td>
|
|
||||||
<td colSpan={2}>
|
|
||||||
{renderFundStatus(userData)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{Object.keys(printData.user_totals).length === 0 && (
|
|
||||||
<p style={{ textAlign: 'center', padding: '20px' }}>Za vybrané období nejsou žádné záznamy.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAlert } from '../context/AlertContext'
|
import { useAlert } from '../context/AlertContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
@@ -9,28 +9,12 @@ import Forbidden from '../components/Forbidden'
|
|||||||
import AdminDatePicker from '../components/AdminDatePicker'
|
import AdminDatePicker from '../components/AdminDatePicker'
|
||||||
import OfferItemsSection from '../components/OfferItemsSection'
|
import OfferItemsSection from '../components/OfferItemsSection'
|
||||||
import OfferScopeSection from '../components/OfferScopeSection'
|
import OfferScopeSection from '../components/OfferScopeSection'
|
||||||
|
import OfferCustomerPicker from '../components/OfferCustomerPicker'
|
||||||
import useModalLock from '../hooks/useModalLock'
|
import useModalLock from '../hooks/useModalLock'
|
||||||
|
import useOfferForm from '../hooks/useOfferForm'
|
||||||
import apiFetch from '../utils/api'
|
import apiFetch from '../utils/api'
|
||||||
const API_BASE = '/api/admin'
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
let _keyCounter = 0
|
|
||||||
const emptyItem = () => ({
|
|
||||||
_key: `item-${++_keyCounter}`,
|
|
||||||
description: '',
|
|
||||||
item_description: '',
|
|
||||||
quantity: 1,
|
|
||||||
unit: '',
|
|
||||||
unit_price: 0,
|
|
||||||
is_included_in_total: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const emptySection = () => ({
|
|
||||||
_key: `sec-${++_keyCounter}`,
|
|
||||||
title: '',
|
|
||||||
title_cz: '',
|
|
||||||
content: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function OfferDetail() {
|
export default function OfferDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
@@ -38,361 +22,35 @@ export default function OfferDetail() {
|
|||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [loading, setLoading] = useState(isEdit)
|
const {
|
||||||
const [saving, setSaving] = useState(false)
|
loading, saving, errors, setErrors,
|
||||||
const [errors, setErrors] = useState({})
|
form, updateForm, items, setItems, sections,
|
||||||
const [customers, setCustomers] = useState([])
|
customers, itemTemplates, scopeTemplates,
|
||||||
const [customerSearch, setCustomerSearch] = useState('')
|
orderInfo, offerStatus, setOfferStatus,
|
||||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
|
totals, draftSavedAtLabel,
|
||||||
const [itemTemplates, setItemTemplates] = useState([])
|
selectCustomer, clearCustomer,
|
||||||
const [scopeTemplates, setScopeTemplates] = useState([])
|
updateItem, addItem, removeItem, addItemFromTemplate,
|
||||||
|
addSection, removeSection, updateSection, moveSection,
|
||||||
|
loadScopeTemplate, handleSave
|
||||||
|
} = useOfferForm({ id, isEdit, alert, navigate })
|
||||||
|
|
||||||
const [showItemTemplateMenu, setShowItemTemplateMenu] = useState(false)
|
const [showItemTemplateMenu, setShowItemTemplateMenu] = useState(false)
|
||||||
const [showScopeTemplateMenu, setShowScopeTemplateMenu] = useState(false)
|
const [showScopeTemplateMenu, setShowScopeTemplateMenu] = useState(false)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
quotation_number: '',
|
|
||||||
project_code: '',
|
|
||||||
customer_id: null,
|
|
||||||
customer_name: '',
|
|
||||||
created_at: new Date().toISOString().split('T')[0],
|
|
||||||
valid_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
|
||||||
currency: 'EUR',
|
|
||||||
language: 'EN',
|
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: false,
|
|
||||||
exchange_rate: '',
|
|
||||||
exchange_rate_date: '',
|
|
||||||
scope_title: '',
|
|
||||||
scope_description: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const [items, setItems] = useState([emptyItem()])
|
|
||||||
const [sections, setSections] = useState([])
|
|
||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [orderInfo, setOrderInfo] = useState(null)
|
|
||||||
const [creatingOrder, setCreatingOrder] = useState(false)
|
const [creatingOrder, setCreatingOrder] = useState(false)
|
||||||
const [showOrderModal, setShowOrderModal] = useState(false)
|
const [showOrderModal, setShowOrderModal] = useState(false)
|
||||||
const [offerStatus, setOfferStatus] = useState('active')
|
|
||||||
const [invalidateConfirm, setInvalidateConfirm] = useState(false)
|
const [invalidateConfirm, setInvalidateConfirm] = useState(false)
|
||||||
const [invalidatingOffer, setInvalidatingOffer] = useState(false)
|
const [invalidatingOffer, setInvalidatingOffer] = useState(false)
|
||||||
useModalLock(showOrderModal)
|
|
||||||
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
|
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
|
||||||
const [orderAttachment, setOrderAttachment] = useState(null)
|
const [orderAttachment, setOrderAttachment] = useState(null)
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false)
|
||||||
|
|
||||||
const DRAFT_KEY = 'boha_offer_draft'
|
useModalLock(showOrderModal)
|
||||||
const [draftSavedAt, setDraftSavedAt] = useState(null)
|
|
||||||
const draftDataRef = useRef({ form, items, sections })
|
|
||||||
const draftRestoredRef = useRef(false)
|
|
||||||
|
|
||||||
// Fetch customers + templates on mount
|
const isInvalidated = offerStatus === 'invalidated'
|
||||||
useEffect(() => {
|
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
|
||||||
const fetchMeta = async () => {
|
|
||||||
try {
|
|
||||||
const [custRes, itemTplRes, scopeTplRes] = await Promise.all([
|
|
||||||
apiFetch(`${API_BASE}/customers.php`),
|
|
||||||
apiFetch(`${API_BASE}/offers-templates.php?action=items`),
|
|
||||||
apiFetch(`${API_BASE}/offers-templates.php?action=scopes`)
|
|
||||||
])
|
|
||||||
const custData = await custRes.json()
|
|
||||||
const itemTplData = await itemTplRes.json()
|
|
||||||
const scopeTplData = await scopeTplRes.json()
|
|
||||||
|
|
||||||
if (custData.success) setCustomers(custData.data.customers)
|
|
||||||
if (itemTplData.success) setItemTemplates(itemTplData.data.templates)
|
|
||||||
if (scopeTplData.success) setScopeTemplates(scopeTplData.data.templates)
|
|
||||||
} catch { /* silent */ }
|
|
||||||
}
|
|
||||||
fetchMeta()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Restore draft on mount (new offers only)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEdit) return
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(DRAFT_KEY)
|
|
||||||
if (!raw) return
|
|
||||||
const draft = JSON.parse(raw)
|
|
||||||
if (!draft || typeof draft !== 'object' || !draft.form || !Array.isArray(draft.items)) {
|
|
||||||
localStorage.removeItem(DRAFT_KEY)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { form: dForm, items: dItems, sections: dSections, savedAt } = draft
|
|
||||||
setForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
project_code: dForm.project_code ?? prev.project_code,
|
|
||||||
customer_id: dForm.customer_id ?? prev.customer_id,
|
|
||||||
customer_name: dForm.customer_name ?? prev.customer_name,
|
|
||||||
created_at: dForm.created_at ?? prev.created_at,
|
|
||||||
valid_until: dForm.valid_until ?? prev.valid_until,
|
|
||||||
currency: dForm.currency ?? prev.currency,
|
|
||||||
language: dForm.language ?? prev.language,
|
|
||||||
vat_rate: dForm.vat_rate ?? prev.vat_rate,
|
|
||||||
apply_vat: dForm.apply_vat ?? prev.apply_vat,
|
|
||||||
exchange_rate: dForm.exchange_rate ?? prev.exchange_rate,
|
|
||||||
exchange_rate_date: dForm.exchange_rate_date ?? prev.exchange_rate_date,
|
|
||||||
scope_title: dForm.scope_title ?? prev.scope_title,
|
|
||||||
scope_description: dForm.scope_description ?? prev.scope_description,
|
|
||||||
}))
|
|
||||||
if (dItems.length) setItems(dItems.map(i => ({ ...i, _key: i._key || `item-${++_keyCounter}` })))
|
|
||||||
if (Array.isArray(dSections) && dSections.length) setSections(dSections.map(s => ({ ...s, _key: s._key || `sec-${++_keyCounter}` })))
|
|
||||||
draftRestoredRef.current = true
|
|
||||||
if (savedAt) setDraftSavedAt(new Date(savedAt))
|
|
||||||
} catch {
|
|
||||||
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}, [isEdit])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
draftDataRef.current = { form, items, sections }
|
|
||||||
}, [form, items, sections])
|
|
||||||
|
|
||||||
// Auto-save draft (jen nove nabidky)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEdit) return
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const { form: f, items: it, sections: sc } = draftDataRef.current
|
|
||||||
const { quotation_number: _qn, ...formWithoutNumber } = f
|
|
||||||
const savedAt = new Date().toISOString()
|
|
||||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: formWithoutNumber, items: it, sections: sc, savedAt }))
|
|
||||||
setDraftSavedAt(new Date(savedAt))
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, 500)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [form, items, sections, isEdit])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEdit) {
|
|
||||||
const fetchNextNumber = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_BASE}/offers.php?action=next_number`)
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.success) {
|
|
||||||
setForm(prev => ({ ...prev, quotation_number: result.data.number }))
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch default settings
|
|
||||||
const fetchDefaults = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_BASE}/company-settings.php`)
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.success && !draftRestoredRef.current) {
|
|
||||||
const s = result.data
|
|
||||||
setForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
currency: s.default_currency || prev.currency,
|
|
||||||
vat_rate: s.default_vat_rate || prev.vat_rate
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchNextNumber()
|
|
||||||
fetchDefaults()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchDetail = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_BASE}/offers.php?action=detail&id=${id}`)
|
|
||||||
if (response.status === 401) return
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.success) {
|
|
||||||
const q = result.data
|
|
||||||
setForm({
|
|
||||||
quotation_number: q.quotation_number || '',
|
|
||||||
project_code: q.project_code || '',
|
|
||||||
customer_id: q.customer_id || null,
|
|
||||||
customer_name: q.customer_name || '',
|
|
||||||
created_at: (q.created_at || '').substring(0, 10),
|
|
||||||
valid_until: (q.valid_until || '').substring(0, 10),
|
|
||||||
currency: q.currency || 'EUR',
|
|
||||||
language: q.language || 'EN',
|
|
||||||
vat_rate: q.vat_rate || 21,
|
|
||||||
apply_vat: Boolean(q.apply_vat),
|
|
||||||
exchange_rate: q.exchange_rate || '',
|
|
||||||
exchange_rate_date: q.exchange_rate_date || '',
|
|
||||||
scope_title: q.scope_title || '',
|
|
||||||
scope_description: q.scope_description || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
if (q.items?.length) {
|
|
||||||
setItems(q.items.map(item => ({
|
|
||||||
_key: `item-${++_keyCounter}`,
|
|
||||||
description: item.description || '',
|
|
||||||
item_description: item.item_description || '',
|
|
||||||
quantity: Number(item.quantity) || 1,
|
|
||||||
unit: item.unit || '',
|
|
||||||
unit_price: Number(item.unit_price) || 0,
|
|
||||||
is_included_in_total: Boolean(item.is_included_in_total)
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (q.sections?.length) {
|
|
||||||
setSections(q.sections.map(s => ({
|
|
||||||
_key: `sec-${++_keyCounter}`,
|
|
||||||
title: s.title || '',
|
|
||||||
title_cz: s.title_cz || '',
|
|
||||||
content: s.content || ''
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
setOrderInfo(q.order || null)
|
|
||||||
setOfferStatus(q.status || 'active')
|
|
||||||
} else {
|
|
||||||
alert.error(result.error || 'Nepodařilo se načíst nabídku')
|
|
||||||
navigate('/offers')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Chyba připojení')
|
|
||||||
navigate('/offers')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchDetail()
|
|
||||||
}, [isEdit, id, alert, navigate])
|
|
||||||
|
|
||||||
// Close customer dropdown on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = () => setShowCustomerDropdown(false)
|
|
||||||
if (showCustomerDropdown) {
|
|
||||||
document.addEventListener('click', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('click', handleClickOutside)
|
|
||||||
}
|
|
||||||
}, [showCustomerDropdown])
|
|
||||||
|
|
||||||
// Calculated totals
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const subtotal = items.reduce((sum, item) => {
|
|
||||||
if (item.is_included_in_total) {
|
|
||||||
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
|
||||||
}
|
|
||||||
return sum
|
|
||||||
}, 0)
|
|
||||||
const vatAmount = form.apply_vat ? subtotal * ((Number(form.vat_rate) || 0) / 100) : 0
|
|
||||||
return { subtotal, vatAmount, total: subtotal + vatAmount }
|
|
||||||
}, [items, form.apply_vat, form.vat_rate])
|
|
||||||
|
|
||||||
// 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])
|
|
||||||
|
|
||||||
// Draft helpers
|
|
||||||
const clearDraft = useCallback(() => {
|
|
||||||
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
|
||||||
setDraftSavedAt(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const draftSavedAtLabel = useMemo(() => {
|
|
||||||
if (!draftSavedAt) return null
|
|
||||||
return draftSavedAt.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}, [draftSavedAt])
|
|
||||||
|
|
||||||
// Form handlers
|
|
||||||
const updateForm = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
|
||||||
|
|
||||||
const selectCustomer = (customer) => {
|
|
||||||
setForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
customer_id: customer.id,
|
|
||||||
customer_name: customer.name
|
|
||||||
}))
|
|
||||||
setErrors(prev => ({ ...prev, customer_id: undefined }))
|
|
||||||
setCustomerSearch('')
|
|
||||||
setShowCustomerDropdown(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearCustomer = () => {
|
|
||||||
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Items handlers
|
|
||||||
const updateItem = (index, field, value) => {
|
|
||||||
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
|
|
||||||
}
|
|
||||||
|
|
||||||
const addItem = () => setItems(prev => [...prev, emptyItem()])
|
|
||||||
|
|
||||||
const removeItem = (index) => {
|
|
||||||
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addItemFromTemplate = (template) => {
|
|
||||||
setItems(prev => [...prev, {
|
|
||||||
_key: `item-${++_keyCounter}`,
|
|
||||||
description: template.name || '',
|
|
||||||
item_description: template.description || '',
|
|
||||||
quantity: 1,
|
|
||||||
unit: '',
|
|
||||||
unit_price: Number(template.default_price) || 0,
|
|
||||||
is_included_in_total: true
|
|
||||||
}])
|
|
||||||
setShowItemTemplateMenu(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sections handlers
|
|
||||||
const addSection = () => setSections(prev => [...prev, emptySection()])
|
|
||||||
|
|
||||||
const removeSection = (index) => {
|
|
||||||
setSections(prev => prev.filter((_, i) => i !== index))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSection = (index, field, value) => {
|
|
||||||
setSections(prev => prev.map((s, i) => i === index ? { ...s, [field]: value } : s))
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveSection = (index, direction) => {
|
|
||||||
setSections(prev => {
|
|
||||||
const newSections = [...prev]
|
|
||||||
const target = index + direction
|
|
||||||
if (target < 0 || target >= newSections.length) return prev
|
|
||||||
;[newSections[index], newSections[target]] = [newSections[target], newSections[index]]
|
|
||||||
return newSections
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadScopeTemplate = async (template) => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope_detail&id=${template.id}`)
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.success) {
|
|
||||||
// Load template-level fields into the quotation form
|
|
||||||
const tpl = result.data
|
|
||||||
setForm(prev => ({
|
|
||||||
...prev,
|
|
||||||
scope_description: tpl.description || prev.scope_description
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Load sections
|
|
||||||
if (tpl.sections) {
|
|
||||||
const newSections = tpl.sections.map(s => ({
|
|
||||||
_key: `sec-${++_keyCounter}`,
|
|
||||||
title: s.title || '',
|
|
||||||
title_cz: s.title_cz || '',
|
|
||||||
content: s.content || ''
|
|
||||||
}))
|
|
||||||
setSections(prev => [...prev, ...newSections])
|
|
||||||
}
|
|
||||||
alert.success(`Načtena šablona "${template.name}"`)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Nepodařilo se načíst šablonu')
|
|
||||||
}
|
|
||||||
setShowScopeTemplateMenu(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateOrder = async () => {
|
const handleCreateOrder = async () => {
|
||||||
if (!customerOrderNumber.trim()) {
|
if (!customerOrderNumber.trim()) {
|
||||||
@@ -426,79 +84,6 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save
|
|
||||||
const handleSave = async () => {
|
|
||||||
const newErrors = {}
|
|
||||||
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
|
|
||||||
if (!form.created_at) newErrors.created_at = 'Zadejte datum'
|
|
||||||
if (!form.valid_until) newErrors.valid_until = 'Zadejte datum'
|
|
||||||
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 payload = {
|
|
||||||
quotation: {
|
|
||||||
project_code: form.project_code,
|
|
||||||
customer_id: form.customer_id,
|
|
||||||
created_at: form.created_at,
|
|
||||||
valid_until: form.valid_until,
|
|
||||||
currency: form.currency,
|
|
||||||
language: form.language,
|
|
||||||
vat_rate: form.vat_rate,
|
|
||||||
apply_vat: form.apply_vat,
|
|
||||||
exchange_rate: form.exchange_rate || null,
|
|
||||||
exchange_rate_date: form.exchange_rate_date || null,
|
|
||||||
scope_title: form.scope_title,
|
|
||||||
scope_description: form.scope_description
|
|
||||||
},
|
|
||||||
items: items.map((item, i) => ({
|
|
||||||
...item,
|
|
||||||
position: i + 1
|
|
||||||
})),
|
|
||||||
sections: sections.map((s, i) => ({
|
|
||||||
...s,
|
|
||||||
position: i + 1
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = isEdit
|
|
||||||
? `${API_BASE}/offers.php?id=${id}`
|
|
||||||
: `${API_BASE}/offers.php`
|
|
||||||
|
|
||||||
const response = await apiFetch(url, {
|
|
||||||
method: isEdit ? 'PUT' : 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
alert.success(result.message || (isEdit ? 'Nabídka byla uložena' : 'Nabídka byla vytvořena'))
|
|
||||||
if (!isEdit && result.data?.id) {
|
|
||||||
clearDraft()
|
|
||||||
const newId = result.data.id
|
|
||||||
setTimeout(() => navigate(`/offers/${newId}`, { replace: true }), 300)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert.error(result.error || 'Nepodařilo se uložit nabídku')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert.error('Chyba připojení')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pdfLoading, setPdfLoading] = useState(false)
|
|
||||||
|
|
||||||
const isInvalidated = offerStatus === 'invalidated'
|
|
||||||
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
|
|
||||||
|
|
||||||
const handleInvalidateOffer = async () => {
|
const handleInvalidateOffer = async () => {
|
||||||
setInvalidatingOffer(true)
|
setInvalidatingOffer(true)
|
||||||
try {
|
try {
|
||||||
@@ -520,13 +105,6 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRequiredPerm = () => {
|
|
||||||
if (!isEdit) return 'offers.create'
|
|
||||||
return isInvalidated ? 'offers.view' : 'offers.edit'
|
|
||||||
}
|
|
||||||
const requiredPerm = getRequiredPerm()
|
|
||||||
if (!hasPermission(requiredPerm)) return <Forbidden />
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
@@ -573,6 +151,13 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRequiredPerm = () => {
|
||||||
|
if (!isEdit) return 'offers.create'
|
||||||
|
return isInvalidated ? 'offers.view' : 'offers.edit'
|
||||||
|
}
|
||||||
|
const requiredPerm = getRequiredPerm()
|
||||||
|
if (!hasPermission(requiredPerm)) return <Forbidden />
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||||
@@ -755,54 +340,15 @@ export default function OfferDetail() {
|
|||||||
readOnly={isInvalidated}
|
readOnly={isInvalidated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`admin-form-group${errors.customer_id ? ' has-error' : ''}`}>
|
<OfferCustomerPicker
|
||||||
<label className="admin-form-label required">Zákazník</label>
|
customers={customers}
|
||||||
{form.customer_id && (
|
customerId={form.customer_id}
|
||||||
<div className="offers-customer-selected">
|
customerName={form.customer_name}
|
||||||
<span>{form.customer_name}</span>
|
onSelect={selectCustomer}
|
||||||
{!isInvalidated && (
|
onClear={clearCustomer}
|
||||||
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka">
|
error={errors.customer_id}
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
readOnly={isInvalidated}
|
||||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!form.customer_id && !isInvalidated && (
|
|
||||||
<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, 10).map(c => (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className="offers-customer-dropdown-item"
|
|
||||||
onMouseDown={() => selectCustomer(c)}
|
|
||||||
>
|
|
||||||
<div>{c.name}</div>
|
|
||||||
{c.city && <div>{c.city}</div>}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{errors.customer_id && <span className="admin-form-error">{errors.customer_id}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
@@ -849,10 +395,10 @@ export default function OfferDetail() {
|
|||||||
className="admin-form-select"
|
className="admin-form-select"
|
||||||
disabled={isInvalidated}
|
disabled={isInvalidated}
|
||||||
>
|
>
|
||||||
<option value="EUR">EUR (€)</option>
|
<option value="EUR">EUR (€)</option>
|
||||||
<option value="USD">USD ($)</option>
|
<option value="USD">USD ($)</option>
|
||||||
<option value="CZK">CZK (Kč)</option>
|
<option value="CZK">CZK (Kč)</option>
|
||||||
<option value="GBP">GBP (£)</option>
|
<option value="GBP">GBP (£)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-form-group">
|
<div className="admin-form-group">
|
||||||
|
|||||||
85
src/admin/utils/dashboardHelpers.js
Normal file
85
src/admin/utils/dashboardHelpers.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export const LEAVE_TYPE_LABELS = {
|
||||||
|
vacation: 'Dovolená',
|
||||||
|
sick: 'Nemoc',
|
||||||
|
holiday: 'Svátek',
|
||||||
|
unpaid: 'Neplacené volno',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_DOT_CLASS = {
|
||||||
|
in: 'dash-status-in',
|
||||||
|
away: 'dash-status-away',
|
||||||
|
out: 'dash-status-out',
|
||||||
|
leave: 'dash-status-leave',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_LABELS = {
|
||||||
|
in: 'Přítomen',
|
||||||
|
away: 'Přestávka',
|
||||||
|
out: 'Nepřihlášen',
|
||||||
|
leave: 'Nepřítomen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_LABELS = {
|
||||||
|
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_LABELS = {
|
||||||
|
create: 'Vytvořil',
|
||||||
|
update: 'Upravil',
|
||||||
|
delete: 'Smazal',
|
||||||
|
login: 'Přihlášení',
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ACTION_LABELS }
|
||||||
|
|
||||||
|
export function getCzechDate() {
|
||||||
|
const now = new Date()
|
||||||
|
const days = ['Neděle', 'Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota']
|
||||||
|
const months = ['ledna', 'února', 'března', 'dubna', 'května', 'června', 'července', 'srpna', 'září', 'října', 'listopadu', 'prosince']
|
||||||
|
const day = days[now.getDay()]
|
||||||
|
const oneJan = new Date(now.getFullYear(), 0, 1)
|
||||||
|
const week = Math.ceil(((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7)
|
||||||
|
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivityIconClass(action) {
|
||||||
|
const map = { create: 'success', update: 'info', delete: 'danger', login: 'accent' }
|
||||||
|
return map[action] || 'muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatActivityTime(dateString) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
if (diff < 60000) {
|
||||||
|
return 'Právě teď'
|
||||||
|
}
|
||||||
|
if (diff < 3600000) {
|
||||||
|
return `${Math.floor(diff / 60000)} min`
|
||||||
|
}
|
||||||
|
if (date.toDateString() === now.toDateString()) {
|
||||||
|
return date.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('cs-CZ', { day: '2-digit', month: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSessionDate(dateString) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('cs-CZ', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user