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}