diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-23 10:10:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-23 10:10:21 +0000 |
| commit | f7f5069a2209cfa39b65f492f32270a5f554bed0 (patch) | |
| tree | 933c731ec2cb7d8bc62219a0aeed45a5e97d5f15 /components/form-data-plant/update-form-sheet.tsx | |
| parent | d49ad5dee1e5a504e1321f6db802b647497ee9ff (diff) | |
(대표님) EDP 해양 관련 개발 사항들
Diffstat (limited to 'components/form-data-plant/update-form-sheet.tsx')
| -rw-r--r-- | components/form-data-plant/update-form-sheet.tsx | 445 |
1 files changed, 445 insertions, 0 deletions
diff --git a/components/form-data-plant/update-form-sheet.tsx b/components/form-data-plant/update-form-sheet.tsx new file mode 100644 index 00000000..bd75d8f3 --- /dev/null +++ b/components/form-data-plant/update-form-sheet.tsx @@ -0,0 +1,445 @@ +"use client"; + +import * as React from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Check, ChevronsUpDown, Loader, LockIcon } from "lucide-react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command"; + +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataInDB } from "@/lib/forms-plant/services"; +import { cn } from "@/lib/utils"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +/** ============================================================= + * 🔄 UpdateTagSheet with grouped fields by `head` property + * ----------------------------------------------------------- + * - Consecutive columns that share the same `head` value will be + * rendered under a section title (the head itself). + * - Columns without a head still appear normally. + * + * NOTE: Only rendering logic is touched – all validation, + * read‑only checks, and mutation logic stay the same. + * ============================================================*/ + +interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { + open: boolean; + onOpenChange: (open: boolean) => void; + columns: DataTableColumnJSON[]; + rowData: Record<string, any> | null; + formCode: string; + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 + /** 업데이트 성공 시 호출될 콜백 */ + onUpdateSuccess?: (updatedValues: Record<string, any>) => void; +} + +export function UpdateTagSheet({ + open, + onOpenChange, + columns, + rowData, + formCode, + contractItemId, + editableFieldsMap = new Map(), + onUpdateSuccess, + ...props +}: UpdateTagSheetProps) { + // ─────────────────────────────────────────────────────────────── + // hooks & helpers + // ─────────────────────────────────────────────────────────────── + const [isPending, startTransition] = React.useTransition(); + const router = useRouter(); + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + /* ---------------------------------------------------------------- + * 1️⃣ Editable‑field helpers (unchanged) + * --------------------------------------------------------------*/ + const editableFields = React.useMemo(() => { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return [] as string[]; + } + return editableFieldsMap.get(rowData.TAG_NO) || []; + }, [rowData?.TAG_NO, editableFieldsMap]); + + const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { + if (column.shi === "OUT" || column.shi === null) return false; // SHI‑only + if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false; + if (column.key === "status") return false; + return editableFields.includes(column.key); + // return true + }, [editableFields]); + + const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]); + + const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { + if (column.shi === "OUT" || column.shi === null) return t("updateTagSheet.readOnlyReasons.shiOnly"); + if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return t("updateTagSheet.readOnlyReasons.noEditableFields"); + } + if (!editableFields.includes(column.key)) { + return t("updateTagSheet.readOnlyReasons.notEditableForTag"); + } + } + return t("updateTagSheet.readOnlyReasons.readOnly"); + }, [rowData?.TAG_NO, editableFieldsMap, editableFields, t]); + + /* ---------------------------------------------------------------- + * 2️⃣ Zod dynamic schema & form state (unchanged) + * --------------------------------------------------------------*/ + const dynamicSchema = React.useMemo(() => { + const shape: Record<string, z.ZodTypeAny> = {}; + for (const col of columns) { + if (col.type === "NUMBER") { + shape[col.key] = z + .union([z.coerce.number(), z.nan()]) + .transform((val) => (isNaN(val as number) ? undefined : val)) + .optional(); + } else { + shape[col.key] = z.string().optional(); + } + } + return z.object(shape); + }, [columns]); + + const form = useForm({ + resolver: zodResolver(dynamicSchema), + defaultValues: React.useMemo(() => { + if (!rowData) return {}; + return columns.reduce<Record<string, any>>((acc, col) => { + acc[col.key] = rowData[col.key] ?? ""; + return acc; + }, {}); + }, [rowData, columns]), + }); + + React.useEffect(() => { + if (!rowData) { + form.reset({}); + return; + } + const defaults: Record<string, any> = {}; + columns.forEach((col) => { + defaults[col.key] = rowData[col.key] ?? ""; + }); + form.reset(defaults); + }, [rowData, columns, form]); + + /* ---------------------------------------------------------------- + * 3️⃣ Grouping logic – figure out consecutive columns that share + * the same `head` value. This mirrors `groupColumnsByHead` that + * you already use for the table view. + * --------------------------------------------------------------*/ + const groupedColumns = React.useMemo(() => { + // Ensure original ordering by `seq` where present + const sorted = [...columns].sort((a, b) => { + const seqA = a.seq ?? 999999; + const seqB = b.seq ?? 999999; + return seqA - seqB; + }); + + const groups: { head: string | null; cols: DataTableColumnJSON[] }[] = []; + let i = 0; + while (i < sorted.length) { + const curr = sorted[i]; + const head = curr.head?.trim() || null; + if (!head) { + groups.push({ head: null, cols: [curr] }); + i += 1; + continue; + } + + // Collect consecutive columns with the same head + const cols: DataTableColumnJSON[] = [curr]; + let j = i + 1; + while (j < sorted.length && sorted[j].head?.trim() === head) { + cols.push(sorted[j]); + j += 1; + } + groups.push({ head, cols }); + i = j; + } + return groups; + }, [columns]); + + /* ---------------------------------------------------------------- + * 4️⃣ Submission handler (unchanged) + * --------------------------------------------------------------*/ + async function onSubmit(values: Record<string, any>) { + startTransition(async () => { + try { + // Restore read‑only fields to their original value before saving + const finalValues: Record<string, any> = { ...values }; + columns.forEach((col) => { + if (isFieldReadOnly(col)) { + finalValues[col.key] = rowData?.[col.key] ?? ""; + } + }); + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + finalValues, + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success(t("updateTagSheet.messages.updateSuccess")); + + const updatedData = { ...rowData, ...finalValues, TAG_NO: rowData?.TAG_NO }; + onUpdateSuccess?.(updatedData); + router.refresh(); + onOpenChange(false); + } catch (error) { + console.error("Error updating form data:", error); + toast.error(t("updateTagSheet.messages.updateError")); + } + }); + } + + /* ---------------------------------------------------------------- + * 5️⃣ UI + * --------------------------------------------------------------*/ + const editableFieldCount = React.useMemo(() => columns.filter(isFieldEditable).length, [columns, isFieldEditable]); + + return ( + <Sheet open={open} onOpenChange={onOpenChange} {...props}> + <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col"> + <SheetHeader className="text-left"> + <SheetTitle> + {t("updateTagSheet.title")} – {rowData?.TAG_NO || t("updateTagSheet.unknownTag")} + </SheetTitle> + <SheetDescription> + {t("updateTagSheet.description")} + <LockIcon className="inline h-3 w-3 mx-1" /> + {t("updateTagSheet.readOnlyIndicator")} + <br /> + <span className="text-sm text-green-600"> + {t("updateTagSheet.editableFieldsCount", { + editableCount: editableFieldCount, + totalCount: columns.length + })} + </span> + </SheetDescription> + </SheetHeader> + + {/* ────────────────────────────────────────────── */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* Scroll wrapper */} + <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4"> + {/* ------------------------------------------------------------------ + * Render groups + * ----------------------------------------------------------------*/} + {groupedColumns.map(({ head, cols }) => ( + <div key={head ?? cols[0].key} className="flex flex-col gap-4 pt-2"> + {head && ( + <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide pl-1"> + {head} + </h3> + )} + + {/* Fields inside the group */} + {cols.map((col) => { + const isReadOnly = isFieldReadOnly(col); + const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : ""; + return ( + <FormField + key={col.key} + control={form.control} + name={col.key} + render={({ field }) => { + // ——————————————— Number ———————————————— + if (col.type === "NUMBER") { + return ( + <FormItem> + <FormLabel className="flex items-center"> + {col.displayLabel || col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> + <FormControl> + <Input + type="number" + readOnly={isReadOnly} + onChange={(e) => { + const num = parseFloat(e.target.value); + field.onChange(isNaN(num) ? "" : num); + }} + value={field.value ?? ""} + className={cn( + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + )} + /> + </FormControl> + {isReadOnly && ( + <FormDescription className="text-xs text-gray-500"> + {readOnlyReason} + </FormDescription> + )} + <FormMessage /> + </FormItem> + ); + } + + // ——————————————— List ———————————————— + if (col.type === "LIST") { + return ( + <FormItem> + <FormLabel className="flex items-center"> + {col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + disabled={isReadOnly} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground", + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + )} + > + {field.value ? + col.options?.find((o) => o === field.value) : + t("updateTagSheet.selectOption") + } + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder={t("updateTagSheet.searchOptions")} /> + <CommandEmpty>{t("updateTagSheet.noOptionFound")}</CommandEmpty> + <CommandList> + <CommandGroup> + {col.options?.map((opt) => ( + <CommandItem key={opt} value={opt} onSelect={() => field.onChange(opt)}> + <Check + className={cn( + "mr-2 h-4 w-4", + field.value === opt ? "opacity-100" : "opacity-0", + )} + /> + {opt} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + {isReadOnly && ( + <FormDescription className="text-xs text-gray-500"> + {readOnlyReason} + </FormDescription> + )} + <FormMessage /> + </FormItem> + ); + } + + // ——————————————— String / default ———————————— + return ( + <FormItem> + <FormLabel className="flex items-center"> + {col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> + <FormControl> + <Input + readOnly={isReadOnly} + {...field} + className={cn( + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + )} + /> + </FormControl> + {isReadOnly && ( + <FormDescription className="text-xs text-gray-500"> + {readOnlyReason} + </FormDescription> + )} + <FormMessage /> + </FormItem> + ); + }} + /> + ); + })} + </div> + ))} + </div> + + {/* Footer */} + <SheetFooter className="gap-2 pt-2"> + <SheetClose asChild> + <Button type="button" variant="outline"> + {t("buttons.cancel")} + </Button> + </SheetClose> + <Button type="submit" disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {t("buttons.save")} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file |
