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,4 +1,3 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
@@ -8,29 +7,26 @@ import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseReservationListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type WarehouseReservation,
} from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
import useReducedMotion from "../hooks/useReducedMotion";
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
issue: "Výdej",
};
const TYPE_TONE: Record<string, string> = {
receipt: "admin-badge-incoming",
issue: "admin-badge-outgoing",
};
export default function Warehouse() {
const { hasPermission } = useAuth();
const reducedMotion = useReducedMotion();
const { data: stockItems, isPending: stockPending } = useQuery(
warehouseStockStatusOptions(),
@@ -42,27 +38,9 @@ export default function Warehouse() {
warehouseReservationListOptions({ status: "ACTIVE", perPage: 1 }),
);
const [recentMovements, setRecentMovements] = useState<MovementLogRow[]>([]);
const [movementsLoading, setMovementsLoading] = useState(true);
useEffect(() => {
async function fetchMovements() {
try {
const response = await apiFetch(
"/api/admin/warehouse/reports/movement-log?limit=10",
);
const result = await response.json();
if (result.success) {
setRecentMovements(result.data ?? []);
}
} catch {
// Non-fatal, show empty
} finally {
setMovementsLoading(false);
}
}
fetchMovements();
}, []);
const { data: recentMovements = [], isPending: movementsLoading } = useQuery(
warehouseMovementLogOptions({ limit: 10 }),
);
if (!hasPermission("warehouse.view")) return <Forbidden />;
@@ -95,6 +73,52 @@ export default function Warehouse() {
<p className="admin-page-subtitle">Přehled skladových zásob</p>
</div>
<div className="admin-page-actions">
{hasPermission("warehouse.operate") && (
<>
<Link
to="/warehouse/receipts/new"
className="admin-btn admin-btn-primary"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
Nový příjem
</Link>
<Link
to="/warehouse/issues/new"
className="admin-btn admin-btn-secondary"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
Nový výdej
</Link>
</>
)}
{hasPermission("warehouse.manage") && (
<Link
to="/warehouse/categories"
className="admin-btn admin-btn-secondary"
>
Kategorie
</Link>
)}
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary">
Položky
</Link>
@@ -108,135 +132,64 @@ export default function Warehouse() {
</motion.div>
{/* KPI cards */}
<div
className="dash-kpi-cards"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "1rem",
marginBottom: "1.5rem",
}}
>
<div className="admin-kpi-grid admin-kpi-4">
<motion.div
className="admin-card"
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.06,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Celkem položek
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : totalItems}
</div>
<div className="admin-stat-label">Celkem položek</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : totalItems}
</div>
</motion.div>
<motion.div
className="admin-card"
className="admin-stat-card success"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.08,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Hodnota zásob
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
</div>
<div className="admin-stat-label">Hodnota zásob</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
</div>
</motion.div>
<motion.div
className="admin-card"
className={`admin-stat-card ${belowMinimumCount > 0 ? "danger" : "warning"}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
style={
belowMinimumCount > 0
? { border: "2px solid var(--danger)" }
: undefined
}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.1,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color:
belowMinimumCount > 0
? "var(--danger)"
: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Pod minimem
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color:
belowMinimumCount > 0
? "var(--danger)"
: "var(--text-primary)",
}}
>
{isLoading ? "—" : belowMinimumCount}
</div>
<div className="admin-stat-label">Pod minimem</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : belowMinimumCount}
</div>
</motion.div>
<motion.div
className="admin-card"
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.12,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Aktivní rezervace
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : activeReservationCount}
</div>
<div className="admin-stat-label">Aktivní rezervace</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : activeReservationCount}
</div>
</motion.div>
</div>
@@ -244,11 +197,13 @@ export default function Warehouse() {
{/* Below minimum alert */}
{belowMin.length > 0 && (
<motion.div
className="admin-card"
className="admin-card admin-card-danger"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.14 }}
style={{ border: "2px solid var(--danger)" }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.14,
}}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
@@ -261,29 +216,24 @@ export default function Warehouse() {
Reporty &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th className="text-right">K dispozici</th>
<th className="text-right">Min. množství</th>
<th>K dispozici</th>
<th>Min. množství</th>
</tr>
</thead>
<tbody>
{belowMin.map((item) => (
<tr key={item.id}>
<tr key={item.id} className="admin-warehouse-row-danger">
<td className="fw-500">{item.name}</td>
<td
className="admin-mono text-right"
style={{ color: "var(--danger)" }}
>
<td className="admin-mono admin-warehouse-danger-value">
{item.available_quantity ?? 0}
</td>
<td className="admin-mono text-right">
{item.min_quantity ?? 0}
</td>
<td className="admin-mono">{item.min_quantity ?? 0}</td>
</tr>
))}
</tbody>
@@ -298,8 +248,10 @@ export default function Warehouse() {
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.16 }}
style={{ marginTop: "1.5rem" }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.16,
}}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední pohyby</h2>
@@ -310,28 +262,13 @@ export default function Warehouse() {
Vše &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="admin-card-body">
{movementsLoading ? (
<div
style={{
textAlign: "center",
padding: "2rem",
color: "var(--text-tertiary)",
}}
>
<div className="admin-spinner admin-spinner-sm" />
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : recentMovements.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "2rem",
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
>
Žádné nedávné pohyby
</div>
<div className="admin-empty-state">Žádné nedávné pohyby</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
@@ -341,17 +278,17 @@ export default function Warehouse() {
<th>Typ</th>
<th>Doklad</th>
<th>Položka</th>
<th className="text-right">Množství</th>
<th>Množství</th>
<th>Dodavatel / Projekt</th>
</tr>
</thead>
<tbody>
{recentMovements.map((row, i) => (
<tr key={i}>
<td className="admin-mono">{row.date}</td>
<td className="admin-mono">{formatDate(row.date)}</td>
<td>
<span
className={`admin-badge ${row.type === "receipt" ? "admin-badge-active" : "admin-badge-info"}`}
className={`admin-badge ${TYPE_TONE[row.type] ?? ""}`}
>
{TYPE_LABEL[row.type] ?? row.type}
</span>
@@ -360,7 +297,7 @@ export default function Warehouse() {
{row.document_number || "—"}
</td>
<td className="fw-500">{row.item_name}</td>
<td className="admin-mono text-right">{row.quantity}</td>
<td className="admin-mono">{row.quantity}</td>
<td>{row.supplier_or_project || "—"}</td>
</tr>
))}