Files
app/src/admin/pages/AttendanceBalances.tsx
BOHA 5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00

845 lines
32 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 } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { useQuery } 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 { useApiMutation } from "../lib/queries/mutations";
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 [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: "" });
if (!hasPermission("attendance.balances")) return <Forbidden />;
const editMutation = useApiMutation<
{
user_id: string;
year: number;
action_type: "edit";
vacation_total: number;
vacation_used: number;
sick_used: number;
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const resetMutation = useApiMutation<
{ user_id: string; year: number; action_type: "reset" },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
onSuccess: (data) => {
setResetConfirm({ show: false, userId: null, userName: "" });
alert.success(data?.message || "Operace dokončena");
},
});
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 result = await editMutation.mutateAsync({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
});
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleReset = async () => {
if (!resetConfirm.userId) return;
try {
await resetMutation.mutateAsync({
user_id: resetConfirm.userId,
year,
action_type: "reset",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "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">
{balancesPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{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>
)}
</>
)}
</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 && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* 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 && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* Edit Modal */}
<FormModal
isOpen={showEditModal && !!editingUser}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit dovolenou"
loading={editMutation.isPending}
>
{editingUser && (
<>
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
>
{editingUser.name}
</p>
<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>
</>
)}
</FormModal>
{/* 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"
loading={resetMutation.isPending}
/>
</div>
);
}