Files
app/src/admin/pages/WarehouseIssueDetail.tsx
BOHA decadd895e fix(ui): stack detail-page header action buttons one-per-row on mobile
Detail-page headers put their action buttons in a flex row with
flexWrap:"wrap", so on a phone the 2-4 buttons (Potvrzení / Zahájit
realizaci / Smazat, etc.) wrapped into a ragged grid.

New shared `headerActionsSx` (ui/PageHeader): on xs it's a full-width
column (each button its own full-width row); from sm up it's the usual
right-aligned wrapping row — desktop unchanged. A StatusChip kept in the
same group stays its natural size (alignSelf) instead of stretching.

Applied to PageHeader's own actions slot and every detail-page header
action group: Order/Offer/Invoice (both views)/Project + the Warehouse
Receipt/Issue/Item/Inventory detail headers.

Verified in Chrome at 430px on OrderDetail: the group is flexDirection
column with all three buttons at the same x, full-width, stacked. tsc -b
--noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:44:22 +02:00

402 lines
10 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 as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseIssueDetailOptions,
type WarehouseIssue,
type WarehouseIssueItem,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
ConfirmDialog,
PageEnter,
headerActionsSx,
type DataColumn,
} from "../ui";
const STATUS_COLOR: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
DRAFT: "warning",
CONFIRMED: "success",
CANCELLED: "error",
};
const STATUS_LABEL: Record<string, string> = {
DRAFT: "Návrh",
CONFIRMED: "Potvrzeno",
CANCELLED: "Zrušeno",
};
const BackIcon = (
<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>
);
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 <LoadingState />;
}
if (error || !issue) {
return (
<Box>
<PageHeader
title="Výdejka"
actions={
<Button
component={RouterLink}
to="/warehouse/issues"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
}
/>
<Card>
<EmptyState title="Výdejka nebyla nalezena." />
</Card>
</Box>
);
}
const iss = issue as WarehouseIssue;
const items = iss.items ?? [];
const itemColumns: DataColumn<WarehouseIssueItem>[] = [
{
key: "item",
header: "Položka",
width: "20%",
bold: true,
render: (row) => row.item?.name || `ID: ${row.item_id}`,
},
{
key: "batch",
header: "Šarže",
width: "18%",
mono: true,
render: (row) =>
row.batch
? `${formatDate(row.batch.received_at)} ${formatCurrency(Number(row.batch.unit_price), "CZK")}`
: "—",
},
{
key: "quantity",
header: "Množství",
width: "10%",
mono: true,
render: (row) => String(Number(row.quantity)),
},
{
key: "unit_price",
header: "Cena/ks",
width: "13%",
mono: true,
render: (row) =>
row.batch ? formatCurrency(Number(row.batch.unit_price), "CZK") : "—",
},
{
key: "total",
header: "Celkem",
width: "13%",
mono: true,
bold: true,
render: (row) =>
row.batch
? formatCurrency(
Number(row.quantity) * Number(row.batch.unit_price),
"CZK",
)
: "—",
},
{
key: "location",
header: "Lokace",
width: "10%",
mono: true,
render: (row) => row.location?.code || "—",
},
{
key: "reservation",
header: "Rezervace",
width: "10%",
mono: true,
render: (row) =>
row.reservation
? `ID: ${row.reservation.id} (${Number(row.reservation.remaining_qty)} ks)`
: "—",
},
{
key: "notes",
header: "Poznámka",
width: "6%",
render: (row) => row.notes || "—",
},
];
return (
<PageEnter>
{/* Header */}
<PageHeader
title={iss.issue_number || "Nová výdejka"}
subtitle={
iss.project
? `${iss.project.project_number} ${iss.project.name}`
: undefined
}
actions={
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/issues"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<StatusChip
label={STATUS_LABEL[iss.status] ?? iss.status}
color={STATUS_COLOR[iss.status] ?? "default"}
/>
{canOperate && iss.status === "DRAFT" && (
<>
<Button
variant="outlined"
color="inherit"
onClick={() => navigate(`/warehouse/issues/${iss.id}/edit`)}
>
Upravit
</Button>
<Button onClick={handleConfirm} disabled={confirming}>
{confirming ? "Potvrzování..." : "Potvrdit"}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
>
Zrušit
</Button>
</>
)}
{canOperate && iss.status === "CONFIRMED" && (
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
>
Zrušit
</Button>
)}
</Box>
}
/>
{/* Basic info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dokladu
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issue_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
{iss.project ? (
<Typography
variant="body2"
component="a"
href={`/projects/${iss.project.id}`}
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
display: "block",
}}
>
{iss.project.project_number} {iss.project.name}
</Typography>
) : (
<Typography variant="body2"></Typography>
)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vydal
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issued_by_user
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
: "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(iss.created_at)}
</Typography>
</Box>
{iss.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{iss.notes}
</Typography>
</Box>
)}
</Box>
</Card>
{/* Lines table */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<WarehouseIssueItem>
columns={itemColumns}
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
{/* Cancel confirmation dialog */}
<ConfirmDialog
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}
/>
</PageEnter>
);
}