This commit is contained in:
2026-04-07 06:32:40 +03:00
parent 24a44481a0
commit 11db1e6941
101 changed files with 54585 additions and 39160 deletions
@@ -0,0 +1,149 @@
"use client"
import React, { useState } from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { Settings2, Plus, ArrowLeft } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { DataTable } from "@/shared/data-view/table-view"
import { createActionsColumn } from "@/shared/data-view/table-view"
import { useCrudDialog, type CrudDialogClient, type UseCrudDialogOptions } from "./use-crud-dialog"
// ── Types ──
export type CrudDialogProps<TClient extends CrudDialogClient> = UseCrudDialogOptions<TClient> & {
/** Dialog title shown in the header */
title: string
/** Table columns (receives `openEdit` and `handleDelete` helpers) */
columns: (helpers: {
openEdit: (row: any) => void
handleDelete: (row: any) => Promise<void>
}) => ColumnDef<any>[]
/** Render the create/edit form */
renderForm: (props: {
resourceId: string | null
initialData: any
onSuccess: () => void
}) => React.ReactNode
/** Optional trigger button; defaults to a settings icon button */
trigger?: React.ReactNode
/** Custom trigger class */
triggerClassName?: string
}
// ── Component ──
export function CrudDialog<TClient extends CrudDialogClient>({
title,
columns: columnsFn,
renderForm,
trigger,
triggerClassName,
...hookOptions
}: CrudDialogProps<TClient>) {
const [isOpen, setIsOpen] = useState(false)
const crud = useCrudDialog(hookOptions)
const columns = columnsFn({
openEdit: crud.openEdit,
handleDelete: crud.handleDelete,
})
// Add actions column
const allColumns: ColumnDef<any>[] = [
...columns,
createActionsColumn({
onEdit: crud.openEdit,
onDelete: crud.handleDelete,
}),
]
const handleClose = () => {
setIsOpen(false)
crud.closeForm()
}
return (
<>
{trigger ? (
<div onClick={() => setIsOpen(true)}>{trigger}</div>
) : (
<Button
type="button"
size="icon"
variant="ghost"
className={triggerClassName ?? "h-5 w-5"}
onClick={() => setIsOpen(true)}
title={`Manage ${title}`}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
)}
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose() }}>
<DialogContent className="min-w-2xl max-w-3xl">
<DialogHeader>
<div className="flex items-center gap-2">
{crud.isFormOpen && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={crud.closeForm}
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<DialogTitle className="text-xl font-bold">
{crud.isFormOpen
? (crud.editingId ? `Edit ${title}` : `New ${title}`)
: title}
</DialogTitle>
</div>
</DialogHeader>
<ScrollArea className="max-h-[75vh]">
{crud.isFormOpen ? (
<div className="px-1">
{renderForm({
resourceId: crud.editingId,
initialData: crud.editingItem,
onSuccess: crud.handleFormSuccess,
})}
</div>
) : (
<div className="space-y-3 px-1">
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={crud.openCreate}
>
<Plus className="h-4 w-4" />
Add {title}
</Button>
</div>
<DataTable
columns={allColumns}
data={crud.items}
pagination={crud.pagination}
sorting={crud.sorting}
onChange={crud.handleChange}
isLoading={crud.isLoading}
/>
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,2 @@
export { CrudDialog, type CrudDialogProps } from "./crud-dialog"
export { useCrudDialog, type CrudDialogClient, type UseCrudDialogOptions } from "./use-crud-dialog"
@@ -0,0 +1,154 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { confirm } from "@/shared/components/confirm-dialog"
import type { DataViewChangeEvent, DataViewPaginationState, DataViewSorting } from "@/shared/data-view/table-view"
// ── Types ──
export type CrudDialogClient = {
list(query?: any): Promise<any>
create(payload: any): Promise<any>
update(id: string, payload: any): Promise<any>
destroy(id: string): Promise<any>
}
export type UseCrudDialogOptions<TClient extends CrudDialogClient> = {
queryKey: string[]
getClient: () => TClient
resourceLabel?: string
}
// ── Hook ──
export function useCrudDialog<TClient extends CrudDialogClient>({
queryKey,
getClient,
resourceLabel = "item",
}: UseCrudDialogOptions<TClient>) {
const client = getClient()
const queryClient = useQueryClient()
// ── Local pagination state (no URL pollution) ──
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [sortBy, setSortBy] = useState<string | null>(null)
const [sortOrder, setSortOrder] = useState<"asc" | "desc" | null>(null)
// ── Form dialog state ──
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editingItem, setEditingItem] = useState<any>(null)
const fullQueryKey = [...queryKey, { page, pageSize, sortBy, sortOrder }]
const { data, isLoading } = useQuery({
queryKey: fullQueryKey,
queryFn: () => {
const params: Record<string, unknown> = { page, per_page: pageSize }
if (sortBy) params.sort_by = sortBy
if (sortOrder) params.sort_order = sortOrder
return client.list(params)
},
})
const responseData = (data as any)?.data ?? []
const items = Array.isArray(responseData) ? responseData : []
const meta = (data as any)?.meta
const pagination: DataViewPaginationState = {
page,
pageSize,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
const sorting: DataViewSorting = sortBy
? [{ id: sortBy, desc: sortOrder === "desc" }]
: []
const handleChange = (event: DataViewChangeEvent) => {
switch (event.type) {
case "pagination":
setPage(event.pagination.page)
setPageSize(event.pagination.pageSize)
break
case "sorting": {
const sort = event.sorting[0]
setSortBy(sort?.id ?? null)
setSortOrder(sort ? (sort.desc ? "desc" : "asc") : null)
setPage(1)
break
}
}
}
const invalidateQuery = () => {
queryClient.invalidateQueries({ queryKey })
}
const { mutateAsync: deleteItem } = useMutation({
mutationFn: (id: string) => {
const promise = client.destroy(id)
toast.promise(promise, {
loading: `Deleting ${resourceLabel}...`,
success: `${resourceLabel} deleted`,
error: `Failed to delete ${resourceLabel}`,
})
return promise
},
onSuccess: invalidateQuery,
})
const openCreate = () => {
setEditingId(null)
setEditingItem(null)
setIsFormOpen(true)
}
const openEdit = (row: any) => {
setEditingId(String(row.id))
setEditingItem(row)
setIsFormOpen(true)
}
const closeForm = () => {
setIsFormOpen(false)
setEditingId(null)
setEditingItem(null)
}
const handleDelete = async (row: any) => {
const confirmed = await confirm({
title: `Delete this ${resourceLabel}?`,
description: "This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) await deleteItem(String(row.id))
}
const handleFormSuccess = () => {
invalidateQuery()
closeForm()
}
return {
items,
isLoading,
pagination,
sorting,
handleChange,
isFormOpen,
editingId,
editingItem,
openCreate,
openEdit,
closeForm,
handleDelete,
handleFormSuccess,
invalidateQuery,
}
}