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

@@ -1,11 +1,12 @@
import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import {
tripListOptions,
tripVehiclesOptions,
@@ -21,12 +22,9 @@ import {
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
interface Trip {
@@ -76,7 +74,6 @@ function mapTrip(bt: BackendTrip): Trip {
export default function TripsAdmin() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1),
);
@@ -125,10 +122,43 @@ export default function TripsAdmin() {
);
const trips = (tripsData ?? []).map(mapTrip);
useModalLock(showEditModal);
if (!hasPermission("trips.manage")) return <Forbidden />;
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly —
// NOT a wrapper that nests the body. id is captured in the URL closure.
const editMutation = useApiMutation<
{
id: number;
vehicle_id: string;
trip_date: string;
start_km: string | number;
end_km: string | number;
route_from: string;
route_to: string;
is_business: number;
notes: string;
},
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/trips/${id}`,
method: () => "PUT",
invalidate: ["trips", "vehicles"],
});
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/trips/${id}`,
method: () => "DELETE",
invalidate: ["trips", "vehicles"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, trip: null });
alert.success(data?.message || "Smazáno");
},
});
const openEditModal = (trip: Trip) => {
setEditingTrip(trip);
setEditForm({
@@ -154,48 +184,30 @@ export default function TripsAdmin() {
}
try {
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
const result = await editMutation.mutateAsync({
id: editingTrip.id,
vehicle_id: editForm.vehicle_id,
trip_date: editForm.trip_date,
start_km: editForm.start_km,
end_km: editForm.end_km,
route_from: editForm.route_from,
route_to: editForm.route_to,
is_business: editForm.is_business,
notes: editForm.notes,
});
const result = await response.json();
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.trip) return;
try {
const response = await apiFetch(
`${API_BASE}/trips/${deleteConfirm.trip.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, trip: null });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deleteConfirm.trip.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -517,11 +529,11 @@ export default function TripsAdmin() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Skeleton
name="trips-admin"
loading={isPending}
fixture={<TripsAdminFixture />}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{trips.length === 0 && (
<div className="admin-empty-state">
@@ -627,190 +639,152 @@ export default function TripsAdmin() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingTrip && (
<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 admin-modal-lg"
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 && !!editingTrip}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit jízdu"
loading={editMutation.isPending}
size="lg"
>
{editingTrip && (
<div className="admin-form">
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit jízdu</h2>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
{editingTrip.driver_name}
</p>
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{editingTrip.driver_name}
</p>
</div>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
</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="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
)}
</AnimatePresence>
</FormModal>
{/* Delete Confirmation */}
<ConfirmModal
@@ -825,6 +799,7 @@ export default function TripsAdmin() {
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
{/* Hidden Print Content */}