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