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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user