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
This commit is contained in:
BOHA
2026-06-03 23:13:10 +02:00
parent dac45baaa8
commit 5233db2002
149 changed files with 11132 additions and 26950 deletions

View File

@@ -2,11 +2,11 @@ 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 { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
@@ -20,9 +20,8 @@ import {
type UserShort,
} from "../lib/queries/attendance";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
const getVacationClass = (remaining: number): string => {
@@ -85,7 +84,6 @@ const getProgressBackground = (
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),
@@ -114,10 +112,37 @@ export default function AttendanceBalances() {
userName: string;
}>({ show: false, userId: null, userName: "" });
useModalLock(showEditModal);
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({
@@ -131,62 +156,29 @@ export default function AttendanceBalances() {
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 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 {
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í");
await resetMutation.mutateAsync({
user_id: resetConfirm.userId,
year,
action_type: "reset",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -263,11 +255,11 @@ export default function AttendanceBalances() {
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<Skeleton
name="attendance-balances"
loading={balancesPending}
fixture={<AttendanceBalancesFixture />}
>
{balancesPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{balancesData &&
Object.keys(balancesData.balances).length === 0 && (
@@ -387,7 +379,7 @@ export default function AttendanceBalances() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
@@ -557,13 +549,9 @@ export default function AttendanceBalances() {
)}
{fundPending && (
<Skeleton
name="attendance-balances-fund"
loading={fundPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* Monthly Project Overview */}
@@ -761,118 +749,82 @@ export default function AttendanceBalances() {
)}
{projectPending && (
<Skeleton
name="attendance-balances-projects"
loading={projectPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* 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 }}
<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" }}
>
<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>
{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>
<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 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>
<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>
</>
)}
</AnimatePresence>
</FormModal>
{/* Reset Confirmation */}
<ConfirmModal
@@ -885,6 +837,7 @@ export default function AttendanceBalances() {
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
loading={resetMutation.isPending}
/>
</div>
);