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:
@@ -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}` : ""}`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user