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

View File

@@ -233,6 +233,9 @@ export default function Invoices() {
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
const [statsYear, setStatsYear] = useState(now.getFullYear());
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(() => {
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 />;
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 />;
}
@@ -832,6 +844,9 @@ export default function Invoices() {
sortDir={order}
onSort={handleSort}
empty={
search || statusFilter ? (
<EmptyState title="Žádné faktury neodpovídají filtru." />
) : (
<EmptyState
title="Zatím nejsou žádné faktury."
description={
@@ -840,6 +855,7 @@ export default function Invoices() {
: undefined
}
/>
)
}
/>
<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 Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
@@ -101,6 +101,9 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState("");
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<{
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 />;
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 />;
}
@@ -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 (
<>
<FilterBar>
@@ -305,10 +343,14 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
columns={columns}
rows={orders}
rowKey={(o) => o.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
isFiltered ? (
<EmptyState title="Žádné objednávky neodpovídají filtru." />
) : (
<EmptyState
title="Zatím žádné vydané objednávky."
description={
@@ -317,6 +359,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
: undefined
}
/>
)
}
/>
<Pagination

View File

@@ -240,6 +240,9 @@ export default function Offers() {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
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());
@@ -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<
number,
{ 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 />;
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
@@ -35,10 +35,20 @@ import {
LoadingState,
type DataColumn,
} 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 STATUS_OPTIONS = statusOptions(ORDER_STATUS, {
value: "",
label: "Všechny stavy",
});
interface Order {
id: number;
order_number: string;
@@ -139,7 +149,11 @@ export default function OrdersReceived({
const { sort, order, handleSort } = useTableSort("order_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState("");
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<{
show: boolean;
@@ -297,11 +311,18 @@ export default function OrdersReceived({
sort,
order,
page,
status: status || undefined,
month,
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 />;
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 />;
}
@@ -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 (
<>
<FilterBar>
@@ -461,6 +511,16 @@ export default function OrdersReceived({
fullWidth
/>
</Box>
<Box sx={{ flex: "0 1 200px" }}>
<Select
value={status}
onChange={(value) => {
setStatus(value);
setPage(1);
}}
options={STATUS_OPTIONS}
/>
</Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
@@ -468,14 +528,19 @@ export default function OrdersReceived({
columns={columns}
rows={orders}
rowKey={(o) => o.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
isFiltered ? (
<EmptyState title="Žádné objednávky neodpovídají filtru." />
) : (
<EmptyState
title="Zatím nejsou žádné objednávky."
description="Objednávky se vytvářejí z nabídek."
/>
)
}
/>
<Pagination

View File

@@ -9,7 +9,12 @@ import MuiTextField from "@mui/material/TextField";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
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 useTableSort from "../hooks/useTableSort";
import {
@@ -562,6 +567,37 @@ export default function ReceivedInvoices({
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>[] = [
{
key: "supplier_name",
@@ -785,10 +821,14 @@ export default function ReceivedInvoices({
columns={columns}
rows={invoices}
rowKey={(inv) => inv.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
search ? (
<EmptyState title="Žádné faktury neodpovídají hledání." />
) : (
<EmptyState
title="Žádné přijaté faktury v tomto měsíci."
description={
@@ -797,6 +837,7 @@ export default function ReceivedInvoices({
: undefined
}
/>
)
}
/>
)}