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