updates
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { Plus } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
export const formDialogParams = {
|
||||
dialog: parseAsBoolean.withDefault(false),
|
||||
@@ -61,23 +62,29 @@ export default function FormDialog(props: {
|
||||
children: (resourceId: string | null) => React.ReactNode
|
||||
title: string
|
||||
paramKey?: string
|
||||
classNames?: {
|
||||
trigger?: string
|
||||
dialogContent?: string
|
||||
scrollArea?: string
|
||||
}
|
||||
|
||||
}) {
|
||||
const { isOpen, resourceId, open, close } = useFormDialog(props.paramKey)
|
||||
const { isOpen, resourceId, open, close } = useFormDialog(props.paramKey)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size='lg' onClick={() => open()}>
|
||||
<Button size='sm' className={cn(props.classNames?.trigger)} onClick={() => open()}>
|
||||
<Plus />
|
||||
{props.title}
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) close() }}>
|
||||
<DialogContent className='min-w-xl'>
|
||||
<DialogContent className={`min-w-xl ${cn(props.classNames?.dialogContent)}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-2xl font-bold'>
|
||||
{props.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='max-h-[80vh] px-4'>
|
||||
<ScrollArea className={`max-h-[80vh] px-4 ${cn(props.classNames?.scrollArea)}`}>
|
||||
{props.children(resourceId)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
|
||||
@@ -54,6 +54,12 @@ export function AsyncSelectField<TOption = AsyncOption>({
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
disabled={disabled}
|
||||
isItemEqualToValue={(item, val) => {
|
||||
if (item !== null && typeof item === "object" && "value" in (item as object)) {
|
||||
return (item as any).value === (val as any)?.value
|
||||
}
|
||||
return item === val
|
||||
}}
|
||||
onInputValueChange={(val, { reason }) => {
|
||||
if (reason === "input-change") {
|
||||
onInputValueChange?.(val)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFormContext, type FieldValues, type FieldPath } from "react-hook-form"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { AUTO_GENERATE_ROUTES } from "@garage/api"
|
||||
import { FieldShell } from "../field-shell"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
type RhfAutoGenerateFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
table: string
|
||||
/** When true, fetches the next code immediately on mount */
|
||||
autoFetch?: boolean
|
||||
}
|
||||
|
||||
export function RhfAutoGenerateField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
placeholder,
|
||||
table,
|
||||
autoFetch = false,
|
||||
}: RhfAutoGenerateFieldProps<TValues, TName>) {
|
||||
const api = useAuthApi()
|
||||
const { setValue, watch, formState: { errors } } = useFormContext<TValues>()
|
||||
const value = watch(name)
|
||||
const error = errors[name]
|
||||
|
||||
const [enabled, setEnabled] = useState(autoFetch)
|
||||
|
||||
const { isFetching } = useQuery({
|
||||
queryKey: [AUTO_GENERATE_ROUTES.BY_TABLE, table],
|
||||
queryFn: async () => {
|
||||
const res = await api.autoGenerate.generate(table)
|
||||
const generated = (res as any)?.data
|
||||
if (generated) {
|
||||
setValue(name, generated as any, { shouldValidate: true })
|
||||
}
|
||||
return res
|
||||
},
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const handleGenerate = () => {
|
||||
setEnabled(false)
|
||||
// Reset and re-enable to trigger a fresh fetch
|
||||
setTimeout(() => setEnabled(true), 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message as string | undefined}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => setValue(name, e.target.value as any, { shouldValidate: true })}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error || undefined}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled || isFetching}
|
||||
onClick={handleGenerate}
|
||||
title="Auto-generate"
|
||||
>
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
</div>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
@@ -39,3 +39,4 @@ export { RhfAsyncSelectField, RhfAsyncMultiSelectField, type InlineCreateFormPro
|
||||
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
|
||||
export { RhfDateField } from "./fields/rhf-date-field"
|
||||
export { RhfTimeField } from "./fields/rhf-time-field"
|
||||
export { RhfAutoGenerateField } from "./fields/rhf-auto-generate-field"
|
||||
|
||||
@@ -137,7 +137,7 @@ function SidebarProvider({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -168,7 +168,7 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-card text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -186,7 +186,7 @@ function Sidebar({
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
className="w-(--sidebar-width) bg-card p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
@@ -241,7 +241,7 @@ function Sidebar({
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
className="flex size-full flex-col bg-card group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -288,10 +288,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-card-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize rtl:in-data-[side=left]:cursor-e-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-card",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
|
||||
className
|
||||
@@ -358,7 +358,7 @@ function SidebarSeparator({
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
className={cn("mx-2 w-auto bg-card-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -421,7 +421,7 @@ function SidebarGroupAction({
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -466,13 +466,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-card-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-card-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-card-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
default: "hover:bg-card-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-card-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm " ,
|
||||
@@ -553,7 +553,7 @@ function SidebarMenuAction({
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
@@ -666,7 +666,7 @@ function SidebarMenuSubButton({
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"flex h-7 min-w-0 -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-card-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-card-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { DataTable, type ActionsColumnOptions } 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 CrudResourceColumnHelpers<TClient extends ResourcePageClient> = {
|
||||
actionsColumn: (options?: Partial<ActionsColumnOptions<ResourceItem<TClient>>>) => ColumnDef<ResourceItem<TClient>, unknown>
|
||||
openEdit: (row: ResourceItem<TClient>) => void
|
||||
deleteItem: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type CrudResourceContext<TClient extends ResourcePageClient> = {
|
||||
selectedItem: ResourceItem<TClient> | null
|
||||
isDialogOpen: boolean
|
||||
dialogResourceId: string | null
|
||||
isLoading: boolean
|
||||
data: ResourceItem<TClient>[]
|
||||
openCreate: () => void
|
||||
openEdit: (row: ResourceItem<TClient>) => void
|
||||
closeDialog: () => void
|
||||
deleteItem: (id: string) => Promise<unknown>
|
||||
invalidateQuery: () => void
|
||||
}
|
||||
|
||||
type ReactNodeOrRender<TClient extends ResourcePageClient> =
|
||||
| React.ReactNode
|
||||
| ((context: CrudResourceContext<TClient>) => React.ReactNode)
|
||||
|
||||
export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
|
||||
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
||||
onRowClick?: (row: ResourceItem<TClient>) => void
|
||||
tableHeader?: ReactNodeOrRender<TClient>
|
||||
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
|
||||
}
|
||||
|
||||
export function CrudResource<TClient extends ResourcePageClient>({
|
||||
columns: columnsProp,
|
||||
routeKey,
|
||||
getClient,
|
||||
queryOptions,
|
||||
paramKey,
|
||||
extraParams,
|
||||
onRowClick,
|
||||
tableHeader,
|
||||
render,
|
||||
}: CrudResourceProps<TClient>) {
|
||||
type TItem = ResourceItem<TClient>
|
||||
|
||||
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
|
||||
|
||||
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<TClient> = {
|
||||
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 table = (
|
||||
<>
|
||||
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
pagination={page.pagination}
|
||||
sorting={page.sorting}
|
||||
onChange={page.handleChange}
|
||||
isLoading={page.isLoading}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (render) return render(table, context)
|
||||
return table
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useResourcePage } from "./use-resource-page"
|
||||
export { ResourcePage } from "./resource-page"
|
||||
export { CrudResource } from "./crud-resource"
|
||||
|
||||
export type {
|
||||
ResourcePageClient,
|
||||
@@ -14,3 +15,9 @@ export type {
|
||||
ResourcePageContext,
|
||||
ResourcePageProps,
|
||||
} from "./resource-page"
|
||||
|
||||
export type {
|
||||
CrudResourceColumnHelpers,
|
||||
CrudResourceContext,
|
||||
CrudResourceProps,
|
||||
} from "./crud-resource"
|
||||
|
||||
@@ -4,9 +4,12 @@ import React from "react"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import type { DashboardHeaderProps } from "@/base/components/layout/dashboard"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { DataTable, type ActionsColumnOptions } 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"
|
||||
import type { ResourcePageClient, ResourceItem } from "./use-resource-page"
|
||||
import { CrudResource, type CrudResourceContext, type CrudResourceColumnHelpers, type CrudResourceProps } from "./crud-resource"
|
||||
|
||||
// Re-exported for backward compatibility
|
||||
export type ResourcePageColumnHelpers<TClient extends ResourcePageClient> = CrudResourceColumnHelpers<TClient>
|
||||
export type ResourcePageContext<TClient extends ResourcePageClient> = CrudResourceContext<TClient>
|
||||
|
||||
export type ResourceFormProps<TClient extends ResourcePageClient> = {
|
||||
resourceId: string | null
|
||||
@@ -19,114 +22,47 @@ export type ResourcePageHeaderHelpers<TClient extends ResourcePageClient> = {
|
||||
invalidateQuery: () => void
|
||||
}
|
||||
|
||||
export type ResourcePageColumnHelpers<TClient extends ResourcePageClient> = {
|
||||
actionsColumn: (options?: Partial<ActionsColumnOptions<ResourceItem<TClient>>>) => ColumnDef<ResourceItem<TClient>, unknown>
|
||||
openEdit: (row: ResourceItem<TClient>) => void
|
||||
deleteItem: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type ResourcePageContext<TClient extends ResourcePageClient> = {
|
||||
selectedItem: ResourceItem<TClient> | null
|
||||
isDialogOpen: boolean
|
||||
dialogResourceId: string | null
|
||||
isLoading: boolean
|
||||
data: ResourceItem<TClient>[]
|
||||
openCreate: () => void
|
||||
openEdit: (row: ResourceItem<TClient>) => void
|
||||
closeDialog: () => void
|
||||
deleteItem: (id: string) => Promise<unknown>
|
||||
invalidateQuery: () => void
|
||||
}
|
||||
|
||||
type ReactNodeOrRender<TClient extends ResourcePageClient> =
|
||||
| React.ReactNode
|
||||
| ((context: ResourcePageContext<TClient>) => React.ReactNode)
|
||||
|
||||
export type ResourcePageProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
|
||||
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: ResourcePageColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
||||
export type ResourcePageProps<TClient extends ResourcePageClient> = Omit<CrudResourceProps<TClient>, "render"> & {
|
||||
headerProps?: DashboardHeaderProps | ((helpers: ResourcePageHeaderHelpers<TClient>) => DashboardHeaderProps)
|
||||
header?: ReactNodeOrRender<TClient> | null
|
||||
pageTitle?: string
|
||||
paramKey?: string
|
||||
onRowClick?: (row: ResourceItem<TClient>) => void
|
||||
toolbar?: ReactNodeOrRender<TClient>
|
||||
tableHeader?: ReactNodeOrRender<TClient>
|
||||
|
||||
}
|
||||
|
||||
export function ResourcePage<TClient extends ResourcePageClient>({
|
||||
columns: columnsProp,
|
||||
headerProps: headerPropsProp,
|
||||
header,
|
||||
pageTitle,
|
||||
routeKey,
|
||||
getClient,
|
||||
queryOptions,
|
||||
paramKey,
|
||||
onRowClick,
|
||||
toolbar,
|
||||
tableHeader,
|
||||
extraParams,
|
||||
...crudResourceProps
|
||||
}: ResourcePageProps<TClient>) {
|
||||
type TItem = ResourceItem<TClient>
|
||||
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
|
||||
|
||||
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: ResourcePageContext<TClient> = {
|
||||
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 resolvedHeaderProps = typeof headerPropsProp === "function"
|
||||
? headerPropsProp({
|
||||
selectedItem: page.selectedItem,
|
||||
invalidateQuery: () => page.invalidateQuery(),
|
||||
})
|
||||
: headerPropsProp
|
||||
|
||||
const resolvedHeader = typeof header === "function" ? header(context) : header
|
||||
const resolvedToolbar = typeof toolbar === "function" ? toolbar(context) : toolbar
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
header={resolvedHeader}
|
||||
headerProps={resolvedHeaderProps}
|
||||
title={pageTitle}
|
||||
toolbar={resolvedToolbar}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
pagination={page.pagination}
|
||||
sorting={page.sorting}
|
||||
onChange={page.handleChange}
|
||||
isLoading={page.isLoading}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardPage>
|
||||
<CrudResource<TClient>
|
||||
{...crudResourceProps}
|
||||
render={(table, context) => {
|
||||
const resolvedHeaderProps = typeof headerPropsProp === "function"
|
||||
? headerPropsProp({
|
||||
selectedItem: context.selectedItem,
|
||||
invalidateQuery: context.invalidateQuery,
|
||||
})
|
||||
: headerPropsProp
|
||||
|
||||
const resolvedHeader = typeof header === "function" ? header(context) : header
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
header={resolvedHeader}
|
||||
headerProps={resolvedHeaderProps}
|
||||
fullscreen
|
||||
>
|
||||
<Card className="rounded-none">
|
||||
<CardContent className="space-y-4">
|
||||
{table}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardPage>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Format a date string or Date to a long readable date: "Jan 6, 2026"
|
||||
*/
|
||||
export function formatDate(value?: string | Date | null): string {
|
||||
if (!value) return "—"
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (isNaN(date.getTime())) return "—"
|
||||
return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string or Date to date + time: "Jan 6, 2026, 2:30 PM"
|
||||
*/
|
||||
export function formatDateTime(value?: string | Date | null): string {
|
||||
if (!value) return "—"
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (isNaN(date.getTime())) return "—"
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string or Date to a short numeric date: "04/06/2026"
|
||||
*/
|
||||
export function formatDateShort(value?: string | Date | null): string {
|
||||
if (!value) return "—"
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (isNaN(date.getTime())) return "—"
|
||||
return date.toLocaleDateString(undefined, { year: "numeric", month: "2-digit", day: "2-digit" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time string or Date to a readable time: "2:30 PM"
|
||||
*/
|
||||
export function formatTime(value?: string | Date | null): string {
|
||||
if (!value) return "—"
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (isNaN(date.getTime())) return "—"
|
||||
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a snake_case or underscore-separated string to Title Case words.
|
||||
* e.g. "in_progress" → "In Progress"
|
||||
*/
|
||||
export function formatEnum(value?: string | null): string {
|
||||
if (!value) return "—"
|
||||
return value
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with locale-aware thousand separators.
|
||||
* e.g. 150000 → "150,000"
|
||||
*/
|
||||
export function formatNumber(value?: number | string | null): string {
|
||||
if (value == null || value === "") return "—"
|
||||
const num = typeof value === "string" ? Number(value) : value
|
||||
if (isNaN(num)) return "—"
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a numeric value as currency: "$1,500.00"
|
||||
*/
|
||||
export function formatCurrency(
|
||||
value?: number | string | null,
|
||||
currency = "USD",
|
||||
locale?: string,
|
||||
): string {
|
||||
if (value == null || value === "") return "—"
|
||||
const num = typeof value === "string" ? Number(value) : value
|
||||
if (isNaN(num)) return "—"
|
||||
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(num)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const getFullName = <T extends { first_name?: string | undefined; last_name?: string | undefined }>(user: T) => {
|
||||
const firstName = user.first_name ?? ""
|
||||
const lastName = user.last_name ?? ""
|
||||
export const getFullName = <T extends { first_name?: string | undefined; last_name?: string | undefined }>(user?: T) => {
|
||||
const firstName = user?.first_name ?? ""
|
||||
const lastName = user?.last_name ?? ""
|
||||
return `${firstName} ${lastName}`.trim()
|
||||
}
|
||||
Reference in New Issue
Block a user