diff options
| author | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
| commit | 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch) | |
| tree | 8a5587f10ca55b162d7e3254cb088b323a34c41b /components/vendor-data/tag-table | |
initial commit
Diffstat (limited to 'components/vendor-data/tag-table')
| -rw-r--r-- | components/vendor-data/tag-table/add-tag-dialog.tsx | 357 | ||||
| -rw-r--r-- | components/vendor-data/tag-table/tag-table-column.tsx | 196 | ||||
| -rw-r--r-- | components/vendor-data/tag-table/tag-table.tsx | 39 | ||||
| -rw-r--r-- | components/vendor-data/tag-table/tag-type-definitions.ts | 87 |
4 files changed, 679 insertions, 0 deletions
diff --git a/components/vendor-data/tag-table/add-tag-dialog.tsx b/components/vendor-data/tag-table/add-tag-dialog.tsx new file mode 100644 index 00000000..1321fc58 --- /dev/null +++ b/components/vendor-data/tag-table/add-tag-dialog.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { createTagSchema, type CreateTagSchema } from "@/lib/tags/validations" +import { createTag } from "@/lib/tags/service" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { useRouter } from "next/navigation" + +// Popover + Command +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { ChevronsUpDown, Check } from "lucide-react" + +// The dynamic Tag Type definitions +import { tagTypeDefinitions } from "./tag-type-definitions" + +// Add Select component for dropdown fields +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<CreateTagSchema>({ + resolver: zodResolver(createTagSchema), + defaultValues: { + tagType: "", // user picks + tagNo: "", // auto-generated + description: "", + functionCode: "", + seqNumber: "", + valveAcronym: "", + processUnit: "", + }, + }) + + const watchAll = useWatch({ control: form.control }) + + // 1) Find the selected tag type definition + const currentTagTypeDef = React.useMemo(() => { + return tagTypeDefinitions.find((def) => def.id === watchAll.tagType) || null + }, [watchAll.tagType]) + + // 2) Whenever the user changes sub-fields, re-generate `tagNo` + React.useEffect(() => { + if (!currentTagTypeDef) { + // if no type selected, no auto-generation + return + } + + // Prevent infinite loop by excluding tagNo from the watched dependencies + // This is crucial because setting tagNo would trigger another update + const { tagNo, ...fieldsToWatch } = watchAll + + const newTagNo = currentTagTypeDef.generateTagNo(fieldsToWatch as CreateTagSchema) + + // Only update if different to avoid unnecessary re-renders + if (form.getValues("tagNo") !== newTagNo) { + form.setValue("tagNo", newTagNo, { shouldValidate: false }) + } + }, [currentTagTypeDef, watchAll, form]) + + // Check if tag number is valid (doesn't contain '??' and is not empty) + const isTagNoValid = React.useMemo(() => { + const tagNo = form.getValues("tagNo"); + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }, [form, watchAll.tagNo]); + + // onSubmit + async function onSubmit(data: CreateTagSchema) { + startTransition(async () => { + if (!selectedPackageId) { + toast.error("No selectedPackageId.") + return + } + + const result = await createTag(data, selectedPackageId) + if ("error" in result) { + toast.error(`Error: ${result.error}`) + return + } + + toast.success("Tag created successfully!") + form.reset() + setOpen(false) + router.refresh() + + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + // 3) TagType selection UI (like your Command menu) + function renderTagTypeSelector(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {field.value + ? tagTypeDefinitions.find((d) => d.id === field.value)?.label + : "Select Tag Type..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search Tag Type..." /> + <CommandList> + <CommandEmpty>No tag type found.</CommandEmpty> + <CommandGroup> + {tagTypeDefinitions.map((def,index) => ( + <CommandItem + key={index} + onSelect={() => { + field.onChange(def.id) // store the 'id' + setPopoverOpen(false) + }} + value={def.id} + > + {def.label} + <Check + className={cn( + "ml-auto h-4 w-4", + field.value === def.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // 4) Render sub-fields based on currentTagTypeDef + // Updated to handle different field types (text, select) + function renderSubFields() { + if (!currentTagTypeDef) return null + + return currentTagTypeDef.subFields.map((subField, index) => ( + + <FormField + key={`${subField.name}-${index}`} + control={form.control} + name={subField.name as keyof CreateTagSchema} + render={({ field }) => ( + <FormItem> + <FormLabel>{subField.label}</FormLabel> + <FormControl> + {subField.type === "select" && subField.options ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder={subField.placeholder || "Select an option"} /> + </SelectTrigger> + <SelectContent> + {subField.options.map((option, index) => ( + <SelectItem key={index} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + placeholder={subField.placeholder || ""} + value={field.value || ""} + onChange={field.onChange} + onBlur={field.onBlur} + name={field.name} + ref={field.ref} + /> + )} + </FormControl> + {subField.formatHint && ( + <p className="text-sm text-muted-foreground mt-1"> + {subField.formatHint} + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + )) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Tag + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Add New Tag</DialogTitle> + <DialogDescription> + Select a Tag Type and fill in sub-fields. The Tag No will be generated automatically. + </DialogDescription> + </DialogHeader> + <ScrollArea className="flex-1"> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="space-y-4"> + {/* Tag Type - Outside ScrollArea as it's always visible */} + <FormField + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeSelector(field)} + /> + </div> + + {/* ScrollArea for dynamic fields */} + <ScrollArea className="h-[50vh] pr-4"> + <div className="space-y-4"> + {/* sub-fields from the selected tagType */} + {renderSubFields()} + + {/* Tag No (auto-generated) */} + <FormField + control={form.control} + name="tagNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Tag No (auto-generated)</FormLabel> + <FormControl> + <Input + placeholder="Auto-generated..." + {...field} + readOnly + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description (optional) */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description </FormLabel> + <FormControl> + <Input + placeholder="Optional desc..." + value={field.value ?? ""} + onChange={(e) => field.onChange(e.target.value)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </ScrollArea> + </form> + </Form> + </ScrollArea> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset(); + setOpen(false); + }} + disabled={isPending} + > + Cancel + </Button> + <Button type="submit" disabled={isPending || !isTagNoValid}> + {isPending && ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + )} + Create + </Button> + </DialogFooter> + + + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/tag-table/tag-table-column.tsx b/components/vendor-data/tag-table/tag-table-column.tsx new file mode 100644 index 00000000..a22611cf --- /dev/null +++ b/components/vendor-data/tag-table/tag-table-column.tsx @@ -0,0 +1,196 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import type { Row } from "@tanstack/react-table" +import { numericFilter } from "@/lib/data-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + } from "@/components/ui/dropdown-menu" +import { Ellipsis } from "lucide-react" +import { Tag } from "@/types/vendorData" + + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: 'open' | "update" | "delete" +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>> +} + +export function getColumns({ + setRowAction, + }: GetColumnsProps): ColumnDef<Tag>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + + { + accessorKey: "tagNo", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag No." /> + ), + cell: ({ row }) => <div className="w-20">{row.getValue("tagNo")}</div>, + meta: { + excelHeader: "Tag No" + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Description" /> + ), + cell: ({ row }) => <div className="w-120">{row.getValue("description")}</div>, + meta: { + excelHeader: "Tag Descripiton" + }, + }, + { + accessorKey: "tagType", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Type" /> + ), + cell: ({ row }) => <div className="w-40">{row.getValue("tagType")}</div>, + meta: { + excelHeader: "Tag Type" + }, + }, + { + id: "validation", + header: "Error", + cell: ({ row }) => <div className="w-100"></div>, + }, + + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + }, + + { + id: "actions", + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + {/* <DropdownMenuRadioGroup + value={row.original.label} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + updateTask({ + id: row.original.id, + label: value as Task["label"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {tasks.label.enumValues.map((label) => ( + <DropdownMenuRadioItem + key={label} + value={label} + className="capitalize" + disabled={isUpdatePending} + > + {label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> */} + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + }, + ] + } +
\ No newline at end of file diff --git a/components/vendor-data/tag-table/tag-table.tsx b/components/vendor-data/tag-table/tag-table.tsx new file mode 100644 index 00000000..a449529f --- /dev/null +++ b/components/vendor-data/tag-table/tag-table.tsx @@ -0,0 +1,39 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { DataTableRowAction, getColumns } from "./tag-table-column" +import { Tag as TagData } from "@/types/vendorData" +import { DataTableAdvancedFilterField } from "@/types/table" +import { AddTagDialog } from "./add-tag-dialog" + +interface TagTableProps { + data: TagData[] +} + +/** + * TagTable: Tag 데이터를 표시하는 표 + */ +export function TagTable({ data }: TagTableProps) { + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<TagData> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const advancedFilterFields: DataTableAdvancedFilterField<TagData>[] = [] + + return ( + <> + <ClientDataTable + data={data} + columns={columns} + advancedFilterFields={advancedFilterFields} + /> + + </> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/tag-table/tag-type-definitions.ts b/components/vendor-data/tag-table/tag-type-definitions.ts new file mode 100644 index 00000000..e5d04eab --- /dev/null +++ b/components/vendor-data/tag-table/tag-type-definitions.ts @@ -0,0 +1,87 @@ +import { CreateTagSchema } from "@/lib/tags/validations" + +/** + * Each "Tag Type" has: + * - id, label + * - subFields[]: + * -- name (form field name) + * -- label (UI label) + * -- placeholder? + * -- type: "select" | "text" + * -- options?: { value: string; label: string; }[] (for dropdown) + * -- optional "regex" or "formatHint" for display + * - generateTagNo: function + */ +export const tagTypeDefinitions = [ + { + id: "EquipmentNumbering", + label: "Equipment Numbering", + subFields: [ + { + name: "functionCode", + label: "Function", + placeholder: "", + type: "select", + // Example options: + options: [ + { value: "PM", label: "Pump" }, + { value: "AA", label: "Pneumatic Motor" }, + ], + // or if you want a regex or format hint: + formatHint: "2 letters, e.g. PM", + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const fc = values.functionCode || "??" + const seq = values.seqNumber || "000" + return `${fc}-${seq}` + }, + }, + { + id: "Valve", + label: "Valve", + subFields: [ + { + name: "valveAcronym", + label: "Valve Acronym", + placeholder: "", + type: "select", + options: [ + { value: "VB", label: "Ball Valve" }, + { value: "VAR", label: "Auto Recirculation Valve" }, + ], + }, + { + name: "processUnit", + label: "Process Unit (2 digits)", + placeholder: "01", + type: "select", + options: [ + { value: "01", label: "Firewater System" }, + { value: "02", label: "Liquefaction Unit" }, + ], + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const va = values.valveAcronym || "??" + const pu = values.processUnit || "??" + const seq= values.seqNumber || "000" + return `${va}-${pu}${seq}` + }, + }, + // ... more types from your API ... +]
\ No newline at end of file |
