"use client" import type { ReactNode } from "react" import { useCallback, useEffect, useMemo, useState } from "react" import { MoreHorizontalIcon, PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon, } from "lucide-react" import { toast } from "sonner" import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar, } from "@/components/dashboard-page" import { ListPagination } from "@/components/list-pagination" import { OptionCombobox } from "@/components/option-combobox" import { Button } from "@/components/ui/button" import { ButtonGroup } from "@/components/ui/button-group" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { buildDashboardCrudQuery, normalizeDashboardCrudPageResult, type DashboardCrudPageResult, type DashboardCrudQueryFilter, type DashboardCrudQueryValue, } from "./dashboard-crud-utils" type DashboardCrudFilter = DashboardCrudQueryFilter & { label: string placeholder?: string defaultValue: TValue type?: "text" | "select" className?: string options?: ReadonlyArray<{ value: string; label: string }> } type DashboardCrudColumn = { key: string label: ReactNode className?: string render: (item: TItem, context: DashboardCrudRowActionContext) => ReactNode } type DashboardCrudDialogProps = { open: boolean saving: boolean item: TItem | null itemId: number | null onOpenChange: (open: boolean) => void onSubmit: (payload: TPayload) => Promise } type DashboardCrudRowActionContext = { item: TItem actionLoading: boolean actionLoadingId: number | null reload: () => Promise setActionLoadingId: (id: number | null) => void } type DashboardCrudPageProps = { filters: DashboardCrudFilter[] columns: DashboardCrudColumn[] fetchList: ( query: Record ) => Promise> renderEditDialog: (props: DashboardCrudDialogProps) => ReactNode getItemId: (item: TItem) => number createItem: (payload: TPayload) => Promise updateItem: (item: TItem, payload: TPayload) => Promise deleteItem?: (item: TItem) => Promise canDelete?: (item: TItem) => boolean renderRowActions?: (context: DashboardCrudRowActionContext) => ReactNode pageSize?: number labels: { refresh: string create: string query: string loading: string empty: string actions: string edit: string delete: string processing: string moreActions: (item: TItem) => string loadFailed: string saveFailed: string deleteFailed: string created: (item: TPayload) => string updated: (item: TItem, payload: TPayload) => string deleted?: (item: TItem) => string } } export function DashboardCrudPage({ filters, columns, fetchList, renderEditDialog, getItemId, createItem, updateItem, deleteItem, canDelete, renderRowActions, pageSize = 20, labels, }: DashboardCrudPageProps) { const initialFilters = useMemo( () => Object.fromEntries( filters.map((filter) => [filter.name, filter.defaultValue]) ) as Record, [filters] ) const [draftFilters, setDraftFilters] = useState(initialFilters) const [appliedFilters, setAppliedFilters] = useState(initialFilters) const [page, setPage] = useState(1) const [limit, setLimit] = useState(pageSize) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [actionLoadingId, setActionLoadingId] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) const [editingItem, setEditingItem] = useState(null) const [result, setResult] = useState>({ results: [], page: { page: 1, limit: pageSize, total: 0 }, }) useEffect(() => { setDraftFilters(initialFilters) setAppliedFilters(initialFilters) }, [initialFilters]) const loadData = useCallback(async () => { setLoading(true) try { const data = await fetchList( buildDashboardCrudQuery({ values: appliedFilters, filters, page, limit, }) ) setResult(normalizeDashboardCrudPageResult(data, page, limit)) } catch (error) { toast.error(error instanceof Error ? error.message : labels.loadFailed) } finally { setLoading(false) } }, [appliedFilters, fetchList, filters, labels.loadFailed, limit, page]) useEffect(() => { void loadData() }, [loadData]) function applyFilters() { setAppliedFilters(draftFilters) setPage(1) } function handleFilterKeyDown(event: React.KeyboardEvent) { if (event.key !== "Enter") return event.preventDefault() applyFilters() } function openCreateDialog() { setEditingItem(null) setDialogOpen(true) } function openEditDialog(item: TItem) { setEditingItem(item) setDialogOpen(true) } function handleDialogOpenChange(open: boolean) { if (saving) return if (!open) setEditingItem(null) setDialogOpen(open) } async function handleSubmit(payload: TPayload) { if (saving) return setSaving(true) try { if (editingItem) { await updateItem(editingItem, payload) toast.success(labels.updated(editingItem, payload)) } else { await createItem(payload) toast.success(labels.created(payload)) } setDialogOpen(false) setEditingItem(null) await loadData() } catch (error) { toast.error(error instanceof Error ? error.message : labels.saveFailed) } finally { setSaving(false) } } async function handleDelete(item: TItem) { if (!deleteItem) return const id = getItemId(item) setActionLoadingId(id) try { await deleteItem(item) toast.success(labels.deleted?.(item) ?? labels.delete) await loadData() } catch (error) { toast.error(error instanceof Error ? error.message : labels.deleteFailed) } finally { setActionLoadingId(null) } } const colSpan = columns.length + 1 return ( <> } > {filters.map((filter) => { const value = draftFilters[filter.name] if (filter.type === "select") { return (
setDraftFilters((current) => ({ ...current, [filter.name]: nextValue, })) } placeholder={filter.placeholder ?? filter.label} options={[...(filter.options ?? [])]} />
) } return (
setDraftFilters((current) => ({ ...current, [filter.name]: event.target.value, })) } onKeyDown={handleFilterKeyDown} placeholder={filter.placeholder ?? filter.label} />
) })}
{ if (nextPage < 1 || nextPage === page) return setPage(nextPage) }} onLimitChange={(nextLimit) => { setLimit(nextLimit) setPage(1) }} /> } > {columns.map((column) => ( {column.label} ))} {labels.actions} {result.results.map((item) => { const id = getItemId(item) const actionLoading = actionLoadingId === id return ( {columns.map((column) => ( {column.render(item, { item, actionLoading, actionLoadingId, reload: loadData, setActionLoadingId, })} ))} {renderRowActions || deleteItem ? ( } aria-label={labels.moreActions(item)} > {renderRowActions?.({ item, actionLoading, actionLoadingId, reload: loadData, setActionLoadingId, })} {deleteItem ? ( void handleDelete(item)} disabled={canDelete ? !canDelete(item) : false} className="text-destructive focus:text-destructive" > {actionLoading ? labels.processing : labels.delete} ) : null} ) : null} ) })} {loading || result.results.length === 0 ? ( ) : null}
{renderEditDialog({ open: dialogOpen, saving, item: editingItem, itemId: editingItem ? getItemId(editingItem) : null, onOpenChange: handleDialogOpenChange, onSubmit: handleSubmit, })} ) }