720 lines
22 KiB
TypeScript
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;
|