Files
app/src/admin/pages/WarehouseIssueDetail.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

358 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 { useParams, useNavigate, Link } 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 { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseIssueDetailOptions,
type WarehouseIssue,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
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 WarehouseIssueDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [cancelConfirm, setCancelConfirm] = useState(false);
const {
data: issue,
isPending,
error,
} = useQuery(warehouseIssueDetailOptions(id));
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const confirmMutation = useApiMutation<void, void>({
url: () =>
issue
? `/api/admin/warehouse/issues/${issue.id}/confirm`
: "/api/admin/warehouse/issues/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
const cancelMutation = useApiMutation<void, void>({
url: () =>
issue
? `/api/admin/warehouse/issues/${issue.id}/cancel`
: "/api/admin/warehouse/issues/0/cancel",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setCancelConfirm(false);
alert.success("Výdejka byla zrušena");
},
});
const handleConfirm = async () => {
if (!issue) return;
try {
await confirmMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleCancel = async () => {
if (!issue) return;
try {
await cancelMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
const cancelling = cancelMutation.isPending;
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
if (error || !issue) {
return (
<div>
<div className="admin-page-header">
<div>
<Link
to="/warehouse/issues"
className="admin-btn-icon"
title="Zpět na seznam"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<p>Výdejka nebyla nalezena.</p>
</div>
</div>
</div>
</div>
);
}
const iss = issue as WarehouseIssue;
const items = iss.items ?? [];
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
to="/warehouse/issues"
className="admin-btn-icon"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
{iss.issue_number || "Nová výdejka"}
</h1>
{iss.project && (
<p className="admin-page-subtitle">
{iss.project.project_number} {iss.project.name}
</p>
)}
</div>
<span
className={`admin-badge ${STATUS_BADGE[iss.status] ?? ""}`}
style={{ marginLeft: "0.5rem" }}
>
{STATUS_LABEL[iss.status] ?? iss.status}
</span>
</div>
{canOperate && (
<div className="admin-page-actions">
{iss.status === "DRAFT" && (
<>
<button
onClick={() => navigate(`/warehouse/issues/${iss.id}/edit`)}
className="admin-btn admin-btn-secondary"
>
Upravit
</button>
<button
onClick={handleConfirm}
className="admin-btn admin-btn-primary"
disabled={confirming}
>
{confirming ? "Potvrzování..." : "Potvrdit"}
</button>
<button
onClick={() => setCancelConfirm(true)}
className="admin-btn admin-btn-secondary"
style={{ color: "var(--danger)" }}
>
Zrušit
</button>
</>
)}
{iss.status === "CONFIRMED" && (
<button
onClick={() => setCancelConfirm(true)}
className="admin-btn admin-btn-secondary"
style={{ color: "var(--danger)" }}
>
Zrušit
</button>
)}
</div>
)}
</motion.div>
{/* Header info */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row admin-form-row-3">
<FormField label="Číslo dokladu">
<div className="admin-mono fw-500">
{iss.issue_number || "—"}
</div>
</FormField>
<FormField label="Projekt">
<div>
{iss.project ? (
<a
className="link-accent admin-mono fw-500"
href={`/projects/${iss.project.id}`}
>
{iss.project.project_number} {iss.project.name}
</a>
) : (
"—"
)}
</div>
</FormField>
<FormField label="Vydal">
<div className="admin-mono fw-500">
{iss.issued_by_user
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
: "—"}
</div>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Vytvořeno">
<div className="admin-mono">{formatDate(iss.created_at)}</div>
</FormField>
</div>
{iss.notes && (
<FormField label="Poznámky">
<div style={{ whiteSpace: "pre-wrap" }}>{iss.notes}</div>
</FormField>
)}
</div>
</div>
</motion.div>
{/* Lines table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
{items.length === 0 ? (
<div className="admin-empty-state">Žádné řádky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th>Šarže</th>
<th>Množství</th>
<th>Cena/ks</th>
<th>Celkem</th>
<th>Lokace</th>
<th>Rezervace</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td className="fw-500">
{item.item?.name || `ID: ${item.item_id}`}
</td>
<td className="admin-mono">
{item.batch
? `${formatDate(item.batch.received_at)} ${formatCurrency(Number(item.batch.unit_price), "CZK")}`
: "—"}
</td>
<td className="admin-mono">{Number(item.quantity)}</td>
<td className="admin-mono">
{item.batch
? formatCurrency(Number(item.batch.unit_price), "CZK")
: "—"}
</td>
<td className="admin-mono fw-500">
{item.batch
? formatCurrency(
Number(item.quantity) *
Number(item.batch.unit_price),
"CZK",
)
: "—"}
</td>
<td className="admin-mono">
{item.location?.code || "—"}
</td>
<td className="admin-mono">
{item.reservation
? `ID: ${item.reservation.id} (${Number(item.reservation.remaining_qty)} ks)`
: "—"}
</td>
<td>{item.notes || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Cancel confirmation modal */}
<ConfirmModal
isOpen={cancelConfirm}
onClose={() => setCancelConfirm(false)}
onConfirm={handleCancel}
title="Zrušit výdejku"
message={`Opravdu chcete zrušit výdejku „${iss.issue_number || iss.id}"?`}
confirmText="Zrušit výdejku"
confirmVariant="danger"
loading={cancelling}
/>
</div>
);
}