feat(ui): list parity — row tinting, received-orders status filter, more sortable cols, search-aware empties, skeleton suppression

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 16:55:50 +02:00
parent 9fe5113c5b
commit 61673247bf
6 changed files with 215 additions and 36 deletions

View File

@@ -61,6 +61,7 @@ export const orderListOptions = (filters: {
order?: string; order?: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
status?: string;
month?: number; month?: number;
year?: number; year?: number;
}) => }) =>
@@ -73,6 +74,7 @@ export const orderListOptions = (filters: {
if (filters.order) params.set("order", filters.order); if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.month) params.set("month", String(filters.month)); if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year)); if (filters.year) params.set("year", String(filters.year));
const qs = params.toString(); const qs = params.toString();

View File

@@ -233,6 +233,9 @@ export default function Invoices() {
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1); const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
const [statsYear, setStatsYear] = useState(now.getFullYear()); const [statsYear, setStatsYear] = useState(now.getFullYear());
const blobUrlRef = useRef<string | null>(null); const blobUrlRef = useRef<string | null>(null);
// Track first successful list load so later refetches (filter/status/month/
// page change) keep the table visible instead of flashing the full skeleton.
const hasLoadedOnce = useRef(false);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -312,6 +315,12 @@ export default function Invoices() {
}), }),
); );
// Mark first load done in an effect (not during render) to avoid a
// render-phase ref mutation.
useEffect(() => {
if (!initialLoad) hasLoadedOnce.current = true;
}, [initialLoad]);
if (!hasPermission("invoices.view")) return <Forbidden />; if (!hasPermission("invoices.view")) return <Forbidden />;
const handleDelete = async () => { const handleDelete = async () => {
@@ -391,7 +400,10 @@ export default function Invoices() {
} }
}; };
if (initialLoad) { // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/status/month/page change) keep the table visible (the
// Card dims via `loading`/isFetching) so it doesn't flash.
if (initialLoad && !hasLoadedOnce.current) {
return <LoadingState />; return <LoadingState />;
} }
@@ -832,6 +844,9 @@ export default function Invoices() {
sortDir={order} sortDir={order}
onSort={handleSort} onSort={handleSort}
empty={ empty={
search || statusFilter ? (
<EmptyState title="Žádné faktury neodpovídají filtru." />
) : (
<EmptyState <EmptyState
title="Zatím nejsou žádné faktury." title="Zatím nejsou žádné faktury."
description={ description={
@@ -840,6 +855,7 @@ export default function Invoices() {
: undefined : undefined
} }
/> />
)
} }
/> />
<Pagination <Pagination

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom"; import { useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -101,6 +101,9 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Track first successful load so later refetches (filter/status/page change)
// keep the table visible instead of flashing the full-page skeleton.
const hasLoadedOnce = useRef(false);
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean; show: boolean;
@@ -138,6 +141,12 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
}), }),
); );
// Mark first load done in an effect (not during render) to avoid a
// render-phase ref mutation.
useEffect(() => {
if (!isPending) hasLoadedOnce.current = true;
}, [isPending]);
if (!hasPermission("orders.view")) return <Forbidden />; if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => { const handleDelete = async () => {
@@ -173,7 +182,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
} }
}; };
if (isPending) { // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/status/page change) keep the table visible (the Card
// dims via isFetching) so it doesn't flash.
if (isPending && !hasLoadedOnce.current) {
return <LoadingState />; return <LoadingState />;
} }
@@ -274,6 +286,32 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
}, },
]; ];
// Per-status row tints — subtle channel-alpha washes (never a solid `.light`
// fill, which is invisible-text in dark mode). Completed = success wash,
// cancelled = faded/muted, matching the Offers/Invoices intensity.
const rowSx = (o: IssuedOrder) => {
if (o.status === "cancelled") {
return {
opacity: 0.6,
"& td": { color: "var(--mui-palette-text-secondary)" },
};
}
if (o.status === "completed") {
return {
backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)",
"&:hover": {
backgroundColor:
"rgba(var(--mui-palette-success-mainChannel) / 0.18)",
},
};
}
return {};
};
// Search/status filter is active → an empty list means "nothing matches"
// (no create CTA); a genuinely empty list keeps the create-CTA empty state.
const isFiltered = !!debouncedSearch || !!status;
return ( return (
<> <>
<FilterBar> <FilterBar>
@@ -305,10 +343,14 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
columns={columns} columns={columns}
rows={orders} rows={orders}
rowKey={(o) => o.id} rowKey={(o) => o.id}
rowSx={rowSx}
sortBy={sort} sortBy={sort}
sortDir={order} sortDir={order}
onSort={handleSort} onSort={handleSort}
empty={ empty={
isFiltered ? (
<EmptyState title="Žádné objednávky neodpovídají filtru." />
) : (
<EmptyState <EmptyState
title="Zatím žádné vydané objednávky." title="Zatím žádné vydané objednávky."
description={ description={
@@ -317,6 +359,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
: undefined : undefined
} }
/> />
)
} }
/> />
<Pagination <Pagination

View File

@@ -240,6 +240,9 @@ export default function Offers() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [customerFilter, setCustomerFilter] = useState<number | "">(""); const [customerFilter, setCustomerFilter] = useState<number | "">("");
// Track first successful load so later refetches (filter/customer/tab/page
// change) keep the table visible instead of flashing the full skeleton.
const hasLoadedOnce = useRef(false);
const { data: customers } = useQuery(offerCustomersOptions()); const { data: customers } = useQuery(offerCustomersOptions());
@@ -303,6 +306,12 @@ export default function Offers() {
}), }),
); );
// Mark first load done in an effect (not during render) to avoid a
// render-phase ref mutation.
useEffect(() => {
if (!isPending) hasLoadedOnce.current = true;
}, [isPending]);
const duplicateMutation = useApiMutation< const duplicateMutation = useApiMutation<
number, number,
{ message?: string; error?: string } { message?: string; error?: string }
@@ -456,7 +465,10 @@ export default function Offers() {
} }
}; };
if (isPending) { // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/customer/tab/page change) keep the table visible (the
// Card dims via isFetching) so it doesn't flash.
if (isPending && !hasLoadedOnce.current) {
return <LoadingState />; return <LoadingState />;
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom"; import { Link as RouterLink, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
@@ -35,10 +35,20 @@ import {
LoadingState, LoadingState,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; import {
ORDER_STATUS,
statusLabel,
statusColor,
statusOptions,
} from "../lib/documentStatus";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
const STATUS_OPTIONS = statusOptions(ORDER_STATUS, {
value: "",
label: "Všechny stavy",
});
interface Order { interface Order {
id: number; id: number;
order_number: string; order_number: string;
@@ -139,7 +149,11 @@ export default function OrdersReceived({
const { sort, order, handleSort } = useTableSort("order_number"); const { sort, order, handleSort } = useTableSort("order_number");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Track first successful load so later refetches (filter/status/page change)
// keep the table visible instead of flashing the full-page skeleton.
const hasLoadedOnce = useRef(false);
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean; show: boolean;
@@ -297,11 +311,18 @@ export default function OrdersReceived({
sort, sort,
order, order,
page, page,
status: status || undefined,
month, month,
year, year,
}), }),
); );
// Mark first load done in an effect (not during render) to avoid a
// render-phase ref mutation.
useEffect(() => {
if (!isPending) hasLoadedOnce.current = true;
}, [isPending]);
if (!hasPermission("orders.view")) return <Forbidden />; if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => { const handleDelete = async () => {
@@ -316,7 +337,10 @@ export default function OrdersReceived({
} }
}; };
if (isPending) { // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/status/page change) keep the table visible (the Card
// dims via isFetching) so it doesn't flash.
if (isPending && !hasLoadedOnce.current) {
return <LoadingState />; return <LoadingState />;
} }
@@ -447,6 +471,32 @@ export default function OrdersReceived({
}, },
]; ];
// Per-status row tints — subtle channel-alpha washes (never a solid `.light`
// fill, which is invisible-text in dark mode). Completed = success wash,
// cancelled (stornovana) = faded/muted, matching the Offers/Invoices intensity.
const rowSx = (o: Order) => {
if (o.status === "stornovana") {
return {
opacity: 0.6,
"& td": { color: "var(--mui-palette-text-secondary)" },
};
}
if (o.status === "dokoncena") {
return {
backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)",
"&:hover": {
backgroundColor:
"rgba(var(--mui-palette-success-mainChannel) / 0.18)",
},
};
}
return {};
};
// Search/status filter is active → an empty list means "nothing matches"
// (no create hint); a genuinely empty list keeps the explanatory empty state.
const isFiltered = !!debouncedSearch || !!status;
return ( return (
<> <>
<FilterBar> <FilterBar>
@@ -461,6 +511,16 @@ export default function OrdersReceived({
fullWidth fullWidth
/> />
</Box> </Box>
<Box sx={{ flex: "0 1 200px" }}>
<Select
value={status}
onChange={(value) => {
setStatus(value);
setPage(1);
}}
options={STATUS_OPTIONS}
/>
</Box>
</FilterBar> </FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}> <Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
@@ -468,14 +528,19 @@ export default function OrdersReceived({
columns={columns} columns={columns}
rows={orders} rows={orders}
rowKey={(o) => o.id} rowKey={(o) => o.id}
rowSx={rowSx}
sortBy={sort} sortBy={sort}
sortDir={order} sortDir={order}
onSort={handleSort} onSort={handleSort}
empty={ empty={
isFiltered ? (
<EmptyState title="Žádné objednávky neodpovídají filtru." />
) : (
<EmptyState <EmptyState
title="Zatím nejsou žádné objednávky." title="Zatím nejsou žádné objednávky."
description="Objednávky se vytvářejí z nabídek." description="Objednávky se vytvářejí z nabídek."
/> />
)
} }
/> />
<Pagination <Pagination

View File

@@ -9,7 +9,12 @@ import MuiTextField from "@mui/material/TextField";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import {
formatCurrency,
formatDate,
czechPlural,
todayLocalStr,
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers"; import { normalizeDateStr } from "../utils/attendanceHelpers";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import { import {
@@ -562,6 +567,37 @@ export default function ReceivedInvoices({
return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0; return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0;
}; };
// Per-status row tints — subtle channel-alpha washes (never a solid `.light`
// fill, which is invisible-text in dark mode). Paid = success wash; unpaid
// past its due date = warning wash (overdue). Compare YYYY-MM-DD strings in
// LOCAL time (lexicographic === chronological) to dodge the UTC off-by-one
// near midnight Prague. Matches the Invoices (issued) overdue tint intensity.
const rowSx = (inv: ReceivedInvoice) => {
if (inv.status === "paid") {
return {
backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)",
"&:hover": {
backgroundColor:
"rgba(var(--mui-palette-success-mainChannel) / 0.18)",
},
};
}
const isOverdue =
inv.status === "unpaid" &&
!!inv.due_date &&
normalizeDateStr(inv.due_date) < todayLocalStr();
if (isOverdue) {
return {
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
"&:hover": {
backgroundColor:
"rgba(var(--mui-palette-warning-mainChannel) / 0.18)",
},
};
}
return {};
};
const columns: DataColumn<ReceivedInvoice>[] = [ const columns: DataColumn<ReceivedInvoice>[] = [
{ {
key: "supplier_name", key: "supplier_name",
@@ -785,10 +821,14 @@ export default function ReceivedInvoices({
columns={columns} columns={columns}
rows={invoices} rows={invoices}
rowKey={(inv) => inv.id} rowKey={(inv) => inv.id}
rowSx={rowSx}
sortBy={sort} sortBy={sort}
sortDir={order} sortDir={order}
onSort={handleSort} onSort={handleSort}
empty={ empty={
search ? (
<EmptyState title="Žádné faktury neodpovídají hledání." />
) : (
<EmptyState <EmptyState
title="Žádné přijaté faktury v tomto měsíci." title="Žádné přijaté faktury v tomto měsíci."
description={ description={
@@ -797,6 +837,7 @@ export default function ReceivedInvoices({
: undefined : undefined
} }
/> />
)
} }
/> />
)} )}