finish bills , po , jobcards relational fields creation

This commit is contained in:
2026-04-13 18:48:15 +03:00
parent 3eb471a604
commit 020ffccfd6
41 changed files with 11973 additions and 467 deletions
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query"
import { RefreshCw } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { AUTO_GENERATE_ROUTES } from "@garage/api"
import { AUTO_GENERATE_ROUTES, Tables } from "@garage/api"
import { FieldShell } from "../field-shell"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
@@ -21,7 +21,7 @@ type RhfAutoGenerateFieldProps<
required?: boolean
disabled?: boolean
placeholder?: string
table: string
table: Tables
/** When true, fetches the next code immediately on mount */
autoFetch?: boolean
}
@@ -3,6 +3,7 @@
import type { FieldValues } from "react-hook-form"
import { FormProvider } from "react-hook-form"
import type { RhformProps } from "./types"
import { cn } from "@/shared/lib/utils"
export function Rhform<TValues extends FieldValues>({
form,
@@ -15,7 +16,7 @@ export function Rhform<TValues extends FieldValues>({
<form
onSubmit={(e) => { e.stopPropagation(); form.handleSubmit(onSubmit)(e) }}
noValidate
className={className}
className={cn('p-1',className)}
>
{children}
</form>
@@ -0,0 +1,2 @@
export { ResourceSelectorDialog, type ResourceSelectorDialogProps } from "./resource-selector-dialog"
export { RhfResourceField, type RhfResourceFieldProps } from "./rhf-resource-field"
@@ -0,0 +1,93 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog"
import { CrudResource, type CrudResourceProps } from "@/shared/data-view/resource-page"
import type { ResourcePageClient, ResourceItem } from "@/shared/data-view/resource-page"
import { Card, CardContent } from "@/shared/components/ui/card"
export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
/** Dialog title shown in the header */
title: string
/** Opens/closes the dialog */
open: boolean
onOpenChange: (open: boolean) => void
/** Called when user confirms selection */
onConfirm: (selectedRows: ResourceItem<TClient>[]) => void
/** CrudResource config for the table (columns, routeKey, getClient, etc.) */
crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps">
/** Optional rowKey for selection identity. Defaults to "id" */
rowKey?: keyof ResourceItem<TClient>
}
export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
title,
open,
onOpenChange,
onConfirm,
crudProps,
rowKey,
}: ResourceSelectorDialogProps<TClient>) {
type TItem = ResourceItem<TClient>
const selectedRef = useRef<TItem[]>([])
const [count, setCount] = useState(0)
const handleSelectionChange = (rows: TItem[]) => {
selectedRef.current = rows
setCount(rows.length)
}
const handleConfirm = () => {
onConfirm(selectedRef.current)
selectedRef.current = []
setCount(0)
onOpenChange(false)
}
const handleCancel = () => {
selectedRef.current = []
setCount(0)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
<Card className="rounded-none border-0 shadow-none">
<CardContent className="p-0">
<CrudResource<TClient>
{...crudProps}
tableProps={{
selection: {
onSelectionChange: handleSelectionChange,
...(rowKey ? { rowKey } : {}),
},
}}
/>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={count === 0}>
Confirm{count > 0 ? ` (${count})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,131 @@
"use client"
import { useState, type ReactNode } from "react"
import { useController, useFormContext, type FieldValues, type FieldPath } from "react-hook-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Field, FieldError } from "@/shared/components/ui/field"
import { Plus } from "lucide-react"
import {
ResourceSelectorDialog,
type ResourceSelectorDialogProps,
} from "./resource-selector-dialog"
import type { ResourcePageClient, ResourceItem } from "@/shared/data-view/resource-page"
export type RhfResourceFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TClient extends ResourcePageClient,
> = {
/** RHF field name — value is an array of items after mapping */
name: TName
/** Label displayed on the card header */
label: string
/** Button text for the trigger */
triggerLabel?: string
/** Map a selected resource row into the form item shape */
mapSelected: (row: ResourceItem<TClient>) => TValues[TName][number]
/** Render the list of selected items inside the card */
renderItems: (
items: TValues[TName],
helpers: {
remove: (index: number) => void
update: (index: number, item: TValues[TName][number]) => void
replace: (items: TValues[TName]) => void
},
) => ReactNode
/** Props forwarded to ResourceSelectorDialog (columns, routeKey, getClient, etc.) */
dialogProps: Omit<
ResourceSelectorDialogProps<TClient>,
"open" | "onOpenChange" | "onConfirm"
>
/** Deduplicate by this key when merging new selections. Defaults to "id" */
itemKey?: string
}
export function RhfResourceField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TClient extends ResourcePageClient,
>({
name,
label,
triggerLabel,
mapSelected,
renderItems,
dialogProps,
itemKey = "id",
}: RhfResourceFieldProps<TValues, TName, TClient>) {
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control })
const [open, setOpen] = useState(false)
const items: TValues[TName] = field.value ?? ([] as unknown as TValues[TName])
const handleConfirm = (rows: ResourceItem<TClient>[]) => {
const mapped = rows.map(mapSelected)
// Merge: keep existing items, add new ones (deduplicate by itemKey)
const existingKeys = new Set(
(items as any[]).map((item: any) => String(item[itemKey])),
)
const newItems = mapped.filter(
(item: any) => !existingKeys.has(String(item[itemKey])),
)
field.onChange([...items, ...newItems])
}
const helpers = {
remove: (index: number) => {
const next = [...(items as any[])]
next.splice(index, 1)
field.onChange(next)
},
update: (index: number, item: TValues[TName][number]) => {
const next = [...(items as any[])]
next[index] = item
field.onChange(next)
},
replace: (newItems: TValues[TName]) => {
field.onChange(newItems)
},
}
return (
<Field data-invalid={!!error || undefined}>
<Card>
<CardHeader className=" flex justify-between items-center">
<span className="text-base font-medium">{label}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setOpen(true)}
>
<Plus className="mr-1.5 h-4 w-4" />
{triggerLabel ?? label}
</Button>
</CardHeader>
<CardContent>
{(items as any[]).length > 0
? renderItems(items, helpers)
: (
<p className="text-sm text-muted-foreground text-center py-4">
No items added yet.
</p>
)}
</CardContent>
</Card>
{error && <FieldError>{error.message}</FieldError>}
<ResourceSelectorDialog<TClient>
{...dialogProps}
open={open}
onOpenChange={setOpen}
onConfirm={handleConfirm}
/>
</Field>
)
}
@@ -1,7 +1,7 @@
"use client"
import React from "react"
import { DataTable, type ActionsColumnOptions } from "@/shared/data-view/table-view"
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"
@@ -28,10 +28,13 @@ 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: ColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => ColumnDef<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
}
@@ -44,6 +47,7 @@ export function CrudResource<TClient extends ResourcePageClient>({
extraParams,
onRowClick,
tableHeader,
tableProps,
render,
}: CrudResourceProps<TClient>) {
type TItem = ResourceItem<TClient>
@@ -79,13 +83,14 @@ export function CrudResource<TClient extends ResourcePageClient>({
<>
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
<DataTable
{...tableProps}
columns={columns}
data={items}
pagination={page.pagination}
sorting={page.sorting}
onChange={page.handleChange}
isLoading={page.isLoading}
onRowClick={onRowClick}
onRowClick={onRowClick ?? tableProps?.onRowClick}
/>
</>
)
@@ -1,10 +1,12 @@
"use client"
import { useState, useMemo, useEffect, useRef } from "react"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
type RowSelectionState,
} from "@tanstack/react-table"
import {
Table,
@@ -15,8 +17,9 @@ import {
TableRow,
TableCell,
} from "@/shared/components/ui/table"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { DataViewProvider } from "./data-view-context"
import type { DataViewProps } from "./types"
import type { DataViewProps } from "./types"
import { DataViewPagination } from "./data-view-pagination"
import { Skeleton } from "@/shared/components/ui/skeleton"
@@ -29,20 +32,83 @@ export function DataTable<TData>({
isLoading = false,
onRowClick,
slots,
selection,
}: DataViewProps<TData>) {
const rowKeyStr = (selection?.rowKey as string) ?? "id"
// Persisted map of id → original row data across all pages
const persistedMap = useRef<Map<string, TData>>(new Map())
// Current-page selection state that TanStack Table controls
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// When the page/data changes, restore selection state for the new page from the map
useEffect(() => {
if (!selection) return
const restored: RowSelectionState = {}
data.forEach((row) => {
const id = String((row as Record<string, unknown>)[rowKeyStr])
if (persistedMap.current.has(id)) restored[id] = true
})
setRowSelection(restored)
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
const selectionColumn: ColumnDef<TData, unknown> = useMemo(
() => ({
id: "__select__",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
onClick={(e) => e.stopPropagation()}
/>
),
size: 40,
enableSorting: false,
}),
[],
)
const resolvedColumns = useMemo(
() =>
selection
? [selectionColumn, ...(columns as ColumnDef<TData, unknown>[])]
: (columns as ColumnDef<TData, unknown>[]),
[selection, columns, selectionColumn],
)
const table = useReactTable({
data,
columns: columns as ColumnDef<TData, unknown>[],
columns: resolvedColumns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
pageCount: pagination.pageCount,
enableRowSelection: !!selection,
getRowId: selection
? (row) => String((row as Record<string, unknown>)[(selection.rowKey as string) ?? "id"])
: undefined,
state: {
sorting,
pagination: {
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize,
},
rowSelection,
},
onSortingChange: (updater) => {
const next = typeof updater === "function" ? updater(sorting) : updater
@@ -61,6 +127,22 @@ export function DataTable<TData>({
},
})
},
onRowSelectionChange: (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater
setRowSelection(next)
if (selection) {
// Sync current page into the persisted map
data.forEach((row) => {
const id = String((row as Record<string, unknown>)[rowKeyStr])
if (next[id]) {
persistedMap.current.set(id, row)
} else {
persistedMap.current.delete(id)
}
})
selection.onSelectionChange(Array.from(persistedMap.current.values()))
}
},
})
return (
@@ -26,6 +26,12 @@ export type DataViewSlots = {
footer?: ReactNode
}
export type DataViewSelection<TData> = {
onSelectionChange: (selectedRows: TData[]) => void
/** Field used as the unique row identifier. Defaults to "id". */
rowKey?: keyof TData
}
export type DataViewProps<TData> = {
columns: ColumnDef<TData, any>[]
data: TData[]
@@ -35,4 +41,5 @@ export type DataViewProps<TData> = {
isLoading?: boolean
onRowClick?: (row: TData) => void
slots?: DataViewSlots
selection?: DataViewSelection<TData>
}