humam kerdiah 4bfd8c84a9 feat: add template checkpoint edit dialog and vendor management components
- Implemented TemplateCheckpointEditDialog for creating and editing inspection checkpoints.
- Added VendorActions component for managing vendor actions including edit, activate/deactivate, and delete.
- Created VendorContext for managing vendor state across components.
- Developed VendorGeneralInfo component to display detailed vendor information.
- Introduced AedSymbol and Money components for consistent currency representation.
- Added PromptDialog for user input prompts throughout the application.
- Implemented RelationLink component for unified related-data display in CRUD tables.
- Created InspectionTemplatesClient for API interactions related to inspection templates.
2026-05-18 12:08:42 +04:00

214 lines
7.9 KiB
TypeScript

"use client"
import React, { useEffect, useMemo, useRef, useState } from "react"
import { SearchIcon } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { formatEnum } from "@/shared/utils/formatters"
import { DataTable, type ActionsColumnOptions, type DataViewProps } 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 StatusFilterConfig = {
/** Allowed status values (typically the matching `<Resource>Status` enum). */
statuses: readonly string[]
/** Query-param key forwarded to the API. Defaults to "status". */
paramKey?: string
/** Initial selected tab. Defaults to "all". */
defaultValue?: string
/** Label for the catch-all tab. Defaults to "All". */
allLabel?: string
/** Per-status label transform. Defaults to formatEnum (snake_case → Title Case). */
formatLabel?: (status: string) => string
}
type LooseColumnDef<TData> = ColumnDef<TData, unknown> | (Omit<ColumnDef<TData, unknown>, "accessorKey"> & { accessorKey: string })
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)
type ManagedTableProps = "columns" | "data" | "pagination" | "sorting" | "onChange" | "isLoading"
export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
columns: LooseColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => LooseColumnDef<ResourceItem<TClient>>[])
onRowClick?: (row: ResourceItem<TClient>) => void
tableHeader?: ReactNodeOrRender<TClient>
tableProps?: Omit<Partial<DataViewProps<ResourceItem<TClient>>>, ManagedTableProps>
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
/** Render a built-in search input above the table and forward `search` to the API. */
searchable?: boolean
/** Placeholder for the built-in search input. */
searchPlaceholder?: string
/** Render a built-in status tab strip above the table and forward the selected status to the API. */
statusFilter?: StatusFilterConfig
}
export function CrudResource<TClient extends ResourcePageClient>({
columns: columnsProp,
routeKey,
getClient,
queryOptions,
paramKey,
extraParams,
localState,
onRowClick,
tableHeader,
tableProps,
render,
searchable = false,
searchPlaceholder = "Search...",
statusFilter,
}: CrudResourceProps<TClient>) {
type TItem = ResourceItem<TClient>
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const statusParamKey = statusFilter?.paramKey ?? "status"
const statusAllValue = statusFilter?.defaultValue ?? "all"
const [statusValue, setStatusValue] = useState<string>(statusAllValue)
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timer)
}, [searchInput])
const mergedExtraParams = useMemo(() => {
const base = { ...(extraParams ?? {}) } as Record<string, unknown>
if (searchable && search) base.search = search
if (statusFilter && statusValue !== statusAllValue) {
base[statusParamKey] = statusValue
}
return base
}, [extraParams, search, searchable, statusFilter, statusValue, statusAllValue, statusParamKey])
const page = useResourcePage<TClient>({
routeKey,
getClient,
queryOptions,
paramKey,
extraParams: mergedExtraParams,
localState,
})
const prevSearchRef = useRef(search)
const prevStatusRef = useRef(statusValue)
const setParamsRef = useRef(page.setParams)
setParamsRef.current = page.setParams
useEffect(() => {
if (prevSearchRef.current !== search) {
prevSearchRef.current = search
setParamsRef.current({ page: 1 })
}
}, [search])
useEffect(() => {
if (prevStatusRef.current !== statusValue) {
prevStatusRef.current = statusValue
setParamsRef.current({ page: 1 })
}
}, [statusValue])
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 searchInputEl = searchable ? (
<div className="relative w-64">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8"
/>
</div>
) : null
const statusTabsEl = statusFilter ? (
<Tabs value={statusValue} onValueChange={setStatusValue}>
<TabsList variant="line">
<TabsTrigger value={statusAllValue}>{statusFilter.allLabel ?? "All"}</TabsTrigger>
{statusFilter.statuses.map((status) => (
<TabsTrigger key={status} value={status}>
{(statusFilter.formatLabel ?? formatEnum)(status)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null
const resolvedTableHeader = tableHeader
? (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)
: null
const leftHeader = statusTabsEl ?? resolvedTableHeader
const extraHeaderAbove = statusTabsEl && resolvedTableHeader ? resolvedTableHeader : null
const headerRow = (searchInputEl || leftHeader) ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex-1 min-w-0">{leftHeader}</div>
{searchInputEl}
</div>
) : null
const table = (
<>
{extraHeaderAbove}
{headerRow}
<DataTable
{...tableProps}
columns={columns as ColumnDef<TItem, any>[]}
data={items}
pagination={page.pagination}
sorting={page.sorting}
onChange={page.handleChange}
isLoading={page.isLoading}
onRowClick={onRowClick ?? tableProps?.onRowClick}
/>
</>
)
if (render) return render(table, context)
return table
}