feat(projects): status filter tabs + per-status row tints on the list

Mirrors the offers page: centered status tabs (Vsechny/Aktivni/
Dokonceny/Zruseny) filtering server-side (?status= already supported),
filtered-vs-empty empty states, and row tints - dokonceny rows get the
info-channel low-alpha wash matching the badge, zruseny rows are faded
like invalidated offers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 19:30:09 +02:00
parent 79d026e756
commit 4bf245d35c
2 changed files with 75 additions and 10 deletions

View File

@@ -40,6 +40,7 @@ export const projectListOptions = (filters: {
order?: string; order?: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
status?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["projects", "list", filters], queryKey: ["projects", "list", filters],
@@ -50,6 +51,7 @@ export const projectListOptions = (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);
const qs = params.toString(); const qs = params.toString();
return paginatedJsonQuery<Project>( return paginatedJsonQuery<Project>(
`/api/admin/projects${qs ? `?${qs}` : ""}`, `/api/admin/projects${qs ? `?${qs}` : ""}`,

View File

@@ -34,6 +34,8 @@ import {
CheckboxField, CheckboxField,
LoadingState, LoadingState,
PageEnter, PageEnter,
Tabs,
type TabDef,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -54,6 +56,12 @@ const STATUS_COLORS: Record<
zruseny: "default", zruseny: "default",
}; };
// Status filter tabs (mirrors the offers page): "" = all.
const STATUS_TABS: TabDef[] = [
{ value: "", label: "Všechny" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
];
interface Project { interface Project {
id: number; id: number;
project_number: string; project_number: string;
@@ -120,6 +128,7 @@ export default function Projects() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null); const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false);
@@ -230,7 +239,13 @@ export default function Projects() {
isPending, isPending,
isFetching, isFetching,
} = usePaginatedQuery<Project>( } = usePaginatedQuery<Project>(
projectListOptions({ search: debouncedSearch, sort, order, page }), projectListOptions({
search: debouncedSearch,
sort,
order,
page,
status: statusFilter || undefined,
}),
); );
if (!hasPermission("projects.view")) return <Forbidden />; if (!hasPermission("projects.view")) return <Forbidden />;
@@ -372,6 +387,31 @@ export default function Projects() {
}, },
]; ];
// Per-status row tints (mirrors the offers list): LOW-ALPHA washes via the
// palette channel vars in the badge's color — never solid `.light` fills
// (invisible white text in dark mode). Cancelled rows are faded/muted like
// invalidated offers.
const rowSx = (p: Project) => {
if (p.status === "dokonceny") {
return {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.12)",
"&:hover": {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.18)",
},
};
}
if (p.status === "zruseny") {
return {
opacity: 0.6,
"& td": { color: "var(--mui-palette-text-secondary)" },
};
}
return {};
};
// Search/status filter active → empty means "nothing matches" (no CTA).
const isFiltered = !!debouncedSearch || !!statusFilter;
return ( return (
<PageEnter> <PageEnter>
<Box <Box
@@ -392,6 +432,17 @@ export default function Projects() {
)} )}
</Box> </Box>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
tabs={STATUS_TABS}
/>
</Box>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
<TextField <TextField
@@ -411,10 +462,21 @@ export default function Projects() {
columns={columns} columns={columns}
rows={projects} rows={projects}
rowKey={(p) => p.id} rowKey={(p) => p.id}
rowSx={rowSx}
sortBy={sort} sortBy={sort}
sortDir={order} sortDir={order}
onSort={handleSort} onSort={handleSort}
empty={ empty={
isFiltered ? (
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Žádné projekty neodpovídají filtru.
</Typography>
<Typography variant="body2" color="text.secondary">
Zkuste změnit filtr nebo hledaný výraz.
</Typography>
</Box>
) : (
<Box sx={{ textAlign: "center", py: 6 }}> <Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom> <Typography color="text.secondary" gutterBottom>
Zatím nejsou žádné projekty. Zatím nejsou žádné projekty.
@@ -424,6 +486,7 @@ export default function Projects() {
tlačítkem Přidat projekt". tlačítkem Přidat projekt".
</Typography> </Typography>
</Box> </Box>
)
} }
/> />
<Pagination <Pagination