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:
@@ -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();
|
||||
|
||||
@@ -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,14 +844,18 @@ export default function Invoices() {
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné faktury."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Vytvořte první fakturu tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
search || statusFilter ? (
|
||||
<EmptyState title="Žádné faktury neodpovídají filtru." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné faktury."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Vytvořte první fakturu tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
|
||||
@@ -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,18 +343,23 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
columns={columns}
|
||||
rows={orders}
|
||||
rowKey={(o) => o.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="Zatím žádné vydané objednávky."
|
||||
description={
|
||||
hasPermission("orders.create")
|
||||
? "Vytvořte první tlačítkem „Vytvořit objednávku vydanou“."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
isFiltered ? (
|
||||
<EmptyState title="Žádné objednávky neodpovídají filtru." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím žádné vydané objednávky."
|
||||
description={
|
||||
hasPermission("orders.create")
|
||||
? "Vytvořte první tlačítkem „Vytvořit objednávku vydanou“."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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={
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné objednávky."
|
||||
description="Objednávky se vytvářejí z nabídek."
|
||||
/>
|
||||
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
|
||||
|
||||
@@ -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,18 +821,23 @@ export default function ReceivedInvoices({
|
||||
columns={columns}
|
||||
rows={invoices}
|
||||
rowKey={(inv) => inv.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="Žádné přijaté faktury v tomto měsíci."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Nahrajte faktury tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
search ? (
|
||||
<EmptyState title="Žádné faktury neodpovídají hledání." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Žádné přijaté faktury v tomto měsíci."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Nahrajte faktury tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user