payment-backoffice/app/features/AdminList/AdminResourceList.tsx
2025-11-25 10:50:27 +01:00

291 lines
7.6 KiB
TypeScript

"use client";
import Spinner from "@/app/components/Spinner/Spinner";
import { DataRowBase } from "@/app/features/DataTable/types";
import {
setError as setAdvancedSearchError,
setStatus,
} from "@/app/redux/advanedSearch/advancedSearchSlice";
import {
selectError,
selectFilters,
selectPagination,
selectSort,
selectStatus,
} from "@/app/redux/advanedSearch/selectors";
import { AppDispatch } from "@/app/redux/store";
import {
Alert,
Box,
Chip,
Divider,
List,
ListItem,
Typography,
} from "@mui/material";
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
type ResourceRow = DataRowBase & Record<string, unknown>;
type FilterValue =
| string
| {
operator?: string;
value: string;
};
interface AdminResourceListProps {
title: string;
endpoint: string;
responseCollectionKeys?: string[];
primaryLabelKeys: string[];
chipKeys?: string[];
excludeKeys?: string[];
filterOverrides?: Record<string, FilterValue>;
}
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
const ensureRowId = (
row: Record<string, unknown>,
fallbackId: number
): ResourceRow => {
const currentId = row.id;
if (typeof currentId === "number") {
return row as ResourceRow;
}
const numericId = Number(currentId);
if (!Number.isNaN(numericId) && numericId !== 0) {
return { ...row, id: numericId } as ResourceRow;
}
return { ...row, id: fallbackId } as ResourceRow;
};
const resolveCollection = (
payload: Record<string, unknown>,
preferredKeys: string[] = []
) => {
for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) {
const maybeCollection = payload?.[key];
if (Array.isArray(maybeCollection)) {
return maybeCollection as Record<string, unknown>[];
}
}
if (Array.isArray(payload)) {
return payload as Record<string, unknown>[];
}
return [];
};
const AdminResourceList = ({
title,
endpoint,
responseCollectionKeys = [],
primaryLabelKeys,
chipKeys = [],
excludeKeys = [],
}: AdminResourceListProps) => {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
const status = useSelector(selectStatus);
const errorMessage = useSelector(selectError);
const [rows, setRows] = useState<ResourceRow[]>([]);
const normalizedTitle = title.toLowerCase();
const excludedKeys = useMemo(() => {
const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]);
excludeKeys.forEach(key => baseExcluded.add(key));
return Array.from(baseExcluded);
}, [primaryLabelKeys, chipKeys, excludeKeys]);
const getPrimaryLabel = (row: ResourceRow) => {
for (const key of primaryLabelKeys) {
if (row[key]) {
return String(row[key]);
}
}
return `${title} #${row.id}`;
};
const getMetaChips = (row: ResourceRow) =>
chipKeys
.filter(key => row[key])
.map(key => ({
key,
value: String(row[key]),
}));
const getSecondaryDetails = (row: ResourceRow) =>
Object.entries(row).filter(([key]) => !excludedKeys.includes(key));
const resolvedCollectionKeys = useMemo(
() => [...responseCollectionKeys],
[responseCollectionKeys]
);
useEffect(() => {
const fetchResources = async () => {
dispatch(setStatus("loading"));
dispatch(setAdvancedSearchError(null));
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters,
pagination,
sort,
}),
});
if (!response.ok) {
dispatch(
setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`)
);
setRows([]);
return;
}
const backendData = await response.json();
const collection = resolveCollection(
backendData,
resolvedCollectionKeys
);
const nextRows = collection.map((item, index) =>
ensureRowId(item, index + 1)
);
setRows(nextRows);
dispatch(setStatus("succeeded"));
} catch (error) {
dispatch(
setAdvancedSearchError(
error instanceof Error ? error.message : "Unknown error"
)
);
setRows([]);
}
};
fetchResources();
}, [
dispatch,
endpoint,
filters,
pagination,
sort,
resolvedCollectionKeys,
normalizedTitle,
]);
return (
<Box sx={{ p: 3, width: "100%", maxWidth: 900 }}>
<Typography variant="h5" sx={{ mb: 2 }}>
{title}
</Typography>
{status === "loading" && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mb: 2 }}>
<Spinner size="small" color="#000" />
<Typography variant="body2">
{`Loading ${normalizedTitle}...`}
</Typography>
</Box>
)}
{status === "failed" && (
<Alert severity="error" sx={{ mb: 2 }}>
{errorMessage || `Failed to load ${normalizedTitle}`}
</Alert>
)}
{!rows.length && status === "succeeded" && (
<Typography variant="body2" color="text.secondary">
{`No ${normalizedTitle} found.`}
</Typography>
)}
{rows.length > 0 && (
<List
sx={{ bgcolor: "background.paper", borderRadius: 2, boxShadow: 1 }}
>
{rows.map(row => {
const chips = getMetaChips(row);
const secondary = getSecondaryDetails(row);
return (
<Box key={row.id}>
<ListItem alignItems="flex-start">
<Box sx={{ width: "100%" }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 1,
}}
>
<Typography variant="subtitle1" fontWeight={600}>
{getPrimaryLabel(row)}
</Typography>
<Typography variant="caption" color="text.secondary">
ID: {row.id}
</Typography>
</Box>
{chips.length > 0 && (
<Box
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
my: 1,
}}
>
{chips.map(chip => (
<Chip
key={`${row.id}-${chip.key}`}
label={`${chip.key}: ${chip.value}`}
size="small"
color="primary"
variant="outlined"
/>
))}
</Box>
)}
{secondary.length > 0 && (
<Typography variant="body2" color="text.secondary">
{secondary
.map(([key, value]) => `${key}: ${String(value)}`)
.join(" • ")}
</Typography>
)}
</Box>
</ListItem>
<Divider component="li" />
</Box>
);
})}
</List>
)}
</Box>
);
};
export default AdminResourceList;