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>
292 lines
7.5 KiB
TypeScript
292 lines
7.5 KiB
TypeScript
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>
|
||
);
|
||
}
|