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;
|
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user