Files
app/src/admin/pages/AttendanceAdmin.tsx
2026-03-24 19:59:14 +01:00

461 lines
15 KiB
TypeScript

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