finish bills , po , jobcards relational fields creation
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user