"use client" import type { ReactNode } from "react" import { useCallback, useEffect, useState } from "react" import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core" import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { GripVerticalIcon, MoreHorizontalIcon, PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon, } from "lucide-react" import { toast } from "sonner" import { useConfirm, type ConfirmOptions } from "@/components/confirm-provider" import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar, } from "@/components/dashboard-page" import { ListPagination } from "@/components/list-pagination" import { OptionCombobox } from "@/components/option-combobox" import { useDashboardPagedList } from "@/components/dashboard/list" 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 { isDashboardCrudActionDisabled, isDashboardCrudActionVisible, type DashboardCrudActionRule, type DashboardCrudFormField, type DashboardCrudPageResult, type DashboardCrudQueryFilter, type DashboardCrudQueryValue, } from "./dashboard-crud-utils" import { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog" export type DashboardCrudFilter = DashboardCrudQueryFilter & { label: string placeholder?: string defaultValue: TValue type?: "text" | "select" className?: string options?: ReadonlyArray<{ value: string; label: string }> } export type DashboardCrudColumn = { key: string label: ReactNode className?: string render: (item: TItem, context: DashboardCrudRowActionContext) => ReactNode } export type DashboardCrudDialogProps = { open: boolean saving: boolean item: TItem | null itemId: number | null onOpenChange: (open: boolean) => void onSubmit: (payload: TPayload) => Promise } export type DashboardCrudRowActionContext = { item: TItem itemId: number actionLoading: boolean actionLoadingId: number | null reload: () => Promise setActionLoadingId: (id: number | null) => void } export type DashboardCrudRowAction = DashboardCrudActionRule & { key: string label: ReactNode | ((item: TItem) => ReactNode) icon?: ReactNode | ((item: TItem) => ReactNode) variant?: "default" | "destructive" confirm?: ConfirmOptions | ((item: TItem) => ConfirmOptions) run: (context: DashboardCrudRowActionContext) => Promise | void } export type DashboardCrudActionState = { onRefresh: () => void onCreate: () => void loading: boolean } export type DashboardCrudPageProps = { filters: DashboardCrudFilter[] columns: DashboardCrudColumn[] fetchList: ( query: Record ) => Promise> renderEditDialog?: (props: DashboardCrudDialogProps) => ReactNode form?: { fields: DashboardCrudFormField[] fetchDetail?: (id: number) => Promise transformSubmitValues?: ( values: Record, context: { mode: "create" | "edit"; item: TItem | null } ) => TPayload labels: { createTitle: string editTitle: string create: string save: string saving: string cancel: string loadingDetail: string required: string invalidNumber: string minValue: (min: number) => string maxValue: (max: number) => string } } getItemId: (item: TItem) => number createItem: (payload: TPayload) => Promise updateItem: (item: TItem, payload: TPayload) => Promise canEdit?: (item: TItem) => boolean onEditItem?: (item: TItem) => void deleteItem?: (item: TItem) => Promise canDelete?: (item: TItem) => boolean deleteConfirm?: false | ConfirmOptions | ((item: TItem) => ConfirmOptions) rowActions?: DashboardCrudRowAction[] renderRowActions?: (context: DashboardCrudRowActionContext) => ReactNode sort?: { enabled?: boolean disabled?: boolean onReorder: (items: TItem[]) => Promise successMessage?: string errorMessage: string handleLabel: string } pageSize?: number reloadKey?: string | number | null layout?: "page" | "fragment" showToolbar?: boolean showToolbarActions?: boolean tableShellClassName?: string onActionStateChange?: (state: DashboardCrudActionState) => void 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, form, getItemId, createItem, updateItem, canEdit, onEditItem, deleteItem, canDelete, deleteConfirm, rowActions = [], renderRowActions, sort, pageSize = 20, reloadKey, layout = "page", showToolbar = true, showToolbarActions = true, tableShellClassName, onActionStateChange, labels, }: DashboardCrudPageProps) { const confirm = useConfirm() const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ) const list = useDashboardPagedList({ filters, fetchList, pageSize, reloadKey, loadFailed: labels.loadFailed, }) const draftFilters = list.draftFilters const setDraftFilter = list.setDraftFilter const loading = list.loading const result = list.result const loadData = list.loadData const [saving, setSaving] = useState(false) const [actionLoadingId, setActionLoadingId] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) const [editingItem, setEditingItem] = useState(null) function handleApplyFilters() { list.applyFilters() } function handleFilterKeyDown(event: React.KeyboardEvent) { if (event.key !== "Enter") return event.preventDefault() handleApplyFilters() } const openCreateDialog = useCallback(() => { setEditingItem(null) setDialogOpen(true) }, []) function openEditDialog(item: TItem) { setEditingItem(item) setDialogOpen(true) } useEffect(() => { onActionStateChange?.({ onRefresh: () => void loadData(), onCreate: openCreateDialog, loading, }) }, [loadData, loading, onActionStateChange, openCreateDialog]) 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 if (deleteConfirm !== false) { const confirmed = await confirm( typeof deleteConfirm === "function" ? deleteConfirm(item) : { confirmText: labels.delete, variant: "destructive", ...deleteConfirm, } ) if (!confirmed) 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) } } async function runRowAction( action: DashboardCrudRowAction, item: TItem, actionLoading: boolean ) { if (actionLoading || isDashboardCrudActionDisabled(action, item)) return if (action.confirm) { const confirmed = await confirm( typeof action.confirm === "function" ? action.confirm(item) : action.confirm ) if (!confirmed) return } const id = getItemId(item) setActionLoadingId(id) try { await action.run({ item, itemId: id, actionLoading: true, actionLoadingId: id, reload: loadData, setActionLoadingId, }) } finally { setActionLoadingId(null) } } async function handleSortEnd(event: DragEndEvent) { if (!sort?.enabled || sort.disabled) return const { active, over } = event if (!over || active.id === over.id) return const oldIndex = result.results.findIndex( (item) => String(getItemId(item)) === String(active.id) ) const newIndex = result.results.findIndex( (item) => String(getItemId(item)) === String(over.id) ) if (oldIndex < 0 || newIndex < 0) return const previousResults = result.results const nextResults = arrayMove(result.results, oldIndex, newIndex) list.setResult((current) => ({ ...current, results: nextResults })) try { await sort.onReorder(nextResults) if (sort.successMessage) { toast.success(sort.successMessage) } } catch (error) { list.setResult((current) => ({ ...current, results: previousResults })) toast.error(error instanceof Error ? error.message : sort.errorMessage) } } const sortable = Boolean(sort?.enabled) const colSpan = columns.length + 1 + (sortable ? 1 : 0) function renderTableRow( item: TItem, options: { sortable?: boolean; sortDisabled?: boolean } = {} ) { const id = getItemId(item) const actionLoading = actionLoadingId === id const rowCells = ( <> {columns.map((column) => ( {column.render(item, { item, itemId: id, actionLoading, actionLoadingId, reload: loadData, setActionLoadingId, })} ))} {renderRowActions || rowActions.length > 0 || deleteItem ? ( } aria-label={labels.moreActions(item)} > {rowActions .filter((action) => isDashboardCrudActionVisible(action, item)) .map((action) => { const icon = typeof action.icon === "function" ? action.icon(item) : action.icon const label = typeof action.label === "function" ? action.label(item) : action.label return ( void runRowAction(action, item, actionLoading)} > {icon} {actionLoading ? labels.processing : label} ) })} {renderRowActions?.({ item, itemId: id, 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} ) if (options.sortable) { return ( {rowCells} ) } return {rowCells} } function renderStateRow() { if (!loading && result.results.length > 0) return null return ( ) } const tableElement = ( {sortable ? : null} {columns.map((column) => ( {column.label} ))} {labels.actions} {sortable ? ( String(getItemId(item)))} strategy={verticalListSortingStrategy} > {result.results.map((item) => renderTableRow(item, { sortable: true, sortDisabled: sort?.disabled, }) )} {renderStateRow()} ) : ( {result.results.map((item) => renderTableRow(item))} {renderStateRow()} )}
) const table = sortable ? ( void handleSortEnd(event)} > {tableElement} ) : ( tableElement ) const content = ( <> {showToolbar ? ( ) : null } > {filters.map((filter) => { const value = draftFilters[filter.name] if (filter.type === "select") { return (
setDraftFilter(filter.name, nextValue)} placeholder={filter.placeholder ?? filter.label} options={[...(filter.options ?? [])]} />
) } return (
setDraftFilter(filter.name, event.target.value) } onKeyDown={handleFilterKeyDown} placeholder={filter.placeholder ?? filter.label} />
) })}
) : null} } > {table} ) const dialog = form ? ( ) : ( renderEditDialog?.({ open: dialogOpen, saving, item: editingItem, itemId: editingItem ? getItemId(editingItem) : null, onOpenChange: handleDialogOpenChange, onSubmit: handleSubmit, }) ) if (layout === "fragment") { return ( <> {content} {dialog} ) } return ( <> {content} {dialog} ) } function DashboardCrudSortableRow({ id, disabled, handleLabel, children, }: { id: string disabled?: boolean handleLabel: string children: ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id, disabled }) return ( {children} ) }