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:
2026-05-18 12:08:42 +04:00
parent 54d11f01b4
commit 4bfd8c84a9
111 changed files with 5002 additions and 1047 deletions
@@ -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,
})