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,
}
}
@@ -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"
+13 -13
View File
@@ -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>
)
}}
/>
)
}
+82
View File
@@ -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)
}
+3 -3
View File
@@ -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()
}