Files
app/src/admin/pages/AttendanceBalances.tsx
BOHA cd6d3daf43 v1.6.5: fix attendance unique constraint, schema sync, seed script
- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 16:21:34 +02:00

892 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
type BalanceEntry,
type BalancesData,
type FundData,
type FundUserData,
type MonthFundData,
type ProjectData,
type UserShort,
} from "../lib/queries/attendance";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
const API_BASE = "/api/admin";
const getVacationClass = (remaining: number): string => {
if (remaining <= 0) return "text-danger";
if (remaining < 20) return "text-warning";
return "";
};
const renderFundDiff = (data: { overtime: number; missing: number }) => {
if (data.overtime > 0) {
return <span className="text-warning fw-600">+{data.overtime}h</span>;
}
if (data.missing > 0) {
return <span className="text-danger">-{data.missing}h</span>;
}
return <span className="text-success">0h</span>;
};
const renderMonthlyStatus = (
us: FundUserData,
isFulfilled: boolean,
isCurrentMonth: boolean,
) => {
if (us.overtime > 0) {
return (
<span className="text-warning fw-600" style={{ fontSize: "11px" }}>
+{us.overtime}h
</span>
);
}
if (us.missing > 0) {
return (
<span className="text-danger fw-600" style={{ fontSize: "11px" }}>
-{us.missing}h
</span>
);
}
if (isFulfilled && !isCurrentMonth) {
return (
<span className="text-success" style={{ fontSize: "11px" }}>
OK
</span>
);
}
return null;
};
const getProgressBackground = (
us: FundUserData,
isFulfilled: boolean,
isCurrentMonth: boolean,
): string => {
if (us.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (isFulfilled) return "linear-gradient(135deg, var(--success), #059669)";
if (isCurrentMonth) return "var(--gradient)";
return "var(--danger)";
};
export default function AttendanceBalances() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [year, setYear] = useState(new Date().getFullYear());
const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundData, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectData, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{
id: string;
name: string;
} | null>(null);
const [editForm, setEditForm] = useState({
vacation_total: 160,
vacation_used: 0,
sick_used: 0,
});
const [resetConfirm, setResetConfirm] = useState<{
show: boolean;
userId: string | null;
userName: string;
}>({ show: false, userId: null, userName: "" });
useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />;
const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name });
setEditForm({
vacation_total: balance.vacation_total,
vacation_used: balance.vacation_used,
sick_used: balance.sick_used,
});
setShowEditModal(true);
};
const handleEditSubmit = async () => {
if (!editingUser) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
}),
},
);
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
const handleReset = async () => {
if (!resetConfirm.userId) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: resetConfirm.userId,
year,
action_type: "reset",
}),
},
);
const result = await response.json();
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
const years: number[] = [];
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
years.push(y);
}
const getYearFundTotals = (userId: string) => {
if (!fundData?.months || Object.keys(fundData.months).length === 0)
return null;
let totalFund = 0;
let totalWorked = 0;
let totalCovered = 0;
for (const monthData of Object.values(fundData.months)) {
// Use prorated fund (fund_to_date) for current month, full fund for past
totalFund += monthData.fund_to_date ?? monthData.fund;
const us = monthData.users?.[userId];
if (us) {
totalWorked += us.worked;
totalCovered += us.covered;
}
}
const missing = Math.max(
0,
Math.round((totalFund - totalCovered) * 10) / 10,
);
const overtime = Math.max(
0,
Math.round((totalCovered - totalFund) * 10) / 10,
);
return {
fund: totalFund,
worked: Math.round(totalWorked * 10) / 10,
covered: Math.round(totalCovered * 10) / 10,
missing,
overtime,
};
};
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa bilancí</h1>
</div>
<div className="admin-page-actions">
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="admin-form-select"
style={{ minWidth: "100px" }}
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<Skeleton
name="attendance-balances"
loading={balancesPending}
fixture={<AttendanceBalancesFixture />}
>
<>
{balancesData &&
Object.keys(balancesData.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{balancesData &&
Object.keys(balancesData.balances).length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Nárok (h)</th>
<th>Čerpáno (h)</th>
<th>Zbývá (h)</th>
<th>Nemoc (h)</th>
<th>Fond roku</th>
<th>Pokryto</th>
<th>+/</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Object.entries(balancesData.balances).map(
([userId, balance]) => {
const yf = getYearFundTotals(userId);
return (
<tr key={userId}>
<td className="fw-500">{balance.name}</td>
<td className="admin-mono">
{balance.vacation_total}
</td>
<td className="admin-mono">
{balance.vacation_used.toFixed(1)}
</td>
<td className="admin-mono">
<span
className={getVacationClass(
balance.vacation_remaining,
)}
>
{balance.vacation_remaining.toFixed(1)}
</span>
</td>
<td className="admin-mono">
{balance.sick_used.toFixed(1)}
</td>
<td className="admin-mono">
{yf ? `${yf.fund}h` : "—"}
</td>
<td className="admin-mono">
{yf ? `${yf.covered}h` : "—"}
</td>
<td className="admin-mono">
{yf ? renderFundDiff(yf) : "—"}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() =>
openEditModal(userId, balance)
}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setResetConfirm({
show: true,
userId,
userName: balance.name,
})
}
className="admin-btn-icon danger"
title="Resetovat"
aria-label="Resetovat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
)}
</>
</Skeleton>
</div>
</motion.div>
{/* Monthly Fund Overview */}
{!fundPending &&
fundData?.months &&
Object.keys(fundData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
className="mt-6"
>
<h2
className="admin-page-title mb-4"
style={{ fontSize: "1.25rem" }}
>
Měsíční přehled fondu {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(fundData.months).map(([monthKey, monthData]) => {
const isCurrentMonth =
year === currentYear && parseInt(monthKey) === currentMonth;
return (
<div
key={monthKey}
className="admin-card"
style={
isCurrentMonth
? {
borderColor: "var(--accent-color)",
boxShadow: "0 0 0 1px var(--accent-color)",
}
: {}
}
>
<div className="admin-card-body">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.75rem",
}}
>
<h3
style={{
fontWeight: 600,
fontSize: "1rem",
margin: 0,
}}
>
{monthData.month_name}
{isCurrentMonth && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.125rem 0.375rem",
background: "var(--accent-light)",
color: "var(--accent-color)",
borderRadius: "var(--border-radius-sm)",
fontWeight: 500,
}}
>
aktuální
</span>
)}
</h3>
<span
className="text-secondary"
style={{ fontSize: "12px" }}
>
{monthData.fund_to_date ?? monthData.fund}h (
{Math.round(
(monthData.fund_to_date ?? monthData.fund) / 8,
)}{" "}
dnů)
</span>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.375rem",
}}
>
{fundData?.users &&
fundData.users.map((user) => {
const us = monthData.users?.[String(user.id)];
if (!us) return null;
const effectiveFund =
monthData.fund_to_date ?? monthData.fund;
const pct =
effectiveFund > 0
? Math.min(
100,
(us.covered / effectiveFund) * 100,
)
: 0;
const isFulfilled = us.covered >= effectiveFund;
return (
<div key={user.id}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "12px",
}}
>
<span
style={{ color: "var(--text-primary)" }}
>
{us.name}
</span>
<span
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<span className="text-secondary">
{us.worked}h
</span>
{renderMonthlyStatus(
us,
isFulfilled,
isCurrentMonth,
)}
</span>
</div>
<div
style={{
marginTop: "0.125rem",
height: "3px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${pct}%`,
background: getProgressBackground(
us,
isFulfilled,
isCurrentMonth,
),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
</motion.div>
)}
{fundPending && (
<Skeleton
name="attendance-balances-fund"
loading={fundPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)}
{/* Monthly Project Overview */}
{!projectPending &&
projectData?.months &&
Object.keys(projectData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
className="mt-6"
>
<h2
className="admin-page-title mb-4"
style={{ fontSize: "1.25rem" }}
>
Měsíční přehled projektů {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(projectData.months).map(
([monthKey, monthInfo]) => {
const isCurrentMonth =
year === currentYear && parseInt(monthKey) === currentMonth;
const totalHours = monthInfo.projects.reduce(
(sum, p) => sum + p.hours,
0,
);
if (monthInfo.projects.length === 0) return null;
return (
<div
key={monthKey}
className="admin-card"
style={
isCurrentMonth
? {
borderColor: "var(--accent-color)",
boxShadow: "0 0 0 1px var(--accent-color)",
}
: {}
}
>
<div className="admin-card-body">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.75rem",
}}
>
<h3
style={{
fontWeight: 600,
fontSize: "1rem",
margin: 0,
}}
>
{monthInfo.month_name}
{isCurrentMonth && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.125rem 0.375rem",
background: "var(--accent-light)",
color: "var(--accent-color)",
borderRadius: "var(--border-radius-sm)",
fontWeight: 500,
}}
>
aktuální
</span>
)}
</h3>
<span
className="text-secondary fw-600"
style={{ fontSize: "12px" }}
>
{totalHours.toFixed(1)}h
</span>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
{monthInfo.projects.map((proj) => (
<div key={proj.project_id || "no-project"}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.25rem",
}}
>
<span
style={{
fontSize: "12px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{proj.project_id
? proj.project_number
: "Bez projektu"}
</span>
<span
className="text-secondary fw-600"
style={{ fontSize: "12px" }}
>
{proj.hours.toFixed(1)}h
</span>
</div>
{proj.project_id && proj.project_name && (
<div
className="text-muted"
style={{
fontSize: "0.7rem",
marginBottom: "0.25rem",
}}
>
{proj.project_name}
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.125rem",
}}
>
{proj.users.map((u) => {
const pct =
proj.hours > 0
? Math.min(
100,
(u.hours / proj.hours) * 100,
)
: 0;
return (
<div key={u.user_id}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "11px",
}}
>
<span className="text-secondary">
{u.user_name}
</span>
<span className="text-secondary">
{u.hours.toFixed(1)}h
</span>
</div>
<div
style={{
marginTop: "1px",
height: "3px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${pct}%`,
background: proj.project_id
? "var(--gradient)"
: "#94a3b8",
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
);
},
)}
</div>
</motion.div>
)}
{projectPending && (
<Skeleton
name="attendance-balances-projects"
loading={projectPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)}
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingUser && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit dovolenou</h2>
<p className="text-secondary" style={{ marginTop: "0.25rem" }}>
{editingUser.name}
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Reset Confirmation */}
<ConfirmModal
isOpen={resetConfirm.show}
onClose={() =>
setResetConfirm({ show: false, userId: null, userName: "" })
}
onConfirm={handleReset}
title="Resetovat bilanci"
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
/>
</div>
);
}