payment-backoffice/app/features/AdminList/AdminResourceList.tsx
2026-01-07 15:41:36 +01:00

720 lines
22 KiB
TypeScript

"use client";
import Spinner from "@/app/components/Spinner/Spinner";
import DeleteModal from "@/app/components/DeleteModal/DeleteModal";
import { TDeleteModalResource } from "@/app/components/DeleteModal/types";
import AddModal from "@/app/components/AddModal/AddModal";
import { IAddModalField } from "@/app/components/AddModal/types";
import { DataRowBase } from "@/app/features/DataTable/types";
import {
selectFilters,
selectPagination,
selectSort,
} from "@/app/redux/advanedSearch/selectors";
import {
Alert,
Button,
Chip,
IconButton,
Switch,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditIcon from "@mui/icons-material/Edit";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import {
createResourceApi,
deleteResourceApi,
updateResourceApi,
} from "./AdminResourceList.utils";
import "./AdminResourceList.scss";
type ResourceRow = DataRowBase & {
identifier?: string | number;
} & 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>;
addModalFields?: IAddModalField[];
showEnabledToggle?: boolean;
idField?: string;
initialData?: Record<string, unknown>;
}
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 identifier = currentId;
const numericId = Number(currentId);
if (!Number.isNaN(numericId) && numericId !== 0) {
return { ...row, id: numericId, identifier } as ResourceRow;
}
return { ...row, id: fallbackId, identifier } 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 = [],
addModalFields,
showEnabledToggle = false,
idField,
initialData,
}: AdminResourceListProps) => {
// Keep Redux for shared state (filters, pagination, sort)
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
// Use local state for component-specific status/error
const [localStatus, setLocalStatus] = useState<
"idle" | "loading" | "succeeded" | "failed"
>(initialData ? "succeeded" : "idle");
const [localError, setLocalError] = useState<string | null>(null);
const [refreshCounter, setRefreshCounter] = useState(0);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [resourceToDelete, setResourceToDelete] =
useState<TDeleteModalResource | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [addModalOpen, setAddModalOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [editingRowId, setEditingRowId] = useState<number | string | null>(
null
);
const [editingName, setEditingName] = useState<string>("");
const [editingRate, setEditingRate] = useState<string>("");
const [isSavingName, setIsSavingName] = useState(false);
const [updatingEnabled, setUpdatingEnabled] = useState<Set<number | string>>(
new Set()
);
const normalizedTitle = title.toLowerCase();
const getRowIdentifier = (row: ResourceRow): number | string => {
if (idField && row[idField] !== undefined) {
return row[idField] as number | string;
}
return (row.identifier as string | number | undefined) ?? row.id;
};
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]
);
// Initialize rows synchronously from initialData if available
const getInitialRows = (): ResourceRow[] => {
if (initialData) {
const collection = resolveCollection(initialData, resolvedCollectionKeys);
return collection.map((item, index) => {
const row = ensureRowId(item, index + 1);
// If idField is specified, use that field as identifier
if (idField && row[idField] !== undefined) {
return { ...row, identifier: row[idField] } as ResourceRow;
}
return row;
});
}
return [];
};
const [rows, setRows] = useState<ResourceRow[]>(() => getInitialRows());
useEffect(() => {
// Skip initial fetch if we have server-rendered data and no filters/pagination/sort changes
// Always fetch when filters/pagination/sort change or after mutations (refreshCounter > 0)
const hasFilters = Object.keys(filters).length > 0;
const hasSort = sort?.field && sort?.order;
// Only skip if we have initialData AND it's the first render (refreshCounter === 0) AND no filters/sort
// Also check if pagination is at default values (page 1, limit 100)
const isDefaultPagination =
pagination.page === 1 && pagination.limit === 100;
const shouldSkipInitialFetch =
initialData &&
refreshCounter === 0 &&
!hasFilters &&
!hasSort &&
isDefaultPagination;
if (shouldSkipInitialFetch) {
return;
}
const fetchResources = async () => {
setLocalStatus("loading");
setLocalError(null);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters,
pagination,
sort,
}),
});
if (!response.ok) {
setLocalError(`Failed to fetch ${normalizedTitle}`);
setRows([]);
setLocalStatus("failed");
return;
}
const backendData = await response.json();
const collection = resolveCollection(
backendData,
resolvedCollectionKeys
);
const nextRows = collection.map((item, index) => {
const row = ensureRowId(item, index + 1);
// If idField is specified, use that field as identifier
if (idField && row[idField] !== undefined) {
return { ...row, identifier: row[idField] } as ResourceRow;
}
return row;
});
setRows(nextRows);
setLocalStatus("succeeded");
} catch (error) {
setLocalError(error instanceof Error ? error.message : "Unknown error");
setRows([]);
setLocalStatus("failed");
}
};
fetchResources();
}, [
endpoint,
filters,
pagination,
sort,
resolvedCollectionKeys,
normalizedTitle,
refreshCounter,
idField,
initialData,
]);
const handleAddClick = () => {
if (!addModalFields) {
return;
}
setAddModalOpen(true);
setAddError(null);
};
const handleAddConfirm = async (data: Record<string, unknown>) => {
if (!addModalFields) {
return;
}
setIsAdding(true);
setAddError(null);
try {
await createResourceApi(endpoint, data, normalizedTitle);
setAddModalOpen(false);
setRefreshCounter(current => current + 1);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: `Failed to create ${normalizedTitle}`;
setAddError(errorMessage);
setLocalError(errorMessage);
} finally {
setIsAdding(false);
}
};
const handleAddModalClose = () => {
if (!isAdding) {
setAddModalOpen(false);
setAddError(null);
}
};
const handleEditClick = (row: ResourceRow) => {
if (!addModalFields || !row.id) {
return;
}
const currentName = getPrimaryLabel(row);
const currentRate = row.rate !== undefined ? String(row.rate) : "";
setEditingRowId(row.id);
setEditingName(currentName);
setEditingRate(currentRate);
};
const handleEditCancel = () => {
setEditingRowId(null);
setEditingName("");
setEditingRate("");
};
const handleEditSave = async (row: ResourceRow) => {
if (!addModalFields || !editingName.trim()) {
handleEditCancel();
return;
}
const identifier = getRowIdentifier(row);
setIsSavingName(true);
try {
const updatePayload: Record<string, unknown> = {
name: editingName.trim(),
};
// Include rate if it exists and has been modified
if (row.rate !== undefined && editingRate !== "") {
const rateValue = parseFloat(editingRate);
if (!Number.isNaN(rateValue)) {
updatePayload.rate = rateValue;
}
}
await updateResourceApi(
endpoint,
identifier,
updatePayload,
normalizedTitle
);
setEditingRowId(null);
setEditingName("");
setEditingRate("");
setRefreshCounter(current => current + 1);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: `Failed to update ${normalizedTitle}`;
setLocalError(errorMessage);
} finally {
setIsSavingName(false);
}
};
const handleEditKeyDown = (e: React.KeyboardEvent, row: ResourceRow) => {
if (e.key === "Enter") {
e.preventDefault();
handleEditSave(row);
} else if (e.key === "Escape") {
e.preventDefault();
handleEditCancel();
}
};
const handleDeleteClick = (row: ResourceRow) => {
if (!addModalFields) {
return;
}
const identifier = getRowIdentifier(row);
if (!identifier) {
return;
}
setResourceToDelete({
id: identifier,
label: getPrimaryLabel(row),
});
setDeleteModalOpen(true);
setDeleteError(null);
};
const handleDeleteConfirm = async (id: number | string) => {
if (!addModalFields) {
return;
}
setIsDeleting(true);
setDeleteError(null);
try {
await deleteResourceApi(endpoint, id, normalizedTitle);
setDeleteModalOpen(false);
setResourceToDelete(null);
setRefreshCounter(current => current + 1);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: `Failed to delete ${normalizedTitle}`;
setDeleteError(errorMessage);
setLocalError(errorMessage);
} finally {
setIsDeleting(false);
}
};
const handleDeleteModalClose = () => {
if (!isDeleting) {
setDeleteModalOpen(false);
setResourceToDelete(null);
setDeleteError(null);
}
};
const handleEnabledToggle = async (row: ResourceRow, newEnabled: boolean) => {
if (!showEnabledToggle) {
return;
}
const identifier = getRowIdentifier(row);
setUpdatingEnabled(prev => new Set(prev).add(identifier));
try {
await updateResourceApi(
endpoint,
identifier,
{ enabled: newEnabled },
normalizedTitle
);
setRefreshCounter(current => current + 1);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: `Failed to update ${normalizedTitle}`;
setLocalError(errorMessage);
} finally {
setUpdatingEnabled(prev => {
const next = new Set(prev);
next.delete(identifier);
return next;
});
}
};
return (
<div className="admin-resource-list">
<div className="admin-resource-list__header">
<Typography variant="h5" className="admin-resource-list__title">
{title}
</Typography>
{addModalFields && (
<Button
variant="contained"
size="small"
onClick={handleAddClick}
className="admin-resource-list__button--add"
>
Add {title}
</Button>
)}
</div>
{localStatus === "loading" && (
<div className="admin-resource-list__loading">
<Spinner size="small" color="#000" />
<Typography variant="body2">
{`Loading ${normalizedTitle}...`}
</Typography>
</div>
)}
{localStatus === "failed" && (
<Alert severity="error" className="admin-resource-list__error">
{localError || `Failed to load ${normalizedTitle}`}
</Alert>
)}
{!rows.length && localStatus === "succeeded" && (
<Typography variant="body2" className="admin-resource-list__empty">
{`No ${normalizedTitle} found.`}
</Typography>
)}
{rows.length > 0 && (
<div className="admin-resource-list__list">
{rows.map(row => {
const chips = getMetaChips(row);
const secondary = getSecondaryDetails(row);
return (
<div key={row.id} className="admin-resource-list__row">
<div className="admin-resource-list__row-content">
<div className="admin-resource-list__row-container">
<div className="admin-resource-list__row-header">
{editingRowId === row.id ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
width: "100%",
}}
>
<TextField
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => handleEditKeyDown(e, row)}
autoFocus
disabled={isSavingName}
size="small"
className="admin-resource-list__edit-field"
variant="standard"
label="Name"
/>
{row.rate !== undefined && (
<TextField
value={editingRate}
onChange={e => setEditingRate(e.target.value)}
onKeyDown={e => handleEditKeyDown(e, row)}
disabled={isSavingName}
size="small"
className="admin-resource-list__edit-field"
variant="standard"
label="Rate"
type="number"
/>
)}
</div>
) : (
<Typography
variant="subtitle1"
className="admin-resource-list__row-title"
>
{getPrimaryLabel(row)}
</Typography>
)}
<Typography
variant="caption"
className="admin-resource-list__row-id"
>
ID: {row.id}
</Typography>
</div>
{chips.length > 0 && editingRowId !== row.id && (
<div className="admin-resource-list__row-chips">
{chips.map(chip => (
<Chip
key={`${row.id}-${chip.key}`}
label={`${chip.key}: ${chip.value}`}
size="small"
color="primary"
variant="outlined"
/>
))}
</div>
)}
{secondary.length > 0 && (
<Typography
variant="body2"
className="admin-resource-list__row-secondary"
>
{secondary
.map(([key, value]) => `${key}: ${String(value)}`)
.join(" • ")}
</Typography>
)}
</div>
{(addModalFields || showEnabledToggle) && (
<div className="admin-resource-list__secondary-actions">
{showEnabledToggle && (
<>
<Typography
variant="body2"
className="admin-resource-list__enabled-label"
>
Enabled
</Typography>
<Switch
checked={Boolean(row.enabled)}
onChange={e =>
handleEnabledToggle(row, e.target.checked)
}
disabled={updatingEnabled.has(
(row.identifier as string | number | undefined) ??
row.id
)}
size="small"
/>
</>
)}
{addModalFields && (
<>
{editingRowId === row.id ? (
<>
<Tooltip title="Save">
<IconButton
edge="end"
aria-label={`save ${normalizedTitle} name`}
onClick={() => handleEditSave(row)}
disabled={isSavingName}
size="small"
color="primary"
>
{isSavingName ? (
<Spinner size="small" color="#1976d2" />
) : (
<CheckIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title="Cancel">
<IconButton
edge="end"
aria-label="cancel edit"
onClick={handleEditCancel}
disabled={isSavingName}
size="small"
>
<CloseIcon />
</IconButton>
</Tooltip>
</>
) : (
<Tooltip title="Edit">
<IconButton
edge="end"
aria-label={`edit ${normalizedTitle}`}
onClick={() => handleEditClick(row)}
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton
edge="end"
aria-label={`delete ${normalizedTitle}`}
onClick={() => handleDeleteClick(row)}
size="small"
>
<DeleteOutlineIcon />
</IconButton>
</Tooltip>
</>
)}
</div>
)}
</div>
<div className="admin-resource-list__divider" />
</div>
);
})}
</div>
)}
{addModalFields && (
<>
<AddModal
open={addModalOpen}
onClose={handleAddModalClose}
onConfirm={handleAddConfirm}
resourceType={normalizedTitle}
fields={addModalFields}
isLoading={isAdding}
error={addError}
/>
<DeleteModal
open={deleteModalOpen}
onClose={handleDeleteModalClose}
onConfirm={handleDeleteConfirm}
resource={resourceToDelete}
resourceType={normalizedTitle}
isLoading={isDeleting}
error={deleteError}
/>
</>
)}
</div>
);
};
export default AdminResourceList;