"use client" import React, { useEffect, useMemo, useRef, useState } from "react" import { SearchIcon } from "lucide-react" import { Input } from "@/shared/components/ui/input" import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { formatEnum } from "@/shared/utils/formatters" import { DataTable, type ActionsColumnOptions, type DataViewProps } from "@/shared/data-view/table-view" import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page" import type { ColumnDef } from "@tanstack/react-table" export type StatusFilterConfig = { /** Allowed status values (typically the matching `Status` enum). */ statuses: readonly string[] /** Query-param key forwarded to the API. Defaults to "status". */ paramKey?: string /** Initial selected tab. Defaults to "all". */ defaultValue?: string /** Label for the catch-all tab. Defaults to "All". */ allLabel?: string /** Per-status label transform. Defaults to formatEnum (snake_case → Title Case). */ formatLabel?: (status: string) => string } type LooseColumnDef = ColumnDef | (Omit, "accessorKey"> & { accessorKey: string }) export type CrudResourceColumnHelpers = { actionsColumn: (options?: Partial>>) => ColumnDef, unknown> openEdit: (row: ResourceItem) => void deleteItem: (id: string) => Promise } export type CrudResourceContext = { selectedItem: ResourceItem | null isDialogOpen: boolean dialogResourceId: string | null isLoading: boolean data: ResourceItem[] openCreate: () => void openEdit: (row: ResourceItem) => void closeDialog: () => void deleteItem: (id: string) => Promise invalidateQuery: () => void } type ReactNodeOrRender = | React.ReactNode | ((context: CrudResourceContext) => React.ReactNode) type ManagedTableProps = "columns" | "data" | "pagination" | "sorting" | "onChange" | "isLoading" export type CrudResourceProps = UseResourcePageOptions & { columns: LooseColumnDef>[] | ((helpers: CrudResourceColumnHelpers) => LooseColumnDef>[]) onRowClick?: (row: ResourceItem) => void tableHeader?: ReactNodeOrRender tableProps?: Omit>>, ManagedTableProps> render?: (table: React.ReactElement, context: CrudResourceContext) => React.ReactElement /** Render a built-in search input above the table and forward `search` to the API. */ searchable?: boolean /** Placeholder for the built-in search input. */ searchPlaceholder?: string /** Render a built-in status tab strip above the table and forward the selected status to the API. */ statusFilter?: StatusFilterConfig } export function CrudResource({ columns: columnsProp, routeKey, getClient, queryOptions, paramKey, extraParams, localState, onRowClick, tableHeader, tableProps, render, searchable = false, searchPlaceholder = "Search...", statusFilter, }: CrudResourceProps) { type TItem = ResourceItem const [searchInput, setSearchInput] = useState("") const [search, setSearch] = useState("") const statusParamKey = statusFilter?.paramKey ?? "status" const statusAllValue = statusFilter?.defaultValue ?? "all" const [statusValue, setStatusValue] = useState(statusAllValue) useEffect(() => { const timer = setTimeout(() => setSearch(searchInput.trim()), 300) return () => clearTimeout(timer) }, [searchInput]) const mergedExtraParams = useMemo(() => { const base = { ...(extraParams ?? {}) } as Record if (searchable && search) base.search = search if (statusFilter && statusValue !== statusAllValue) { base[statusParamKey] = statusValue } return base }, [extraParams, search, searchable, statusFilter, statusValue, statusAllValue, statusParamKey]) const page = useResourcePage({ routeKey, getClient, queryOptions, paramKey, extraParams: mergedExtraParams, localState, }) const prevSearchRef = useRef(search) const prevStatusRef = useRef(statusValue) const setParamsRef = useRef(page.setParams) setParamsRef.current = page.setParams useEffect(() => { if (prevSearchRef.current !== search) { prevSearchRef.current = search setParamsRef.current({ page: 1 }) } }, [search]) useEffect(() => { if (prevStatusRef.current !== statusValue) { prevStatusRef.current = statusValue setParamsRef.current({ page: 1 }) } }, [statusValue]) const columns = typeof columnsProp === "function" ? columnsProp({ actionsColumn: page.actionsColumn, openEdit: page.openEdit, deleteItem: page.deleteItem, }) : columnsProp type ListResponse = { data?: TItem[] } const responseData = page.data as ListResponse | undefined const items = (responseData?.data ?? []) as TItem[] const context: CrudResourceContext = { selectedItem: page.selectedItem, isDialogOpen: page.isDialogOpen, dialogResourceId: page.dialogResourceId, isLoading: page.isLoading, data: items, openCreate: page.openCreate, openEdit: page.openEdit, closeDialog: page.closeDialog, deleteItem: page.deleteItem, invalidateQuery: () => page.invalidateQuery(), } const searchInputEl = searchable ? (
setSearchInput(e.target.value)} className="pl-8" />
) : null const statusTabsEl = statusFilter ? ( {statusFilter.allLabel ?? "All"} {statusFilter.statuses.map((status) => ( {(statusFilter.formatLabel ?? formatEnum)(status)} ))} ) : null const resolvedTableHeader = tableHeader ? (typeof tableHeader === "function" ? tableHeader(context) : tableHeader) : null const leftHeader = statusTabsEl ?? resolvedTableHeader const extraHeaderAbove = statusTabsEl && resolvedTableHeader ? resolvedTableHeader : null const headerRow = (searchInputEl || leftHeader) ? (
{leftHeader}
{searchInputEl}
) : null const table = ( <> {extraHeaderAbove} {headerRow} []} data={items} pagination={page.pagination} sorting={page.sorting} onChange={page.handleChange} isLoading={page.isLoading} onRowClick={onRowClick ?? tableProps?.onRowClick} /> ) if (render) return render(table, context) return table }