Files
app/src/admin/pages/WarehouseIssues.tsx
BOHA 5233db2002 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
2026-06-03 23:13:10 +02:00

333 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import { motion, AnimatePresence } from "framer-motion";
import Pagination from "../components/Pagination";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { formatDate } from "../utils/formatters";
import {
warehouseIssueListOptions,
type WarehouseIssue,
} from "../lib/queries/warehouse";
import { projectListOptions, type Project } from "../lib/queries/projects";
const PER_PAGE = 20;
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
CONFIRMED: "admin-badge-active",
CANCELLED: "admin-badge-danger",
};
const STATUS_LABEL: Record<string, string> = {
DRAFT: "Návrh",
CONFIRMED: "Potvrzeno",
CANCELLED: "Zrušeno",
};
export default function WarehouseIssues() {
const { hasPermission } = useAuth();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [projectId, setProjectId] = useState<number | "">("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const debouncedSearch = useDebounce(search, 300);
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects = projectsData?.data ?? [];
const { items, pagination, isPending, isFetching } =
usePaginatedQuery<WarehouseIssue>(
warehouseIssueListOptions({
search: debouncedSearch || undefined,
page,
perPage: PER_PAGE,
status: statusFilter || undefined,
project_id: projectId ? Number(projectId) : undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
}),
);
const navigate = useNavigate();
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const handleRowClick = (issue: WarehouseIssue) => {
navigate(`/warehouse/issues/${issue.id}`);
};
const resetFilters = () => {
setSearch("");
setStatusFilter("");
setProjectId("");
setDateFrom("");
setDateTo("");
setPage(1);
};
const hasActiveFilters = statusFilter || projectId || dateFrom || dateTo;
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Výdejky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? items.length}{" "}
{pagination?.total === 1
? "doklad"
: pagination?.total !== undefined &&
pagination.total >= 2 &&
pagination.total <= 4
? "doklady"
: "dokladů"}
</p>
</div>
<div className="admin-page-actions">
{canOperate && (
<button
onClick={() => navigate("/warehouse/issues/new")}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nová výdejka
</button>
)}
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<div className="admin-search">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat podle čísla dokladu..."
className="admin-form-input"
/>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="admin-form-select"
>
<option value="">Všechny stavy</option>
<option value="DRAFT">Návrh</option>
<option value="CONFIRMED">Potvrzeno</option>
<option value="CANCELLED">Zrušeno</option>
</select>
{projects.length > 0 && (
<select
value={projectId}
onChange={(e) => {
setProjectId(
e.target.value === "" ? "" : Number(e.target.value),
);
setPage(1);
}}
className="admin-form-select"
>
<option value="">Všechny projekty</option>
{projects.map((p: Project) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
)}
<AdminDatePicker
mode="date"
value={dateFrom}
onChange={(val) => {
setDateFrom(val);
setPage(1);
}}
placeholder="Od"
/>
<AdminDatePicker
mode="date"
value={dateTo}
onChange={(val) => {
setDateTo(val);
setPage(1);
}}
placeholder="Do"
/>
{hasActiveFilters && (
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={resetFilters}
style={{ whiteSpace: "nowrap" }}
>
Zrušit filtry
</button>
)}
</div>
<AnimatePresence mode="wait">
<motion.div
key={`${debouncedSearch}-${statusFilter}-${projectId}-${dateFrom}-${dateTo}-${page}-${items.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{items.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>
{search || hasActiveFilters
? "Žádné doklady pro zadaný filtr."
: "Zatím nejsou žádné výdejky."}
</p>
{canOperate && !search && !hasActiveFilters && (
<button
onClick={() => navigate("/warehouse/issues/new")}
className="admin-btn admin-btn-primary"
>
Vytvořit první výdejku
</button>
)}
</div>
)}
{items.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo dokladu</th>
<th>Projekt</th>
<th>Stav</th>
<th>Vydal</th>
<th>Vytvořeno</th>
<th>Položek</th>
</tr>
</thead>
<tbody>
{items.map((issue) => (
<tr
key={issue.id}
onClick={() => handleRowClick(issue)}
style={{ cursor: "pointer" }}
>
<td className="admin-mono fw-500">
{issue.issue_number || "—"}
</td>
<td>
{issue.project
? `${issue.project.project_number} ${issue.project.name}`
: "—"}
</td>
<td>
<span
className={`admin-badge ${STATUS_BADGE[issue.status] ?? ""}`}
>
{STATUS_LABEL[issue.status] ?? issue.status}
</span>
</td>
<td>
{issue.issued_by_user
? `${issue.issued_by_user.first_name} ${issue.issued_by_user.last_name}`
: "—"}
</td>
<td className="admin-mono">
{formatDate(issue.created_at)}
</td>
<td className="admin-mono">
{issue._count?.items ?? 0}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
</div>
);
}