Files
app/src/admin/pages/WarehouseReservations.tsx
BOHA ca092c6166 fix(ui): unify colored-control text — white on all filled, both themes
The text color on filled colored controls was inconsistent: contained
primary resolved to white in both schemes, but error/success/warning/info
used a per-scheme contrastText (#fff light, #1a1a1a dark). So in DARK mode
a primary (red) button showed white text while an error (red) button —
e.g. every ConfirmDialog delete confirm, the contained "Smazat", and the
dashboard quick-action tiles — showed near-black text. "Black text on one
red button, white on another."

Unify on a single rule: every FILLED colored surface gets WHITE text/glyph
in BOTH themes. The palette .main stays bright in dark mode (it drives text,
outlined buttons and row tints), but a bright fill makes white illegible, so
filled surfaces drop to a darker fill in dark mode (new FILLED_DARK_BG, ≈ the
light-scheme shades; all ≥4.5:1 with white):
- theme MuiButton: contained error/success/warning/info -> white text +
  darker dark-mode fill (disabled state preserved).
- theme MuiChip: filled error/success/warning/info -> white label + darker
  dark-mode fill (covers StatusChip + status pills).
- StatCard icon badges: white glyph + darker dark-mode tile.

Plus two consistency cleanups:
- Detail-page "Smazat" unified to variant=outlined color=error (Offer +
  Invoice now match Order/Project/Warehouse; the contained->confirm pattern
  stays: subtle trigger, strong contained confirm dialog).
- "Zrušit filtry" reset buttons -> color=inherit (neutral), matching the
  rest of the secondary-button family (were default outlined-primary).

Neutral (color=inherit) text/outlined buttons were already correct (label =
ambient text color) and are untouched. tsc -b --noEmit, npm run build and
vitest 152/152 all clean.

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

490 lines
13 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, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import ItemPicker from "../components/warehouse/ItemPicker";
import apiFetch from "../utils/api";
import { formatDate, czechPlural } from "../utils/formatters";
import {
warehouseReservationListOptions,
type WarehouseReservation,
} from "../lib/queries/warehouse";
import { projectListOptions } from "../lib/queries/projects";
import {
Button,
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const PER_PAGE = 20;
const STATUS_COLOR: Record<
string,
"success" | "error" | "warning" | "info" | "default"
> = {
ACTIVE: "success",
FULFILLED: "info",
CANCELLED: "error",
};
const STATUS_LABEL: Record<string, string> = {
ACTIVE: "Aktivní",
FULFILLED: "Splněna",
CANCELLED: "Zrušena",
};
const PlusIcon = (
<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>
);
const IssueIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
);
const CancelIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
interface ReservationForm {
item_id: number | null;
project_id: number | null;
quantity: number;
notes: string;
}
export default function WarehouseReservations() {
const navigate = useNavigate();
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
const [projectId, setProjectId] = useState<number | "">("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ReservationForm>({
item_id: null,
project_id: null,
quantity: 0,
notes: "",
});
const [cancelTarget, setCancelTarget] = useState<WarehouseReservation | null>(
null,
);
const [cancelling, setCancelling] = useState(false);
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects =
(projectsData?.data as {
id: number;
project_number: string;
name: string;
}[]) ?? [];
const { items, pagination, isPending, isFetching } =
usePaginatedQuery<WarehouseReservation>(
warehouseReservationListOptions({
page,
perPage: PER_PAGE,
status: statusFilter || undefined,
project_id: projectId ? Number(projectId) : undefined,
}),
);
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const resetFilters = () => {
setStatusFilter("");
setProjectId("");
setPage(1);
};
const hasActiveFilters = statusFilter || projectId;
const handleCreate = async () => {
if (!form.item_id || !form.project_id || form.quantity <= 0) {
alert.error("Vyplňte položku, projekt a množství");
return;
}
setSaving(true);
try {
const response = await apiFetch("/api/admin/warehouse/reservations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
item_id: form.item_id,
project_id: form.project_id,
quantity: form.quantity,
notes: form.notes.trim() || null,
}),
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
setShowCreateModal(false);
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
alert.success("Rezervace byla vytvořena");
} else {
alert.error(result.error || "Nepodařilo se vytvořit rezervaci");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
}
};
const handleCancel = async () => {
if (!cancelTarget) return;
setCancelling(true);
try {
const response = await apiFetch(
`/api/admin/warehouse/reservations/${cancelTarget.id}/cancel`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
setCancelTarget(null);
alert.success("Rezervace byla zrušena");
} else {
alert.error(result.error || "Nepodařilo se zrušit rezervaci");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCancelling(false);
}
};
if (isPending) {
return <LoadingState />;
}
const total = pagination?.total ?? items.length;
const subtitle = `${total} ${czechPlural(total, "rezervace", "rezervace", "rezervací")}`;
const columns: DataColumn<WarehouseReservation>[] = [
{
key: "item",
header: "Položka",
width: "22%",
bold: true,
render: (res) => res.item?.name || `ID: ${res.item_id}`,
},
{
key: "project",
header: "Projekt",
width: "20%",
render: (res) => res.project?.name || "—",
},
{
key: "quantity",
header: "Množství",
width: "10%",
mono: true,
render: (res) => String(Number(res.quantity)),
},
{
key: "remaining_qty",
header: "Zbývá",
width: "10%",
mono: true,
render: (res) => String(Number(res.remaining_qty)),
},
{
key: "status",
header: "Stav",
width: "11%",
render: (res) => (
<StatusChip
label={STATUS_LABEL[res.status] ?? res.status}
color={STATUS_COLOR[res.status] ?? "default"}
/>
),
},
{
key: "reserved_by",
header: "Rezervoval",
width: "15%",
render: (res) =>
res.reserved_by_user
? `${res.reserved_by_user.first_name} ${res.reserved_by_user.last_name}`
: "—",
},
{
key: "created_at",
header: "Vytvořeno",
width: "12%",
mono: true,
render: (res) => formatDate(res.created_at),
},
...(canOperate
? [
{
key: "actions",
header: "Akce",
width: "10%",
align: "right" as const,
render: (res: WarehouseReservation) =>
res.status === "ACTIVE" ? (
<Box
sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}
>
<IconButton
size="small"
onClick={() =>
navigate("/warehouse/issues/new", {
state: {
reservationId: res.id,
itemId: res.item_id,
projectId: res.project_id,
quantity: Number(res.remaining_qty),
itemName: res.item?.name,
},
})
}
aria-label="Vytvořit výdej"
title="Vytvořit výdej"
>
{IssueIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setCancelTarget(res)}
aria-label="Zrušit rezervaci"
title="Zrušit rezervaci"
>
{CancelIcon}
</IconButton>
</Box>
) : null,
},
]
: []),
];
return (
<PageEnter>
<PageHeader
title="Rezervace"
subtitle={subtitle}
actions={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => setShowCreateModal(true)}
>
Nová rezervace
</Button>
) : undefined
}
/>
<FilterBar>
<Box sx={{ flex: "0 0 160px" }}>
<Select
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={[
{ value: "", label: "Všechny stavy" },
{ value: "ACTIVE", label: "Aktivní" },
{ value: "FULFILLED", label: "Splněna" },
{ value: "CANCELLED", label: "Zrušena" },
]}
/>
</Box>
{projects.length > 0 && (
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={projectId === "" ? "" : String(projectId)}
onChange={(val) => {
setProjectId(val === "" ? "" : Number(val));
setPage(1);
}}
options={[
{ value: "", label: "Všechny projekty" },
...projects.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
)}
{hasActiveFilters && (
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</Button>
)}
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<WarehouseReservation>
columns={columns}
rows={items}
rowKey={(res) => res.id}
empty={
hasActiveFilters ? (
<EmptyState title="Žádné rezervace pro zadaný filtr." />
) : (
<EmptyState
title="Zatím nejsou žádné rezervace."
action={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => setShowCreateModal(true)}
>
Vytvořit první rezervaci
</Button>
) : undefined
}
/>
)
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
{/* Create reservation modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
title="Nová rezervace"
submitText="Vytvořit rezervaci"
loading={saving}
>
<Field label="Položka" required>
<ItemPicker
value={form.item_id}
onChange={(id) => setForm((prev) => ({ ...prev, item_id: id }))}
/>
</Field>
<Field label="Projekt" required>
<Select
value={form.project_id === null ? "" : String(form.project_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
project_id: val ? Number(val) : null,
}))
}
options={[
{ value: "", label: "Vyberte projekt" },
...projects.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Field>
<Field label="Množství" required>
<TextField
type="number"
value={form.quantity || ""}
onChange={(e) =>
setForm((prev) => ({
...prev,
quantity: Number(e.target.value),
}))
}
inputProps={{ min: 0, step: 0.001 }}
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
</Field>
</Modal>
{/* Cancel confirmation modal */}
<ConfirmDialog
isOpen={!!cancelTarget}
onClose={() => setCancelTarget(null)}
onConfirm={handleCancel}
title="Zrušit rezervaci"
message={`Opravdu chcete zrušit rezervaci pro položku „${cancelTarget?.item?.name ?? cancelTarget?.item_id}"?`}
confirmText="Zrušit rezervaci"
confirmVariant="danger"
loading={cancelling}
/>
</PageEnter>
);
}