"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; type FilterValue = | string | { operator?: string; value: string; }; interface AdminResourceListProps { title: string; endpoint: string; responseCollectionKeys?: string[]; primaryLabelKeys: string[]; chipKeys?: string[]; excludeKeys?: string[]; filterOverrides?: Record; addModalFields?: IAddModalField[]; showEnabledToggle?: boolean; idField?: string; initialData?: Record; } const DEFAULT_COLLECTION_KEYS = ["data", "items"]; const ensureRowId = ( row: Record, 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, preferredKeys: string[] = [] ) => { for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) { const maybeCollection = payload?.[key]; if (Array.isArray(maybeCollection)) { return maybeCollection as Record[]; } } if (Array.isArray(payload)) { return payload as Record[]; } 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(null); const [refreshCounter, setRefreshCounter] = useState(0); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [resourceToDelete, setResourceToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); const [addModalOpen, setAddModalOpen] = useState(false); const [isAdding, setIsAdding] = useState(false); const [addError, setAddError] = useState(null); const [editingRowId, setEditingRowId] = useState( null ); const [editingName, setEditingName] = useState(""); const [editingRate, setEditingRate] = useState(""); const [isSavingName, setIsSavingName] = useState(false); const [updatingEnabled, setUpdatingEnabled] = useState>( 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(() => 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) => { 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 = { 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 (
{title} {addModalFields && ( )}
{localStatus === "loading" && (
{`Loading ${normalizedTitle}...`}
)} {localStatus === "failed" && ( {localError || `Failed to load ${normalizedTitle}`} )} {!rows.length && localStatus === "succeeded" && ( {`No ${normalizedTitle} found.`} )} {rows.length > 0 && (
{rows.map(row => { const chips = getMetaChips(row); const secondary = getSecondaryDetails(row); return (
{editingRowId === row.id ? (
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 && ( 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" /> )}
) : ( {getPrimaryLabel(row)} )} ID: {row.id}
{chips.length > 0 && editingRowId !== row.id && (
{chips.map(chip => ( ))}
)} {secondary.length > 0 && ( {secondary .map(([key, value]) => `${key}: ${String(value)}`) .join(" • ")} )}
{(addModalFields || showEnabledToggle) && (
{showEnabledToggle && ( <> Enabled handleEnabledToggle(row, e.target.checked) } disabled={updatingEnabled.has( (row.identifier as string | number | undefined) ?? row.id )} size="small" /> )} {addModalFields && ( <> {editingRowId === row.id ? ( <> handleEditSave(row)} disabled={isSavingName} size="small" color="primary" > {isSavingName ? ( ) : ( )} ) : ( handleEditClick(row)} size="small" > )} handleDeleteClick(row)} size="small" > )}
)}
); })}
)} {addModalFields && ( <> )}
); }; export default AdminResourceList;