diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:36:14 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:36:14 +0000 |
| commit | 92eda21e45d902663052575aaa4c4f80bfa2faea (patch) | |
| tree | 8483702edf82932d4359a597a854fa8e1b48e94b /components/form-data | |
| parent | f0213de0d2fb5fcb931b3ddaddcbb6581cab5d28 (diff) | |
(대표님) 벤더 문서 변경사항, data-table 변경, sync 변경
Diffstat (limited to 'components/form-data')
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 95 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 310 |
2 files changed, 224 insertions, 181 deletions
diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index 930e113b..2a065d1b 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -103,53 +103,59 @@ function getHeaderText(col: DataTableColumnJSON): string { } /** - * 컬럼들을 head 값에 따라 그룹핑하는 헬퍼 함수 + * 컬럼들을 seq 순서대로 배치하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶는 함수 */ function groupColumnsByHead(columns: DataTableColumnJSON[]): ColumnDef<any>[] { - const groupedColumns: ColumnDef<any>[] = []; - const groupMap = new Map<string, DataTableColumnJSON[]>(); - const ungroupedColumns: DataTableColumnJSON[] = []; + const result: ColumnDef<any>[] = []; + let i = 0; - // head 값에 따라 컬럼들을 그룹핑 - columns.forEach(col => { - if (col.head && col.head.trim()) { - const groupKey = col.head.trim(); - if (!groupMap.has(groupKey)) { - groupMap.set(groupKey, []); - } - groupMap.get(groupKey)!.push(col); - } else { - ungroupedColumns.push(col); + while (i < columns.length) { + const currentCol = columns[i]; + + // head가 없거나 빈 문자열인 경우 일반 컬럼으로 처리 + if (!currentCol.head || !currentCol.head.trim()) { + result.push(createColumnDef(currentCol, false)); + i++; + continue; + } + + // 같은 head를 가진 연속된 컬럼들을 찾기 + const groupHead = currentCol.head.trim(); + const groupColumns: DataTableColumnJSON[] = [currentCol]; + let j = i + 1; + + while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) { + groupColumns.push(columns[j]); + j++; } - }); - // 그룹핑된 컬럼들 처리 - groupMap.forEach((groupColumns, groupHeader) => { + // 그룹에 컬럼이 하나만 있으면 일반 컬럼으로 처리 if (groupColumns.length === 1) { - // 그룹에 컬럼이 하나만 있으면 일반 컬럼으로 처리 - ungroupedColumns.push(groupColumns[0]); + result.push(createColumnDef(currentCol, false)); } else { - // 그룹 컬럼 생성 + // 그룹 컬럼 생성 (구분선 스타일 적용) const groupColumn: ColumnDef<any> = { - header: groupHeader, - columns: groupColumns.map(col => createColumnDef(col)) + id: `group-${groupHead.replace(/\s+/g, '-')}`, + header: groupHead, + columns: groupColumns.map(col => createColumnDef(col, true)), + meta: { + isGroupColumn: true, + groupBorders: true, // 그룹 구분선 표시 플래그 + } }; - groupedColumns.push(groupColumn); + result.push(groupColumn); } - }); - // 그룹핑되지 않은 컬럼들 처리 - ungroupedColumns.forEach(col => { - groupedColumns.push(createColumnDef(col)); - }); + i = j; // 다음 그룹으로 이동 + } - return groupedColumns; + return result; } /** * 개별 컬럼 정의를 생성하는 헬퍼 함수 */ -function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { +function createColumnDef(col: DataTableColumnJSON, isInGroup: boolean = false): ColumnDef<any> { return { accessorKey: col.key, header: ({ column }) => ( @@ -165,6 +171,8 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { paddingFactor: 1.2, maxWidth: col.key === "TAG_NO" ? 120 : 150, isReadOnly: col.shi === true, + isInGroup, // 그룹 내 컬럼인지 표시 + groupBorders: isInGroup, // 그룹 구분선 표시 플래그 }, cell: ({ row }) => { @@ -173,10 +181,19 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { // SHI 필드만 읽기 전용으로 처리 const isReadOnly = col.shi === true; + // 그룹 구분선 스타일 클래스 추가 + const groupBorderClass = isInGroup ? "group-column-border" : ""; const readOnlyClass = isReadOnly ? "read-only-cell" : ""; - const cellStyle = isReadOnly - ? { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' } - : {}; + const combinedClass = [groupBorderClass, readOnlyClass].filter(Boolean).join(" "); + + const cellStyle = { + ...(isReadOnly && { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' }), + ...(isInGroup && { + borderLeft: '2px solid #e2e8f0', + borderRight: '2px solid #e2e8f0', + position: 'relative' as const + }) + }; // 툴팁 메시지 설정 (SHI 필드만) const tooltipMessage = isReadOnly ? "SHI 전용 필드입니다" : ""; @@ -188,7 +205,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { return ( <div - className={readOnlyClass} + className={combinedClass} style={cellStyle} title={tooltipMessage} > @@ -204,7 +221,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { case "NUMBER": return ( <div - className={readOnlyClass} + className={combinedClass} style={cellStyle} title={tooltipMessage} > @@ -215,7 +232,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { case "LIST": return ( <div - className={readOnlyClass} + className={combinedClass} style={cellStyle} title={tooltipMessage} > @@ -227,7 +244,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { default: return ( <div - className={readOnlyClass} + className={combinedClass} style={cellStyle} title={tooltipMessage} > @@ -243,7 +260,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> { * getColumns 함수 * 1) columnsJSON 배열을 필터링 (hidden이 true가 아닌 것들만) * 2) seq에 따라 정렬 - * 3) head 값에 따라 컬럼 그룹핑 + * 3) seq 순서를 유지하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶기 * 4) 체크박스 컬럼 추가 * 5) 마지막에 "Action" 칼럼 추가 */ @@ -318,7 +335,7 @@ export function getColumns<TData extends object>({ }; columns.push(selectColumn); - // (2) 기본 컬럼들 (head에 따라 그룹핑 처리) + // (2) 기본 컬럼들 (seq 순서를 유지하면서 head에 따라 그룹핑 처리) const groupedColumns = groupColumnsByHead(visibleColumns); columns.push(...groupedColumns); diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index abc9bbf3..5666a116 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -32,7 +32,7 @@ import { Popover, PopoverTrigger, PopoverContent, -} from "@/components/ui/popover" +} from "@/components/ui/popover"; import { Command, CommandInput, @@ -40,14 +40,24 @@ import { CommandGroup, CommandItem, CommandEmpty, -} from "@/components/ui/command" +} from "@/components/ui/command"; import { DataTableColumnJSON } from "./form-data-table-columns"; import { updateFormDataInDB } from "@/lib/forms/services"; import { cn } from "@/lib/utils"; -interface UpdateTagSheetProps - extends React.ComponentPropsWithoutRef<typeof Sheet> { +/** ============================================================= + * 🔄 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[]; @@ -66,74 +76,57 @@ export function UpdateTagSheet({ rowData, formCode, contractItemId, - editableFieldsMap = new Map(), // 기본값 설정 + editableFieldsMap = new Map(), onUpdateSuccess, ...props }: UpdateTagSheetProps) { + // ─────────────────────────────────────────────────────────────── + // hooks & helpers + // ─────────────────────────────────────────────────────────────── const [isPending, startTransition] = React.useTransition(); const router = useRouter(); - // 현재 TAG의 편집 가능한 필드 목록 가져오기 + /* ---------------------------------------------------------------- + * 1️⃣ Editable‑field helpers (unchanged) + * --------------------------------------------------------------*/ const editableFields = React.useMemo(() => { if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { - return []; + return [] as string[]; } return editableFieldsMap.get(rowData.TAG_NO) || []; }, [rowData?.TAG_NO, editableFieldsMap]); - // 필드가 편집 가능한지 판별하는 함수 const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { - // 1. SHI-only 필드는 편집 불가 - if (column.shi === true) { - return false; - } - - // 2. TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (필요에 따라 수정 가능) - if (column.key === "TAG_NO" || column.key === "TAG_DESC") { - return true; - } + if (column.shi === true) return false; // SHI‑only + if (column.key === "TAG_NO" || column.key === "TAG_DESC") return true; + return editableFields.includes(column.key); + }, [editableFields]); - //3. editableFieldsMap이 있으면 해당 리스트에 있는지 확인 - // if (rowData?.TAG_NO && editableFieldsMap.has(rowData.TAG_NO)) { - // return editableFields.includes(column.key); - // } + const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]); - // 4. editableFieldsMap 정보가 없으면 기본적으로 편집 불가 (안전한 기본값) - return true; - }, []); - - // 읽기 전용 필드인지 판별하는 함수 (편집 가능의 반대) - const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => { - return !isFieldEditable(column); - }, [isFieldEditable]); - - // 읽기 전용 사유를 반환하는 함수 const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { - if (column.shi === true) { - return "SHI-only field (managed by SHI system)"; - } - + if (column.shi) return "SHI‑only field (managed by SHI system)"; if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { return "No editable fields information for this TAG"; } - if (!editableFields.includes(column.key)) { return "Not editable for this TAG class"; } } - - return "Read-only field"; + return "Read‑only field"; }, [rowData?.TAG_NO, editableFieldsMap, editableFields]); - // 1) zod 스키마 + /* ---------------------------------------------------------------- + * 2️⃣ Zod dynamic schema & form state (unchanged) + * --------------------------------------------------------------*/ const dynamicSchema = React.useMemo(() => { - const shape: Record<string, z.ZodType<any>> = {}; + 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) ? undefined : val)) + .transform((val) => (isNaN(val as number) ? undefined : val)) .optional(); } else { shape[col.key] = z.string().optional(); @@ -142,16 +135,14 @@ export function UpdateTagSheet({ return z.object(shape); }, [columns]); - // 2) form init const form = useForm({ resolver: zodResolver(dynamicSchema), defaultValues: React.useMemo(() => { if (!rowData) return {}; - const defaults: Record<string, any> = {}; - for (const col of columns) { - defaults[col.key] = rowData[col.key] ?? ""; - } - return defaults; + return columns.reduce<Record<string, any>>((acc, col) => { + acc[col.key] = rowData[col.key] ?? ""; + return acc; + }, {}); }, [rowData, columns]), }); @@ -161,28 +152,67 @@ export function UpdateTagSheet({ return; } const defaults: Record<string, any> = {}; - for (const col of columns) { + 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 { - // 제출 전에 읽기 전용 필드를 원본 값으로 복원 - const finalValues = { ...values }; - for (const col of columns) { + // 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 + finalValues, ); if (!success) { @@ -190,25 +220,12 @@ export function UpdateTagSheet({ return; } - // Success handling toast.success("Updated successfully!"); - // Create a merged object of original rowData and new values - const updatedData = { - ...rowData, - ...finalValues, - TAG_NO: rowData?.TAG_NO, - }; - - // Call the success callback + const updatedData = { ...rowData, ...finalValues, TAG_NO: rowData?.TAG_NO }; onUpdateSuccess?.(updatedData); - - // Refresh the entire route to get fresh data router.refresh(); - - // Close the sheet onOpenChange(false); - } catch (error) { console.error("Error updating form data:", error); toast.error("An unexpected error occurred while updating"); @@ -216,44 +233,57 @@ export function UpdateTagSheet({ }); } - // 편집 가능한 필드 개수 계산 - const editableFieldCount = React.useMemo(() => { - return columns.filter(col => isFieldEditable(col)).length; - }, [columns, isFieldEditable]); + /* ---------------------------------------------------------------- + * 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>Update Row - {rowData?.TAG_NO || 'Unknown TAG'}</SheetTitle> + <SheetTitle> + Update Row – {rowData?.TAG_NO || "Unknown TAG"} + </SheetTitle> <SheetDescription> - Modify the fields below and save changes. Fields with <LockIcon className="inline h-3 w-3" /> are read-only. + Modify the fields below and save changes. Fields with + <LockIcon className="inline h-3 w-3 mx-1" /> are read‑only. <br /> <span className="text-sm text-green-600"> - {editableFieldCount} of {columns.length} fields are editable for this TAG. + {editableFieldCount} of {columns.length} fields are editable for + this TAG. </span> </SheetDescription> </SheetHeader> + {/* ────────────────────────────────────────────── */} <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4" - > + <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"> - <div className="flex flex-col gap-4 pt-2"> - {columns.map((col) => { - const isReadOnly = isFieldReadOnly(col); - const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : ""; - - return ( - <FormField - key={col.key} - control={form.control} - name={col.key} - render={({ field }) => { - switch (col.type) { - case "NUMBER": + {/* ------------------------------------------------------------------ + * 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"> @@ -272,7 +302,8 @@ export function UpdateTagSheet({ }} value={field.value ?? ""} className={cn( - isReadOnly && "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300" + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", )} /> </FormControl> @@ -284,8 +315,10 @@ export function UpdateTagSheet({ <FormMessage /> </FormItem> ); + } - case "LIST": + // ——————————————— List ———————————————— + if (col.type === "LIST") { return ( <FormItem> <FormLabel className="flex items-center"> @@ -303,12 +336,11 @@ export function UpdateTagSheet({ className={cn( "w-full justify-between", !field.value && "text-muted-foreground", - isReadOnly && "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300" + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", )} > - {field.value - ? col.options?.find((opt) => opt === field.value) - : "Select an option"} + {field.value ? col.options?.find((o) => o === field.value) : "Select an option"} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> @@ -319,17 +351,11 @@ export function UpdateTagSheet({ <CommandList> <CommandGroup> {col.options?.map((opt) => ( - <CommandItem - key={opt} - value={opt} - onSelect={() => { - field.onChange(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" + field.value === opt ? "opacity-100" : "opacity-0", )} /> {opt} @@ -348,52 +374,52 @@ export function UpdateTagSheet({ <FormMessage /> </FormItem> ); + } - case "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> + // ——————————————— String / default ———————————— + return ( + <FormItem> + <FormLabel className="flex items-center"> + {col.label} {isReadOnly && ( - <FormDescription className="text-xs text-gray-500"> - {readOnlyReason} - </FormDescription> + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> )} - <FormMessage /> - </FormItem> - ); - } - }} - /> - ); - })} - </div> + </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"> Cancel </Button> </SheetClose> - <Button type="submit" disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - Save + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}Save </Button> </SheetFooter> </form> @@ -401,4 +427,4 @@ export function UpdateTagSheet({ </SheetContent> </Sheet> ); -}
\ No newline at end of file +} |
