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

292 lines
7.5 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 { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import Box from "@mui/material/Box";
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";
import {
Button,
Card,
DataTable,
Pagination,
TextField,
Select,
DateField,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const PER_PAGE = 20;
const STATUS_COLOR: Record<
string,
"warning" | "success" | "error" | "info" | "default"
> = {
DRAFT: "warning",
CONFIRMED: "success",
CANCELLED: "error",
};
const STATUS_LABEL: Record<string, string> = {
DRAFT: "Návrh",
CONFIRMED: "Potvrzeno",
CANCELLED: "Zrušeno",
};
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>
);
export default function WarehouseIssues() {
const { hasPermission } = useAuth();
const navigate = useNavigate();
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,
}),
);
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const resetFilters = () => {
setSearch("");
setStatusFilter("");
setProjectId("");
setDateFrom("");
setDateTo("");
setPage(1);
};
const hasActiveFilters = !!(statusFilter || projectId || dateFrom || dateTo);
if (isPending) {
return <LoadingState />;
}
const total = pagination?.total ?? items.length;
const subtitle = `${total} ${
total === 1 ? "doklad" : total >= 2 && total <= 4 ? "doklady" : "dokladů"
}`;
const columns: DataColumn<WarehouseIssue>[] = [
{
key: "issue_number",
header: "Číslo dokladu",
width: "15%",
mono: true,
bold: true,
render: (r) => r.issue_number || "—",
},
{
key: "project",
header: "Projekt",
width: "25%",
render: (r) =>
r.project ? `${r.project.project_number} ${r.project.name}` : "—",
},
{
key: "status",
header: "Stav",
width: "13%",
render: (r) => (
<StatusChip
label={STATUS_LABEL[r.status] ?? r.status}
color={STATUS_COLOR[r.status] ?? "default"}
/>
),
},
{
key: "issued_by_user",
header: "Vydal",
width: "20%",
render: (r) =>
r.issued_by_user
? `${r.issued_by_user.first_name} ${r.issued_by_user.last_name}`
: "—",
},
{
key: "created_at",
header: "Vytvořeno",
width: "14%",
mono: true,
render: (r) => formatDate(r.created_at),
},
{
key: "items_count",
header: "Položek",
width: "13%",
mono: true,
render: (r) => String(r._count?.items ?? 0),
},
];
return (
<PageEnter>
<PageHeader
title="Výdejky"
subtitle={subtitle}
actions={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => navigate("/warehouse/issues/new")}
>
Nová výdejka
</Button>
) : undefined
}
/>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat podle čísla dokladu..."
fullWidth
/>
</Box>
<Box sx={{ flex: "0 0 160px" }}>
<Select
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={[
{ value: "", label: "Všechny stavy" },
{ value: "DRAFT", label: "Návrh" },
{ value: "CONFIRMED", label: "Potvrzeno" },
{ value: "CANCELLED", label: "Zrušeno" },
]}
/>
</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: Project) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
)}
<Box sx={{ flex: "0 0 150px" }}>
<DateField
value={dateFrom}
onChange={(val) => {
setDateFrom(val);
setPage(1);
}}
label="Od"
/>
</Box>
<Box sx={{ flex: "0 0 150px" }}>
<DateField
value={dateTo}
onChange={(val) => {
setDateTo(val);
setPage(1);
}}
label="Do"
/>
</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<WarehouseIssue>
columns={columns}
rows={items}
rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/warehouse/issues/${r.id}`)}
empty={
search || hasActiveFilters ? (
<EmptyState title="Žádné doklady pro zadaný filtr." />
) : (
<EmptyState
title="Zatím nejsou žádné výdejky."
action={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => navigate("/warehouse/issues/new")}
>
Vytvořit první výdejku
</Button>
) : undefined
}
/>
)
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
</PageEnter>
);
}