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.
This commit is contained in:
@@ -1,10 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
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> = {
|
||||
@@ -38,6 +55,12 @@ export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourceP
|
||||
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>({
|
||||
@@ -47,14 +70,62 @@ export function CrudResource<TClient extends ResourcePageClient>({
|
||||
queryOptions,
|
||||
paramKey,
|
||||
extraParams,
|
||||
localState,
|
||||
onRowClick,
|
||||
tableHeader,
|
||||
tableProps,
|
||||
render,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search...",
|
||||
statusFilter,
|
||||
}: CrudResourceProps<TClient>) {
|
||||
type TItem = ResourceItem<TClient>
|
||||
|
||||
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
|
||||
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({
|
||||
@@ -81,9 +152,49 @@ export function CrudResource<TClient extends ResourcePageClient>({
|
||||
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 = (
|
||||
<>
|
||||
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
|
||||
{extraHeaderAbove}
|
||||
{headerRow}
|
||||
<DataTable
|
||||
{...tableProps}
|
||||
columns={columns as ColumnDef<TItem, any>[]}
|
||||
|
||||
@@ -20,4 +20,5 @@ export type {
|
||||
CrudResourceColumnHelpers,
|
||||
CrudResourceContext,
|
||||
CrudResourceProps,
|
||||
StatusFilterConfig,
|
||||
} from "./crud-resource"
|
||||
|
||||
@@ -26,6 +26,8 @@ export type UseResourcePageOptions<TClient extends ResourcePageClient> = {
|
||||
queryOptions?: Omit<UseQueryOptions<CrudListResponse<TClient>>, "queryKey" | "queryFn">
|
||||
paramKey?: string
|
||||
extraParams?: Record<string, unknown>
|
||||
/** When true, pagination/sorting state is local instead of URL-synced. Useful for tables inside dialogs. */
|
||||
localState?: boolean
|
||||
}
|
||||
|
||||
export function useResourcePage<TClient extends ResourcePageClient>({
|
||||
@@ -34,6 +36,7 @@ export function useResourcePage<TClient extends ResourcePageClient>({
|
||||
queryOptions,
|
||||
paramKey,
|
||||
extraParams,
|
||||
localState,
|
||||
}: UseResourcePageOptions<TClient>) {
|
||||
type TItem = ResourceItem<TClient>
|
||||
|
||||
@@ -51,6 +54,7 @@ export function useResourcePage<TClient extends ResourcePageClient>({
|
||||
client,
|
||||
queryOptions,
|
||||
extraParams,
|
||||
localState,
|
||||
})
|
||||
|
||||
const { mutateAsync: deleteItem } = useMutation({
|
||||
@@ -59,8 +63,11 @@ export function useResourcePage<TClient extends ResourcePageClient>({
|
||||
const promise = client.destroy(id)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting...",
|
||||
success: "Deleted successfully",
|
||||
error: "Failed to delete",
|
||||
success: (data: any) => data?.message ?? "Deleted successfully",
|
||||
// Surface the backend's actual error (validation/constraint message)
|
||||
// so a failed delete doesn't appear silent to the user.
|
||||
error: (err: any) =>
|
||||
err?.payload?.message ?? err?.message ?? "Failed to delete",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
@@ -87,7 +94,13 @@ export function useResourcePage<TClient extends ResourcePageClient>({
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) await deleteItem(String(row.id))
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await deleteItem(String(row.id))
|
||||
} catch {
|
||||
// toast.promise already surfaced the error; swallow to avoid
|
||||
// an unhandled promise rejection bubbling up from the menu click.
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table"
|
||||
import type { ComponentType } from "react"
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
@@ -10,9 +11,20 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
|
||||
export type ActionsColumnExtraItem<TData> = {
|
||||
label: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
onClick: (row: TData) => void | Promise<unknown>
|
||||
variant?: "default" | "destructive"
|
||||
/** Hide the item entirely when false. Useful for permission gates. */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type ActionsColumnOptions<TData> = {
|
||||
onEdit?: (row: TData) => void
|
||||
onDelete?: (row: TData) => Promise<unknown>
|
||||
/** Extra menu items rendered between Edit and Delete. */
|
||||
extraItems?: (row: TData) => ActionsColumnExtraItem<TData>[]
|
||||
}
|
||||
|
||||
export function createActionsColumn<TData extends { id: string | number }>(
|
||||
@@ -53,6 +65,24 @@ function ActionsCell<TData extends { id: string | number }>({
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{options.extraItems?.(row.original)
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.label}
|
||||
variant={item.variant}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void item.onClick(row.original)
|
||||
}}
|
||||
>
|
||||
{Icon ? <Icon className="size-3.5 text-muted-foreground" /> : null}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
{options.onDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQueryStates } from "nuqs"
|
||||
import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"
|
||||
import type { DataViewChangeEvent, DataViewPaginationState, DataViewSorting } from "./types"
|
||||
import { dataTableSearchParams } from "./search-params"
|
||||
import { type CrudListResponse } from "@garage/api"
|
||||
import { DEFAULT_PER_PAGE, type CrudListResponse } from "@garage/api"
|
||||
|
||||
type DataTableClient = {
|
||||
list(query?: any): Promise<any>
|
||||
}
|
||||
|
||||
type DataTableParams = {
|
||||
page: number
|
||||
per_page: number
|
||||
sort_by: string | null
|
||||
sort_order: "asc" | "desc" | null
|
||||
}
|
||||
|
||||
type UseDataTableQueryOptions<C extends DataTableClient> = {
|
||||
queryKey: string[]
|
||||
client: C
|
||||
queryOptions?: Omit<UseQueryOptions<CrudListResponse<C>>, "queryKey" | "queryFn">
|
||||
extraParams?: Record<string, unknown>
|
||||
/** When true, pagination/sorting state lives in component state instead of URL params. Used for tables inside dialogs so they don't share state with the parent page. */
|
||||
localState?: boolean
|
||||
}
|
||||
|
||||
export function useDataTableQuery<C extends DataTableClient>({
|
||||
@@ -22,8 +32,25 @@ export function useDataTableQuery<C extends DataTableClient>({
|
||||
client,
|
||||
queryOptions,
|
||||
extraParams,
|
||||
localState = false,
|
||||
}: UseDataTableQueryOptions<C>) {
|
||||
const [params, setParams] = useQueryStates(dataTableSearchParams)
|
||||
const [urlParams, setUrlParams] = useQueryStates(dataTableSearchParams)
|
||||
const [localParams, setLocalParams] = useState<DataTableParams>({
|
||||
page: 1,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
sort_by: null,
|
||||
sort_order: null,
|
||||
})
|
||||
|
||||
const params: DataTableParams = localState ? localParams : urlParams
|
||||
const setParams = (next: Partial<DataTableParams>) => {
|
||||
if (localState) {
|
||||
setLocalParams((prev) => ({ ...prev, ...next }))
|
||||
} else {
|
||||
setUrlParams(next)
|
||||
}
|
||||
}
|
||||
|
||||
const _queryKey = [...queryKey, params, ...(extraParams ? [extraParams] : [])]
|
||||
const query = useQuery<CrudListResponse<C>>({
|
||||
queryKey: _queryKey,
|
||||
@@ -74,7 +101,10 @@ export function useDataTableQuery<C extends DataTableClient>({
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const invalidateQuery = () => {
|
||||
queryClient.invalidateQueries({ queryKey: _queryKey })
|
||||
// Prefix-match on the base route key so all paginated/sorted variants of
|
||||
// the list refetch — exact-matching the params object is flaky because
|
||||
// nuqs returns a fresh reference per render.
|
||||
queryClient.invalidateQueries({ queryKey, refetchType: "active" })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user