From 20167ad35943b59dc028991e3264209b05730220 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 16:07:15 +0900 Subject: [PATCH 01/23] feat: Implement advanced filtering capabilities in entity search - Added a new helper function `applyFilters` to handle dynamic filter conditions for entity search queries. - Enhanced the `getDistinctColumnValues` and `getEntityOptions` endpoints to support JSON array filters, allowing for more flexible data retrieval based on specified conditions. - Updated the frontend components to integrate filter conditions, improving user interaction and data management in selection components. - Introduced new filter options in the V2Select component, enabling users to define and apply various filter criteria dynamically. --- .../src/controllers/entitySearchController.ts | 128 +++++++- frontend/components/v2/V2Select.tsx | 82 ++++- .../v2/config-panels/V2SelectConfigPanel.tsx | 293 +++++++++++++++++- .../modal-repeater-table/RepeaterTable.tsx | 77 ++++- .../v2-table-list/TableListComponent.tsx | 6 - .../v2-table-list/TableListConfigPanel.tsx | 33 +- frontend/next.config.mjs | 6 +- frontend/package-lock.json | 42 +-- frontend/types/v2-components.ts | 20 +- scripts/dev/start-npm.sh | 66 ++++ 10 files changed, 689 insertions(+), 64 deletions(-) create mode 100755 scripts/dev/start-npm.sh diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 3ece2ce7..62fc8bbe 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +/** + * 필터 조건을 WHERE절에 적용하는 공통 헬퍼 + * filters JSON 배열: [{ column, operator, value }] + */ +function applyFilters( + filtersJson: string | undefined, + existingColumns: Set, + whereConditions: string[], + params: any[], + startParamIndex: number, + tableName: string, +): number { + let paramIndex = startParamIndex; + + if (!filtersJson) return paramIndex; + + let filters: Array<{ column: string; operator: string; value: unknown }>; + try { + filters = JSON.parse(filtersJson as string); + } catch { + logger.warn("filters JSON 파싱 실패", { tableName, filtersJson }); + return paramIndex; + } + + if (!Array.isArray(filters)) return paramIndex; + + for (const filter of filters) { + const { column, operator = "=", value } = filter; + if (!column || !existingColumns.has(column)) { + logger.warn("필터 컬럼 미존재 제외", { tableName, column }); + continue; + } + + switch (operator) { + case "=": + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${column}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + case "<": + case ">=": + case "<=": + whereConditions.push(`"${column}" ${operator} $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": { + const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inVals.length > 0) { + const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" IN (${ph})`); + params.push(...inVals); + paramIndex += inVals.length; + } + break; + } + case "notIn": { + const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInVals.length > 0) { + const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" NOT IN (${ph})`); + params.push(...notInVals); + paramIndex += notInVals.length; + } + break; + } + case "like": + whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + case "isNull": + whereConditions.push(`"${column}" IS NULL`); + break; + case "isNotNull": + whereConditions.push(`"${column}" IS NOT NULL`); + break; + default: + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + } + } + + return paramIndex; +} + /** * 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용) * GET /api/entity/:tableName/distinct/:columnName * * 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환 + * + * Query Params: + * - labelColumn: 별도의 라벨 컬럼 (선택) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) { try { const { tableName, columnName } = req.params; - const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼 + const { labelColumn, filters: filtersParam } = req.query; // 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re whereConditions.push(`"${columnName}" IS NOT NULL`); whereConditions.push(`"${columnName}" != ''`); + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re columnName, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, }); @@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re * Query Params: * - value: 값 컬럼 (기본: id) * - label: 표시 컬럼 (기본: name) + * - fields: 추가 반환 컬럼 (콤마 구분) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name", fields } = req.query; + const { value = "id", label = "name", fields, filters: filtersParam } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) paramIndex++; } + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) valueColumn, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, extraFields: extraColumns ? true : false, }); diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index fe21b790..f0021eeb 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { V2SelectProps, SelectOption } from "@/types/v2-components"; +import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components"; import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import V2FormContext from "./V2FormContext"; @@ -655,6 +655,7 @@ export const V2Select = forwardRef( const labelColumn = config.labelColumn; const apiEndpoint = config.apiEndpoint; const staticOptions = config.options; + const configFilters = config.filters; // 계층 코드 연쇄 선택 관련 const hierarchical = config.hierarchical; @@ -662,6 +663,54 @@ export const V2Select = forwardRef( // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) const formContext = useContext(V2FormContext); + + /** + * 필터 조건을 API 전달용 JSON으로 변환 + * field/user 타입은 런타임 값으로 치환 + */ + const resolvedFiltersJson = useMemo(() => { + if (!configFilters || configFilters.length === 0) return undefined; + + const resolved: Array<{ column: string; operator: string; value: unknown }> = []; + + for (const f of configFilters) { + const vt = f.valueType || "static"; + + // isNull/isNotNull은 값 불필요 + if (f.operator === "isNull" || f.operator === "isNotNull") { + resolved.push({ column: f.column, operator: f.operator, value: null }); + continue; + } + + let resolvedValue: unknown = f.value; + + if (vt === "field" && f.fieldRef) { + // 다른 폼 필드 참조 + if (formContext) { + resolvedValue = formContext.getValue(f.fieldRef); + } else { + const fd = (props as any).formData; + resolvedValue = fd?.[f.fieldRef]; + } + // 참조 필드 값이 비어있으면 이 필터 건너뜀 + if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue; + } else if (vt === "user" && f.userField) { + // 로그인 사용자 정보 참조 (props에서 가져옴) + const userMap: Record = { + companyCode: (props as any).companyCode, + userId: (props as any).userId, + deptCode: (props as any).deptCode, + userName: (props as any).userName, + }; + resolvedValue = userMap[f.userField]; + if (!resolvedValue) continue; + } + + resolved.push({ column: f.column, operator: f.operator, value: resolvedValue }); + } + + return resolved.length > 0 ? JSON.stringify(resolved) : undefined; + }, [configFilters, formContext, props]); // 부모 필드의 값 계산 const parentValue = useMemo(() => { @@ -684,6 +733,13 @@ export const V2Select = forwardRef( } }, [parentValue, hierarchical, source]); + // 필터 조건이 변경되면 옵션 다시 로드 + useEffect(() => { + if (resolvedFiltersJson !== undefined) { + setOptionsLoaded(false); + } + }, [resolvedFiltersJson]); + useEffect(() => { // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) if (optionsLoaded && source !== "static") { @@ -731,11 +787,13 @@ export const V2Select = forwardRef( } } else if (source === "db" && table) { // DB 테이블에서 로드 + const dbParams: Record = { + value: valueColumn || "id", + label: labelColumn || "name", + }; + if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${table}/options`, { - params: { - value: valueColumn || "id", - label: labelColumn || "name", - }, + params: dbParams, }); const data = response.data; if (data.success && data.data) { @@ -745,8 +803,10 @@ export const V2Select = forwardRef( // 엔티티(참조 테이블)에서 로드 const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; + const entityParams: Record = { value: valueCol, label: labelCol }; + if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { value: valueCol, label: labelCol }, + params: entityParams, }); const data = response.data; if (data.success && data.data) { @@ -790,11 +850,13 @@ export const V2Select = forwardRef( } } else if (source === "select" || source === "distinct") { // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 - // tableName, columnName은 props에서 가져옴 - // 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀 const isValidColumnName = columnName && !columnName.startsWith("comp_"); if (tableName && isValidColumnName) { - const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); + const distinctParams: Record = {}; + if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson; + const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, { + params: distinctParams, + }); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ @@ -818,7 +880,7 @@ export const V2Select = forwardRef( }; loadOptions(); - }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]); // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 const autoFillTargets = useMemo(() => { diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index d631f454..66ebb369 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -5,15 +5,16 @@ * 통합 선택 컴포넌트의 세부 설정을 관리합니다. */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; -import { Plus, Trash2, Loader2 } from "lucide-react"; +import { Plus, Trash2, Loader2, Filter } from "lucide-react"; import { apiClient } from "@/lib/api/client"; +import type { V2SelectFilter } from "@/types/v2-components"; interface ColumnOption { columnName: string; @@ -25,6 +26,238 @@ interface CategoryValueOption { valueLabel: string; } +const OPERATOR_OPTIONS = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "다름 (!=)" }, + { value: ">", label: "초과 (>)" }, + { value: "<", label: "미만 (<)" }, + { value: ">=", label: "이상 (>=)" }, + { value: "<=", label: "이하 (<=)" }, + { value: "in", label: "포함 (IN)" }, + { value: "notIn", label: "미포함 (NOT IN)" }, + { value: "like", label: "유사 (LIKE)" }, + { value: "isNull", label: "NULL" }, + { value: "isNotNull", label: "NOT NULL" }, +] as const; + +const VALUE_TYPE_OPTIONS = [ + { value: "static", label: "고정값" }, + { value: "field", label: "폼 필드 참조" }, + { value: "user", label: "로그인 사용자" }, +] as const; + +const USER_FIELD_OPTIONS = [ + { value: "companyCode", label: "회사코드" }, + { value: "userId", label: "사용자ID" }, + { value: "deptCode", label: "부서코드" }, + { value: "userName", label: "사용자명" }, +] as const; + +/** + * 필터 조건 설정 서브 컴포넌트 + */ +const FilterConditionsSection: React.FC<{ + filters: V2SelectFilter[]; + columns: ColumnOption[]; + loadingColumns: boolean; + targetTable: string; + onFiltersChange: (filters: V2SelectFilter[]) => void; +}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => { + + const addFilter = () => { + onFiltersChange([ + ...filters, + { column: "", operator: "=", valueType: "static", value: "" }, + ]); + }; + + const updateFilter = (index: number, patch: Partial) => { + const updated = [...filters]; + updated[index] = { ...updated[index], ...patch }; + + // valueType 변경 시 관련 필드 초기화 + if (patch.valueType) { + if (patch.valueType === "static") { + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "field") { + updated[index].value = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "user") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + } + } + + // isNull/isNotNull 연산자는 값 불필요 + if (patch.operator === "isNull" || patch.operator === "isNotNull") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + updated[index].valueType = "static"; + } + + onFiltersChange(updated); + }; + + const removeFilter = (index: number) => { + onFiltersChange(filters.filter((_, i) => i !== index)); + }; + + const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull"; + + return ( +
+
+
+ + +
+ +
+ +

+ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건 +

+ + {loadingColumns && ( +
+ + 컬럼 목록 로딩 중... +
+ )} + + {filters.length === 0 && ( +

+ 필터 조건이 없습니다 +

+ )} + +
+ {filters.map((filter, index) => ( +
+ {/* 행 1: 컬럼 + 연산자 + 삭제 */} +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 삭제 버튼 */} + +
+ + {/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */} + {needsValue(filter.operator) && ( +
+ {/* 값 유형 */} + + + {/* 값 입력 영역 */} + {(filter.valueType || "static") === "static" && ( + updateFilter(index, { value: e.target.value })} + placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "field" && ( + updateFilter(index, { fieldRef: e.target.value })} + placeholder="참조할 필드명 (columnName)" + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "user" && ( + + )} +
+ )} +
+ ))} +
+
+ ); +}; + interface V2SelectConfigPanelProps { config: Record; onChange: (config: Record) => void; @@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC = ({ const [categoryValues, setCategoryValues] = useState([]); const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + // 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼) + const [filterColumns, setFilterColumns] = useState([]); + const [loadingFilterColumns, setLoadingFilterColumns] = useState(false); + const updateConfig = (field: string, value: any) => { onChange({ ...config, [field]: value }); }; + // 필터 대상 테이블 결정 + const filterTargetTable = useMemo(() => { + const src = config.source || "static"; + if (src === "entity") return config.entityTable; + if (src === "db") return config.table; + if (src === "distinct" || src === "select") return tableName; + return null; + }, [config.source, config.entityTable, config.table, tableName]); + + // 필터 대상 테이블의 컬럼 로드 + useEffect(() => { + if (!filterTargetTable) { + setFilterColumns([]); + return; + } + + const loadFilterColumns = async () => { + setLoadingFilterColumns(true); + try { + const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + setFilterColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, + })) + ); + } catch { + setFilterColumns([]); + } finally { + setLoadingFilterColumns(false); + } + }; + + loadFilterColumns(); + }, [filterTargetTable]); + // 카테고리 타입이면 source를 자동으로 category로 설정 useEffect(() => { if (isCategoryType && config.source !== "category") { @@ -518,6 +793,20 @@ export const V2SelectConfigPanel: React.FC = ({ /> )} + + {/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */} + {effectiveSource !== "static" && filterTargetTable && ( + <> + + updateConfig("filters", filters)} + /> + + )} ); }; diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 5ad6d0eb..d57ae60b 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -162,6 +162,79 @@ export function RepeaterTable({ // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) const initializedRef = useRef(false); + // 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용) + const editableColIndices = useMemo( + () => visibleColumns.reduce((acc, col, idx) => { + if (col.editable && !col.calculated) acc.push(idx); + return acc; + }, []), + [visibleColumns], + ); + + // 방향키로 리피터 셀 간 이동 + const handleArrowNavigation = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return; + + const target = e.target as HTMLElement; + const cell = target.closest("[data-repeater-row]") as HTMLElement | null; + if (!cell) return; + + const row = Number(cell.dataset.repeaterRow); + const col = Number(cell.dataset.repeaterCol); + if (isNaN(row) || isNaN(col)) return; + + // 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시 + if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") { + const input = target as HTMLInputElement; + const len = input.value?.length ?? 0; + const pos = input.selectionStart ?? 0; + // 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동 + if (key === "ArrowRight" && pos < len) return; + if (key === "ArrowLeft" && pos > 0) return; + } + + let nextRow = row; + let nextColPos = editableColIndices.indexOf(col); + + switch (key) { + case "ArrowUp": + nextRow = Math.max(0, row - 1); + break; + case "ArrowDown": + nextRow = Math.min(data.length - 1, row + 1); + break; + case "ArrowLeft": + nextColPos = Math.max(0, nextColPos - 1); + break; + case "ArrowRight": + nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1); + break; + } + + const nextCol = editableColIndices[nextColPos]; + if (nextRow === row && nextCol === col) return; + + e.preventDefault(); + + const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`; + const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null; + if (!nextCell) return; + + const focusable = nextCell.querySelector( + 'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])', + ); + if (focusable) { + focusable.focus(); + if (focusable.tagName === "INPUT") { + (focusable as HTMLInputElement).select(); + } + } + }, + [editableColIndices, data.length], + ); + // DnD 센서 설정 const sensors = useSensors( useSensor(PointerSensor, { @@ -648,7 +721,7 @@ export function RepeaterTable({ return ( -
+
{renderCell(row, col, rowIndex)} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 1eaef469..30584fc4 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -5777,12 +5777,6 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
- {/* 🆕 편집 불가 컬럼 표시 */} - {column.editable === false && ( - - - - )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index becd3c34..35f15596 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -1333,7 +1333,38 @@ export const TableListConfigPanel: React.FC = ({ /> {column.label || column.columnName} - + {isAdded && ( + + )} + {column.input_type || column.dataType}
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index f6f1907e..b7b134ec 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -23,8 +23,7 @@ const nextConfig = { // Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용 // 로컬 개발: http://127.0.0.1:8080 사용 async rewrites() { - // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080"; + const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080"; return [ { source: "/api/:path*", @@ -49,8 +48,7 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { - // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api", + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8080/api", }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8b262a1..01edd32d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -262,7 +262,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -304,7 +303,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -338,7 +336,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2669,7 +2666,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3323,7 +3319,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3391,7 +3386,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3705,7 +3699,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6206,7 +6199,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6217,7 +6209,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6260,7 +6251,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6343,7 +6333,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6976,7 +6965,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8127,8 +8115,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -8450,7 +8437,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9210,7 +9196,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9299,7 +9284,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9401,7 +9385,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10573,7 +10556,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11354,8 +11336,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/levn": { "version": "0.4.1", @@ -12684,7 +12665,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12978,7 +12958,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -13008,7 +12987,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -13057,7 +13035,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13184,7 +13161,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13254,7 +13230,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13305,7 +13280,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13338,8 +13312,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -13647,7 +13620,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13670,8 +13642,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -14701,8 +14672,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -14790,7 +14760,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15139,7 +15108,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index 88ac1691..6ce5a974 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -139,6 +139,23 @@ export interface SelectOption { label: string; } +/** + * V2Select 필터 조건 + * 옵션 데이터를 조회할 때 적용할 WHERE 조건 + */ +export interface V2SelectFilter { + column: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull"; + /** 값 유형: static=고정값, field=다른 폼 필드 참조, user=로그인 사용자 정보 */ + valueType?: "static" | "field" | "user"; + /** static일 때 고정값 */ + value?: unknown; + /** field일 때 참조할 폼 필드명 (columnName) */ + fieldRef?: string; + /** user일 때 참조할 사용자 필드 */ + userField?: "companyCode" | "userId" | "deptCode" | "userName"; +} + export interface V2SelectConfig { mode: V2SelectMode; source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드) @@ -151,7 +168,8 @@ export interface V2SelectConfig { table?: string; valueColumn?: string; labelColumn?: string; - filters?: Array<{ column: string; operator: string; value: unknown }>; + // 옵션 필터 조건 (모든 source에서 사용 가능) + filters?: V2SelectFilter[]; // 엔티티 연결 (source: entity) entityTable?: string; entityValueField?: string; diff --git a/scripts/dev/start-npm.sh b/scripts/dev/start-npm.sh new file mode 100755 index 00000000..7b7fc54a --- /dev/null +++ b/scripts/dev/start-npm.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +echo "============================================" +echo "WACE 솔루션 - npm 직접 실행 (Docker 없이)" +echo "============================================" +echo "" + +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LOG_DIR="$PROJECT_ROOT/scripts/dev/logs" +mkdir -p "$LOG_DIR" + +BACKEND_LOG="$LOG_DIR/backend.log" +FRONTEND_LOG="$LOG_DIR/frontend.log" + +# 기존 프로세스 정리 +echo "[1/4] 기존 프로세스 정리 중..." +lsof -ti:8080 | xargs kill -9 2>/dev/null +lsof -ti:9771 | xargs kill -9 2>/dev/null +echo " 완료" +echo "" + +# 백엔드 npm install + 실행 +echo "[2/4] 백엔드 의존성 설치 중..." +cd "$PROJECT_ROOT/backend-node" +npm install --silent +echo " 완료" +echo "" + +echo "[3/4] 백엔드 서버 시작 중 (포트 8080)..." +npm run dev > "$BACKEND_LOG" 2>&1 & +BACKEND_PID=$! +echo " PID: $BACKEND_PID" +echo "" + +# 프론트엔드 npm install + 실행 +echo "[4/4] 프론트엔드 의존성 설치 + 서버 시작 중 (포트 9771)..." +cd "$PROJECT_ROOT/frontend" +npm install --silent +npm run dev > "$FRONTEND_LOG" 2>&1 & +FRONTEND_PID=$! +echo " PID: $FRONTEND_PID" +echo "" + +sleep 3 + +echo "============================================" +echo "모든 서비스가 시작되었습니다!" +echo "============================================" +echo "" +echo " [BACKEND] http://localhost:8080/api" +echo " [FRONTEND] http://localhost:9771" +echo "" +echo " 백엔드 PID: $BACKEND_PID" +echo " 프론트엔드 PID: $FRONTEND_PID" +echo "" +echo " 프론트엔드 로그: tail -f $FRONTEND_LOG" +echo "" +echo "Ctrl+C 로 종료하면 백엔드/프론트엔드 모두 종료됩니다." +echo "============================================" +echo "" +echo "--- 백엔드 로그 출력 시작 ---" +echo "" + +trap "echo ''; echo '서비스를 종료합니다...'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" SIGINT SIGTERM + +tail -f "$BACKEND_LOG" -- 2.43.0 From 43ead0e7f2c912ece4ae807ad114bba35537ea8d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 26 Feb 2026 16:39:06 +0900 Subject: [PATCH 02/23] feat: Enhance SelectedItemsDetailInputComponent with sourceKeyField auto-detection and FK mapping - Implemented automatic detection of sourceKeyField based on component configuration, improving flexibility in data handling. - Enhanced the SelectedItemsDetailInputConfigPanel to support automatic FK detection and mapping, streamlining the configuration process. - Updated the database connection logic to handle DATE types correctly, preventing timezone-related issues. - Improved overall component performance by optimizing memoization and state management for better user experience. --- backend-node/src/database/db.ts | 4 + .../SelectedItemsDetailInputComponent.tsx | 38 ++-- .../SelectedItemsDetailInputConfigPanel.tsx | 140 ++++++++++++- .../selected-items-detail-input/types.ts | 24 +++ scripts/browser-test-admin-switch-button.js | 170 +++++++++++++++ scripts/browser-test-customer-crud.js | 167 +++++++++++++++ scripts/browser-test-customer-via-menu.js | 157 ++++++++++++++ scripts/browser-test-purchase-supplier.js | 196 ++++++++++++++++++ 8 files changed, 865 insertions(+), 31 deletions(-) create mode 100644 scripts/browser-test-admin-switch-button.js create mode 100644 scripts/browser-test-customer-crud.js create mode 100644 scripts/browser-test-customer-via-menu.js create mode 100644 scripts/browser-test-purchase-supplier.js diff --git a/backend-node/src/database/db.ts b/backend-node/src/database/db.ts index 4c249ac3..6fc10cf1 100644 --- a/backend-node/src/database/db.ts +++ b/backend-node/src/database/db.ts @@ -13,9 +13,13 @@ import { PoolClient, QueryResult as PgQueryResult, QueryResultRow, + types, } from "pg"; import config from "../config/environment"; +// DATE 타입(OID 1082)을 문자열로 반환 (타임존 변환에 의한 -1day 버그 방지) +types.setTypeParser(1082, (val: string) => val); + // PostgreSQL 연결 풀 let pool: Pool | null = null; diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c2bb436d..1f8b0484 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -67,9 +67,12 @@ export const SelectedItemsDetailInputComponent: React.FC { + // sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨) + return componentConfig.sourceKeyField || "item_id"; + }, [componentConfig.sourceKeyField]); // 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id const dataSourceId = useMemo( @@ -446,10 +449,16 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; + + // sourceKeyField 자동 매핑 (item_id = originalData.id) + if (sourceKeyField && item.originalData?.id) { + baseRecord[sourceKeyField] = item.originalData.id; + } + + // 나머지 autoFillFrom 필드 (sourceKeyField 제외) additionalFields.forEach((f) => { - if (f.autoFillFrom && item.originalData) { + if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) { const value = item.originalData[f.autoFillFrom]; if (value !== undefined && value !== null) { baseRecord[f.name] = value; @@ -504,7 +513,7 @@ export const SelectedItemsDetailInputComponent: React.FC { - const groupFields = additionalFields.filter((f) => f.groupId === group.id); - groupFields.forEach((field) => { - if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) { - sourceKeyValue = item.originalData[field.autoFillFrom] || null; - } - }); - }); - } - - // 3순위: fallback (최후의 수단) + // 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드) if (!sourceKeyValue && item.originalData) { sourceKeyValue = item.originalData.id || null; } diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 61f755a4..1f70e7e0 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo, useEffect } from "react"; +import React, { useState, useMemo, useEffect, useRef } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Plus, X, ChevronDown, ChevronRight } from "lucide-react"; -import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types"; +import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat, AutoDetectedFk } from "./types"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; @@ -97,7 +97,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>([]); - const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + + // FK 자동 감지 결과 + const [autoDetectedFks, setAutoDetectedFks] = useState([]); // 🆕 원본 테이블 컬럼 로드 useEffect(() => { @@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { if (!config.targetTable) { setLoadedTargetTableColumns([]); + setAutoDetectedFks([]); return; } @@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(() => { + if (!config.targetTable || loadedTargetTableColumns.length === 0) return []; + + const entityFkColumns = loadedTargetTableColumns.filter( + (col) => col.inputType === "entity" && col.referenceTable + ); + if (entityFkColumns.length === 0) return []; + + return entityFkColumns.map((col) => { + let mappingType: "source" | "parent" | "unknown" = "unknown"; + if (config.sourceTable && col.referenceTable === config.sourceTable) { + mappingType = "source"; + } else if (config.sourceTable && col.referenceTable !== config.sourceTable) { + mappingType = "parent"; + } + return { + columnName: col.columnName, + columnLabel: col.columnLabel, + referenceTable: col.referenceTable!, + referenceColumn: col.referenceColumn || "id", + mappingType, + }; + }); + }, [config.targetTable, config.sourceTable, loadedTargetTableColumns]); + + // 감지 결과를 state에 반영 + useEffect(() => { + setAutoDetectedFks(detectedFks); + }, [detectedFks]); + + // 자동 매핑 적용 (최초 1회만, targetTable 변경 시 리셋) + useEffect(() => { + fkAutoAppliedRef.current = false; + }, [config.targetTable]); + + useEffect(() => { + if (fkAutoAppliedRef.current || detectedFks.length === 0) return; + + const sourceFk = detectedFks.find((fk) => fk.mappingType === "source"); + const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent"); + let changed = false; + + // sourceKeyField 자동 설정 + if (sourceFk && !config.sourceKeyField) { + console.log("🔗 sourceKeyField 자동 설정:", sourceFk.columnName); + handleChange("sourceKeyField", sourceFk.columnName); + changed = true; + } + + // parentDataMapping 자동 생성 (기존에 없을 때만) + if (parentFks.length > 0 && (!config.parentDataMapping || config.parentDataMapping.length === 0)) { + const autoMappings = parentFks.map((fk) => ({ + sourceTable: fk.referenceTable, + sourceField: "id", + targetField: fk.columnName, + })); + console.log("🔗 parentDataMapping 자동 생성:", autoMappings); + handleChange("parentDataMapping", autoMappings); + changed = true; + } + + if (changed) { + fkAutoAppliedRef.current = true; + } + }, [detectedFks]); + // 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화 useEffect(() => { setLocalFieldGroups(config.fieldGroups || []); @@ -898,6 +975,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC최종 데이터를 저장할 테이블

+ {/* FK 자동 감지 결과 표시 */} + {autoDetectedFks.length > 0 && ( +
+

+ FK 자동 감지됨 ({autoDetectedFks.length}건) +

+
+ {autoDetectedFks.map((fk) => ( +
+ + {fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"} + + {fk.columnName} + -> + {fk.referenceTable} +
+ ))} +
+

+ 엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이 자동으로 설정됩니다. +

+
+ )} + {/* 표시할 원본 데이터 컬럼 */}
@@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {localFields.map((field, index) => ( + {localFields.map((field, index) => { + return (
@@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - ))} + ); + })} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {includeTime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/InlineCellDatePicker.tsx b/frontend/components/screen/filters/InlineCellDatePicker.tsx new file mode 100644 index 00000000..f47546b4 --- /dev/null +++ b/frontend/components/screen/filters/InlineCellDatePicker.tsx @@ -0,0 +1,279 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface InlineCellDatePickerProps { + value: string; + onChange: (value: string) => void; + onSave: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + inputRef?: React.RefObject; +} + +export const InlineCellDatePicker: React.FC = ({ + value, + onChange, + onSave, + onKeyDown, + inputRef, +}) => { + const [isOpen, setIsOpen] = useState(true); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const localInputRef = useRef(null); + const actualInputRef = inputRef || localInputRef; + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + } + }, []); + + const handleDateClick = (date: Date) => { + const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleSetToday = () => { + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleClear = () => { + onChange(""); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleInputChange = (raw: string) => { + onChange(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + } + } + }; + + const handlePopoverClose = (open: boolean) => { + if (!open) { + setIsOpen(false); + onSave(); + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + + + handleInputChange(e.target.value)} + onKeyDown={onKeyDown} + onClick={() => setIsOpen(true)} + placeholder="YYYYMMDD" + className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + /> + + e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 54fdcfed..79f16a41 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC = ({ label, value const [isOpen, setIsOpen] = useState(false); const [currentMonth, setCurrentMonth] = useState(new Date()); const [selectingType, setSelectingType] = useState<"from" | "to">("from"); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); // 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장) const [tempValue, setTempValue] = useState(value || {}); @@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC = ({ label, value if (isOpen) { setTempValue(value || {}); setSelectingType("from"); + setViewMode("calendar"); } }, [isOpen, value]); @@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC = ({ label, value
- {/* 월 네비게이션 */} -
- -
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
- -
- - {/* 요일 헤더 */} -
- {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( -
- {day} -
- ))} -
- - {/* 날짜 그리드 */} -
- {allDays.map((date, index) => { - if (!date) { - return
; - } - - const isCurrentMonth = isSameMonth(date, currentMonth); - const isSelected = isRangeStart(date) || isRangeEnd(date); - const isInRangeDate = isInRange(date); - const isTodayDate = isToday(date); - - return ( - - ); - })} -
+
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> + {/* 월 선택 뷰 */} +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> + {/* 월 네비게이션 */} +
+ + + +
+ + {/* 요일 헤더 */} +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {allDays.map((date, index) => { + if (!date) { + return
; + } + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = isRangeStart(date) || isRangeEnd(date); + const isInRangeDate = isInRange(date); + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} {/* 선택된 범위 표시 */} {(tempValue.from || tempValue.to) && ( diff --git a/frontend/components/screen/table-options/GroupingPanel.tsx b/frontend/components/screen/table-options/GroupingPanel.tsx index 0495991d..867448d0 100644 --- a/frontend/components/screen/table-options/GroupingPanel.tsx +++ b/frontend/components/screen/table-options/GroupingPanel.tsx @@ -99,7 +99,7 @@ export const GroupingPanel: React.FC = ({ 전체 해제
-
+
{selectedColumns.map((colName, index) => { const col = table?.columns.find( (c) => c.columnName === colName diff --git a/frontend/components/screen/table-options/TableSettingsModal.tsx b/frontend/components/screen/table-options/TableSettingsModal.tsx index ef07e017..4f9325cb 100644 --- a/frontend/components/screen/table-options/TableSettingsModal.tsx +++ b/frontend/components/screen/table-options/TableSettingsModal.tsx @@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters 전체 해제
-
+
{selectedGroupColumns.map((colName, index) => { const col = table?.columns.find((c) => c.columnName === colName); if (!col) return null; diff --git a/frontend/components/screen/widgets/types/DateWidget.tsx b/frontend/components/screen/widgets/types/DateWidget.tsx index edb78df9..3b0f47e2 100644 --- a/frontend/components/screen/widgets/types/DateWidget.tsx +++ b/frontend/components/screen/widgets/types/DateWidget.tsx @@ -1,7 +1,22 @@ "use client"; -import React from "react"; -import { Input } from "@/components/ui/input"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; import { WebTypeComponentProps } from "@/lib/registry/types"; import { WidgetComponent, DateTypeConfig } from "@/types/screen"; @@ -10,99 +25,341 @@ export const DateWidget: React.FC = ({ component, value, const { placeholder, required, style } = widget; const config = widget.webTypeConfig as DateTypeConfig | undefined; - // 사용자가 테두리를 설정했는지 확인 const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const borderClass = hasCustomBorder ? "!border-0" : ""; - // 날짜 포맷팅 함수 - const formatDateValue = (val: string) => { - if (!val) return ""; + const isDatetime = widget.widgetType === "datetime"; + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string | undefined): Date | undefined => { + if (!val) return undefined; try { const date = new Date(val); - if (isNaN(date.getTime())) return val; - - if (widget.widgetType === "datetime") { - return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm - } else { - return date.toISOString().slice(0, 10); // YYYY-MM-DD - } + if (isNaN(date.getTime())) return undefined; + return date; } catch { - return val; + return undefined; } }; - // 날짜 유효성 검증 - const validateDate = (dateStr: string): boolean => { - if (!dateStr) return true; - - const date = new Date(dateStr); - if (isNaN(date.getTime())) return false; - - // 최소/최대 날짜 검증 - if (config?.minDate) { - const minDate = new Date(config.minDate); - if (date < minDate) return false; - } - - if (config?.maxDate) { - const maxDate = new Date(config.maxDate); - if (date > maxDate) return false; - } - - return true; - }; - - // 입력값 처리 - const handleChange = (e: React.ChangeEvent) => { - const inputValue = e.target.value; - - if (validateDate(inputValue)) { - onChange?.(inputValue); - } - }; - - // 웹타입에 따른 input type 결정 - const getInputType = () => { - switch (widget.widgetType) { - case "datetime": - return "datetime-local"; - case "date": - default: - return "date"; - } - }; - - // 기본값 설정 (현재 날짜/시간) - const getDefaultValue = () => { + const getDefaultValue = (): string => { if (config?.defaultValue === "current") { const now = new Date(); - if (widget.widgetType === "datetime") { - return now.toISOString().slice(0, 16); - } else { - return now.toISOString().slice(0, 10); - } + if (isDatetime) return now.toISOString().slice(0, 16); + return now.toISOString().slice(0, 10); } return ""; }; const finalValue = value || getDefaultValue(); + const selectedDate = parseDate(finalValue); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + if (isDatetime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const handleDateClick = (date: Date) => { + let dateStr: string; + if (isDatetime) { + const [hours, minutes] = timeValue.split(":").map(Number); + const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0); + dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + onChange?.(dateStr); + if (!isDatetime) { + setIsOpen(false); + } + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) { + const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`; + onChange?.(dateStr); + } + }; + + const handleClear = () => { + onChange?.(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + let dateStr: string; + if (isDatetime) { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + } + onChange?.(dateStr); + setCurrentMonth(new Date(y, m, 1)); + if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const handleSetToday = () => { + const today = new Date(); + if (isDatetime) { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange?.(dateStr); + } else { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange?.(dateStr); + } + setIsOpen(false); + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; return ( - + { if (!v) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!readonly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !readonly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {/* datetime 타입: 시간 입력 */} + {isDatetime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} + +
+ + ); }; DateWidget.displayName = "DateWidget"; - - diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index d6ed8c62..872e7d57 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; -import { FolderTree, Loader2, Search, X } from "lucide-react"; +import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; interface CategoryColumn { @@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); // 검색어로 필터링된 컬럼 목록 const filteredColumns = useMemo(() => { @@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, }); }, [columns, searchQuery]); + // 테이블별로 그룹화된 컬럼 목록 + const groupedColumns = useMemo(() => { + const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = []; + const groupMap = new Map(); + + for (const col of filteredColumns) { + const key = col.tableName; + if (!groupMap.has(key)) { + groupMap.set(key, []); + } + groupMap.get(key)!.push(col); + } + + for (const [tblName, cols] of groupMap) { + groups.push({ + tableName: tblName, + tableLabel: cols[0]?.tableLabel || tblName, + columns: cols, + }); + } + + return groups; + }, [filteredColumns]); + + // 선택된 컬럼이 있는 그룹을 자동 펼침 + useEffect(() => { + if (!selectedColumn) return; + const tableName = selectedColumn.split(".")[0]; + if (tableName) { + setExpandedGroups((prev) => { + if (prev.has(tableName)) return prev; + const next = new Set(prev); + next.add(tableName); + return next; + }); + } + }, [selectedColumn]); + useEffect(() => { // 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회 loadCategoryColumnsByMenu(); @@ -279,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, )}
-
+
{filteredColumns.length === 0 && searchQuery ? (
'{searchQuery}'에 대한 검색 결과가 없습니다
) : null} - {filteredColumns.map((column) => { - const uniqueKey = `${column.tableName}.${column.columnName}`; - const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교 - return ( -
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} - className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ - isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" - }`} - > -
- -
-

{column.columnLabel || column.columnName}

-

{column.tableLabel || column.tableName}

-
- - {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} - + {groupedColumns.map((group) => { + const isExpanded = expandedGroups.has(group.tableName); + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const hasSelectedInGroup = group.columns.some( + (c) => selectedColumn === `${c.tableName}.${c.columnName}`, + ); + + // 그룹이 1개뿐이면 드롭다운 없이 바로 표시 + if (groupedColumns.length <= 1) { + return ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ + isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+

{column.tableLabel || column.tableName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })}
+ ); + } + + return ( +
+ {/* 드롭다운 헤더 */} + + + {/* 펼쳐진 컬럼 목록 */} + {isExpanded && ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${ + isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50" + }`} + > +
+ + {column.columnLabel || column.columnName} + + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })} +
+ )}
); })} diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 3c7a9239..2da0647f 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( 2100) return null; + return date; +} + /** * 단일 날짜 선택 컴포넌트 */ const SingleDatePicker = forwardRef< - HTMLButtonElement, + HTMLDivElement, { value?: string; onChange?: (value: string) => void; @@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef< ref, ) => { const [open, setOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + const inputRef = React.useRef(null); const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); - // 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로) const displayText = useMemo(() => { if (!value) return ""; - // Date 객체로 변환 후 포맷팅 - if (date && isValid(date)) { - return formatDate(date, dateFormat); - } + if (date && isValid(date)) return formatDate(date, dateFormat); return value; }, [value, date, dateFormat]); - const handleSelect = useCallback( - (selectedDate: Date | undefined) => { - if (selectedDate) { - onChange?.(formatDate(selectedDate, dateFormat)); - setOpen(false); + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (date && isValid(date)) { + setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1)); + setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); } - }, - [dateFormat, onChange], - ); + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleDateClick = useCallback((clickedDate: Date) => { + onChange?.(formatDate(clickedDate, dateFormat)); + setIsTyping(false); + setOpen(false); + }, [dateFormat, onChange]); const handleToday = useCallback(() => { onChange?.(formatDate(new Date(), dateFormat)); + setIsTyping(false); setOpen(false); }, [dateFormat, onChange]); const handleClear = useCallback(() => { onChange?.(""); + setIsTyping(false); setOpen(false); }, [onChange]); + const handleTriggerInput = useCallback((raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!open) setOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + onChange?.(formatDate(parsed, dateFormat)); + setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1)); + setTimeout(() => { setIsTyping(false); setOpen(false); }, 400); + } + } + }, [dateFormat, onChange, open]); + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + return ( - + { if (!v) { setOpen(false); setIsTyping(false); } }}> - + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className={cn( + "h-full w-full bg-transparent text-sm outline-none", + "placeholder:text-muted-foreground disabled:cursor-not-allowed", + !displayText && !isTyping && "text-muted-foreground", + )} + /> +
- - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> -
- {showToday && ( - + )} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = date ? isSameDay(d, date) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ )} -
@@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker"; /** * 날짜 범위 선택 컴포넌트 */ +/** + * 범위 날짜 팝오버 내부 캘린더 (drill-down 지원) + */ +const RangeCalendarPopover: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDate?: Date; + onSelect: (date: Date) => void; + label: string; + disabled?: boolean; + readonly?: boolean; + displayValue?: string; +}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (selectedDate && isValid(selectedDate)) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + setIsTyping(false); + onSelect(parsed); + } + } + }; + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + + return ( + { if (!v) { setIsTyping(false); } onOpenChange(v); }}> + +
{ if (!disabled && !readonly) onOpenChange(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> +
+
+ e.preventDefault()}> +
+ {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = selectedDate ? isSameDay(d, selectedDate) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ + )} +
+ + + ); +}; + const RangeDatePicker = forwardRef< HTMLDivElement, { @@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef< const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const handleStartSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newStart = formatDate(date, dateFormat); - // 시작일이 종료일보다 크면 종료일도 같이 변경 - if (endDate && date > endDate) { - onChange?.([newStart, newStart]); - } else { - onChange?.([newStart, value[1]]); - } - setOpenStart(false); + (date: Date) => { + const newStart = formatDate(date, dateFormat); + if (endDate && date > endDate) { + onChange?.([newStart, newStart]); + } else { + onChange?.([newStart, value[1]]); } + setOpenStart(false); }, [value, dateFormat, endDate, onChange], ); const handleEndSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newEnd = formatDate(date, dateFormat); - // 종료일이 시작일보다 작으면 시작일도 같이 변경 - if (startDate && date < startDate) { - onChange?.([newEnd, newEnd]); - } else { - onChange?.([value[0], newEnd]); - } - setOpenEnd(false); + (date: Date) => { + const newEnd = formatDate(date, dateFormat); + if (startDate && date < startDate) { + onChange?.([newEnd, newEnd]); + } else { + onChange?.([value[0], newEnd]); } + setOpenEnd(false); }, [value, dateFormat, startDate, onChange], ); return (
- {/* 시작 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> - - - + ~ - - {/* 종료 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - // 시작일보다 이전 날짜는 선택 불가 - if (startDate && date < startDate) return true; - return false; - }} - /> - - +
); }); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 173a67ad..f753a240 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index 13a7ac4f..2f35c799 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC = ({ style, ...props }) => { - // 컴포넌트 설정 const componentConfig = { ...config, ...component.config, } as ImageDisplayConfig; - // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) + const objectFit = componentConfig.objectFit || "contain"; + const altText = componentConfig.altText || "이미지"; + const borderRadius = componentConfig.borderRadius ?? 8; + const showBorder = componentConfig.showBorder ?? true; + const backgroundColor = componentConfig.backgroundColor || "#f9fafb"; + const placeholder = componentConfig.placeholder || "이미지 없음"; + + const imageSrc = component.value || componentConfig.imageUrl || ""; + const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", }; - // 디자인 모드 스타일 if (isDesignMode) { componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; } - // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); @@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC = ({ }} > {component.label} - {component.required && *} + {(component.required || componentConfig.required) && ( + * + )} )} @@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC = ({ style={{ width: "100%", height: "100%", - border: "1px solid #d1d5db", - borderRadius: "8px", + border: showBorder ? "1px solid #d1d5db" : "none", + borderRadius: `${borderRadius}px`, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", - backgroundColor: "#f9fafb", + backgroundColor, transition: "all 0.2s ease-in-out", - boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none", + opacity: componentConfig.disabled ? 0.5 : 1, + cursor: componentConfig.disabled ? "not-allowed" : "default", }} onMouseEnter={(e) => { - e.currentTarget.style.borderColor = "#f97316"; - e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + if (!componentConfig.disabled) { + if (showBorder) { + e.currentTarget.style.borderColor = "#f97316"; + } + e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + } }} onMouseLeave={(e) => { - e.currentTarget.style.borderColor = "#d1d5db"; - e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)"; + if (showBorder) { + e.currentTarget.style.borderColor = "#d1d5db"; + } + e.currentTarget.style.boxShadow = showBorder + ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" + : "none"; }} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} > - {component.value || componentConfig.imageUrl ? ( + {imageSrc ? ( {componentConfig.altText { (e.target as HTMLImageElement).style.display = "none"; if (e.target?.parentElement) { e.target.parentElement.innerHTML = `
-
🖼️
+
이미지 로드 실패
`; @@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC = ({ fontSize: "14px", }} > -
🖼️
-
이미지 없음
+ + + + + +
{placeholder}
)}
@@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC = ({ /** * ImageDisplay 래퍼 컴포넌트 - * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ImageDisplayWrapper: React.FC = (props) => { return ; diff --git a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx index 6c73e1d9..7f36f51b 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx @@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types"; export interface ImageDisplayConfigPanelProps { config: ImageDisplayConfig; - onChange: (config: Partial) => void; + onChange?: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; } /** * ImageDisplay 설정 패널 - * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ export const ImageDisplayConfigPanel: React.FC = ({ config, onChange, + onConfigChange, }) => { const handleChange = (key: keyof ImageDisplayConfig, value: any) => { - onChange({ [key]: value }); + const update = { ...config, [key]: value }; + onChange?.(update); + onConfigChange?.(update); }; return (
-
- image-display 설정 +
이미지 표시 설정
+ + {/* 이미지 URL */} +
+ + handleChange("imageUrl", e.target.value)} + placeholder="https://..." + className="h-8 text-xs" + /> +

+ 데이터 바인딩 값이 없을 때 표시할 기본 이미지 +

- {/* file 관련 설정 */} + {/* 대체 텍스트 */}
- + + handleChange("altText", e.target.value)} + placeholder="이미지 설명" + className="h-8 text-xs" + /> +
+ + {/* 이미지 맞춤 */} +
+ + +
+ + {/* 테두리 둥글기 */} +
+ + handleChange("borderRadius", parseInt(e.target.value) || 0)} + className="h-8 text-xs" + /> +
+ + {/* 배경 색상 */} +
+ +
+ handleChange("backgroundColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("backgroundColor", e.target.value)} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 플레이스홀더 */} +
+ handleChange("placeholder", e.target.value)} + placeholder="이미지 없음" + className="h-8 text-xs" />
- {/* 공통 설정 */} -
- + {/* 테두리 표시 */} +
handleChange("disabled", checked)} + id="showBorder" + checked={config.showBorder ?? true} + onCheckedChange={(checked) => handleChange("showBorder", checked)} /> +
-
- - handleChange("required", checked)} - /> -
- -
- + {/* 읽기 전용 */} +
handleChange("readonly", checked)} /> + +
+ + {/* 필수 입력 */} +
+ handleChange("required", checked)} + /> +
); diff --git a/frontend/lib/registry/components/image-display/config.ts b/frontend/lib/registry/components/image-display/config.ts index 268382f0..bae67e14 100644 --- a/frontend/lib/registry/components/image-display/config.ts +++ b/frontend/lib/registry/components/image-display/config.ts @@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types"; * ImageDisplay 컴포넌트 기본 설정 */ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { - placeholder: "입력하세요", - - // 공통 기본값 + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", + disabled: false, required: false, readonly: false, @@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { /** * ImageDisplay 컴포넌트 설정 스키마 - * 유효성 검사 및 타입 체크에 사용 */ export const ImageDisplayConfigSchema = { - placeholder: { type: "string", default: "" }, - - // 공통 스키마 + imageUrl: { type: "string", default: "" }, + altText: { type: "string", default: "이미지" }, + objectFit: { + type: "enum", + values: ["contain", "cover", "fill", "none", "scale-down"], + default: "contain", + }, + borderRadius: { type: "number", default: 8 }, + showBorder: { type: "boolean", default: true }, + backgroundColor: { type: "string", default: "#f9fafb" }, + placeholder: { type: "string", default: "이미지 없음" }, + disabled: { type: "boolean", default: false }, required: { type: "boolean", default: false }, readonly: { type: "boolean", default: false }, - variant: { - type: "enum", - values: ["default", "outlined", "filled"], - default: "default" + variant: { + type: "enum", + values: ["default", "outlined", "filled"], + default: "default", }, - size: { - type: "enum", - values: ["sm", "md", "lg"], - default: "md" + size: { + type: "enum", + values: ["sm", "md", "lg"], + default: "md", }, }; diff --git a/frontend/lib/registry/components/image-display/index.ts b/frontend/lib/registry/components/image-display/index.ts index ddb38f95..ffa5712a 100644 --- a/frontend/lib/registry/components/image-display/index.ts +++ b/frontend/lib/registry/components/image-display/index.ts @@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({ webType: "file", component: ImageDisplayWrapper, defaultConfig: { - placeholder: "입력하세요", + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", }, defaultSize: { width: 200, height: 200 }, configPanel: ImageDisplayConfigPanel, diff --git a/frontend/lib/registry/components/image-display/types.ts b/frontend/lib/registry/components/image-display/types.ts index f2b6971d..e882ebe4 100644 --- a/frontend/lib/registry/components/image-display/types.ts +++ b/frontend/lib/registry/components/image-display/types.ts @@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component"; * ImageDisplay 컴포넌트 설정 타입 */ export interface ImageDisplayConfig extends ComponentConfig { - // file 관련 설정 + // 이미지 관련 설정 + imageUrl?: string; + altText?: string; + objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down"; + borderRadius?: number; + showBorder?: boolean; + backgroundColor?: string; placeholder?: string; - + // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; - placeholder?: string; - helperText?: string; - + // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; - + // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; @@ -37,7 +41,7 @@ export interface ImageDisplayProps { config?: ImageDisplayConfig; className?: string; style?: React.CSSProperties; - + // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index c806e0df..6d55b650 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { UniversalFormModalComponentProps, @@ -1835,11 +1836,11 @@ export function UniversalFormModalComponent({ case "date": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜를 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} /> @@ -1847,13 +1848,14 @@ export function UniversalFormModalComponent({ case "datetime": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜/시간을 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} + includeTime /> ); diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 371814b5..c00c1b1f 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx index e8b0dba9..58554c9d 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -247,14 +247,12 @@ export const FileManagerModal: React.FC = ({
- {/* 파일 업로드 영역 - 높이 축소 */} - {!isDesignMode && ( + {/* 파일 업로드 영역 - readonly/disabled이면 숨김 */} + {!isDesignMode && !config.readonly && !config.disabled && (
{ - if (!config.disabled && !isDesignMode) { - fileInputRef.current?.click(); - } + fileInputRef.current?.click(); }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -267,7 +265,6 @@ export const FileManagerModal: React.FC = ({ accept={config.accept} onChange={handleFileInputChange} className="hidden" - disabled={config.disabled} /> {uploading ? ( @@ -286,8 +283,8 @@ export const FileManagerModal: React.FC = ({ {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
- {/* 좌측: 이미지 미리보기 (확대/축소 가능) */} -
+ {/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */} + {(config.showPreview !== false) &&
{/* 확대/축소 컨트롤 */} {selectedFile && previewImageUrl && (
@@ -369,10 +366,10 @@ export const FileManagerModal: React.FC = ({ {selectedFile.realFileName}
)} -
+
} - {/* 우측: 파일 목록 (고정 너비) */} -
+ {/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */} + {(config.showFileList !== false) &&

업로드된 파일

@@ -404,7 +401,7 @@ export const FileManagerModal: React.FC = ({ )}

- {formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()} + {config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • }{file.fileExt.toUpperCase()}

@@ -434,19 +431,21 @@ export const FileManagerModal: React.FC = ({ > - - {!isDesignMode && ( + {config.allowDownload !== false && ( + + )} + {!isDesignMode && config.allowDelete !== false && (
)}
-
+
}
@@ -487,8 +486,8 @@ export const FileManagerModal: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={onFileDownload} - onDelete={!isDesignMode ? onFileDelete : undefined} + onDownload={config.allowDownload !== false ? onFileDownload : undefined} + onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined} /> ); diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index fc39458a..de55bf2a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -105,6 +105,8 @@ const FileUploadComponent: React.FC = ({ const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + // objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지) + const filesLoadedFromObjidRef = useRef(false); // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); @@ -150,6 +152,7 @@ const FileUploadComponent: React.FC = ({ if (isRecordMode || !recordId) { setUploadedFiles([]); setRepresentativeImageUrl(null); + filesLoadedFromObjidRef.current = false; } } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 @@ -191,63 +194,68 @@ const FileUploadComponent: React.FC = ({ }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 // 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드 - // 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지) + // 콤마로 구분된 다중 objid도 처리 (예: "123,456") const imageObjidFromFormData = formData?.[columnName]; useEffect(() => { - // 이미지 objid가 있고, 숫자 문자열인 경우에만 처리 - if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) { - const objidStr = String(imageObjidFromFormData); - - // 이미 같은 objid의 파일이 로드되어 있으면 스킵 - const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr); - if (alreadyLoaded) { - return; - } - - // 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일) - (async () => { - try { - const fileInfoResponse = await getFileInfoByObjid(objidStr); + if (!imageObjidFromFormData) return; + + const rawValue = String(imageObjidFromFormData); + // 콤마 구분 다중 objid 또는 단일 objid 모두 처리 + const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s)); + + if (objids.length === 0) return; + + // 모든 objid가 이미 로드되어 있으면 스킵 + const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id)); + if (allLoaded) return; + + (async () => { + try { + const loadedFiles: FileInfo[] = []; + + for (const objid of objids) { + // 이미 로드된 파일은 스킵 + if (uploadedFiles.some(f => String(f.objid) === objid)) continue; + + const fileInfoResponse = await getFileInfoByObjid(objid); if (fileInfoResponse.success && fileInfoResponse.data) { const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data; - const fileInfo = { - objid: objidStr, - realFileName: realFileName, - fileExt: fileExt, - fileSize: fileSize, - filePath: getFilePreviewUrl(objidStr), - regdate: regdate, + loadedFiles.push({ + objid, + realFileName, + fileExt, + fileSize, + filePath: getFilePreviewUrl(objid), + regdate, isImage: true, - isRepresentative: isRepresentative, - }; - - setUploadedFiles([fileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + isRepresentative, + } as FileInfo); } else { // 파일 정보 조회 실패 시 최소 정보로 추가 - console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용"); - const minimalFileInfo = { - objid: objidStr, - realFileName: `image_${objidStr}.jpg`, + loadedFiles.push({ + objid, + realFileName: `file_${objid}`, fileExt: '.jpg', fileSize: 0, - filePath: getFilePreviewUrl(objidStr), + filePath: getFilePreviewUrl(objid), regdate: new Date().toISOString(), isImage: true, - }; - - setUploadedFiles([minimalFileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + } as FileInfo); } - } catch (error) { - console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); } - })(); - } - }, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존 + + if (loadedFiles.length > 0) { + setUploadedFiles(loadedFiles); + filesLoadedFromObjidRef.current = true; + } + } catch (error) { + console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); + } + })(); + }, [imageObjidFromFormData, columnName, component.id]); // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 @@ -365,6 +373,10 @@ const FileUploadComponent: React.FC = ({ ...file, })); + // 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음 + if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) { + return false; + } // 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용) let finalFiles = formattedFiles; @@ -427,14 +439,19 @@ const FileUploadComponent: React.FC = ({ return; // DB 로드 성공 시 localStorage 무시 } - // 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 + // objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음 + if (filesLoadedFromObjidRef.current) { + return; + } + + // 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 if (!isRecordMode || !recordId) { return; } // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) - // 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용) + // 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const uniqueKeyForFallback = getUniqueKey(); const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || []; @@ -442,6 +459,10 @@ const FileUploadComponent: React.FC = ({ // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; + // 빈 데이터로 기존 파일을 덮어쓰지 않음 + if (currentFiles.length === 0) { + return; + } // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { @@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={handleFileDownload} - onDelete={!isDesignMode ? handleFileDelete : undefined} + onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined} + onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined} /> {/* 파일 관리 모달 */} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 30584fc4..ebdf9d2b 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2172,7 +2172,7 @@ export const TableListComponent: React.FC = ({ const handleRowClick = (row: any, index: number, e: React.MouseEvent) => { // 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨) const target = e.target as HTMLElement; - if (target.closest('input[type="checkbox"]')) { + if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) { return; } @@ -2198,35 +2198,32 @@ export const TableListComponent: React.FC = ({ } }; - // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) + // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); setFocusedCell({ rowIndex, colIndex }); - // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) tableContainerRef.current?.focus(); - // 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리 - // filteredData에서 해당 행의 데이터 가져오기 const row = filteredData[rowIndex]; if (!row) return; + // 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵 + const column = visibleColumns[colIndex]; + if (column?.columnName === "__checkbox__") return; + const rowKey = getRowKey(row, rowIndex); const isCurrentlySelected = selectedRows.has(rowKey); - // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { - // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 + // 분할 패널 좌측: 단일 행 선택 모드 if (!isCurrentlySelected) { - // 기존 선택 해제하고 새 행 선택 setSelectedRows(new Set([rowKey])); setIsAllSelected(false); - // 분할 패널 컨텍스트에 데이터 저장 splitPanelContext.setSelectedLeftData(row); - // onSelectedRowsChange 콜백 호출 if (onSelectedRowsChange) { onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection); } @@ -2234,6 +2231,17 @@ export const TableListComponent: React.FC = ({ onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] }); } } + } else { + // 일반 모드: 행 선택/해제 토글 + handleRowSelection(rowKey, !isCurrentlySelected); + + if (splitPanelContext && effectiveSplitPosition === "left") { + if (!isCurrentlySelected) { + splitPanelContext.setSelectedLeftData(row); + } else { + splitPanelContext.setSelectedLeftData(null); + } + } } }; @@ -6309,6 +6317,21 @@ export const TableListComponent: React.FC = ({ ); } + // 날짜 타입: 캘린더 피커 + const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime"; + if (isDateType) { + const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); + return ( + + ); + } + // 일반 입력 필드 return ( { if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) { return; @@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return; } - const newOptions: Record> = { ...selectOptions }; + const loadedOptions: Record> = {}; + let hasNewOptions = false; for (const filter of selectFilters) { - // 이미 로드된 옵션이 있으면 스킵 (초기값 유지) - if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) { - continue; - } - try { const options = await currentTable.getColumnUniqueValues(filter.columnName); - newOptions[filter.columnName] = options; + if (options && options.length > 0) { + loadedOptions[filter.columnName] = options; + hasNewOptions = true; + } } catch (error) { console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error); } } - setSelectOptions(newOptions); + + if (hasNewOptions) { + setSelectOptions((prev) => { + // 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합 + const merged = { ...prev }; + for (const [key, value] of Object.entries(loadedOptions)) { + if (!merged[key] || merged[key].length === 0) { + merged[key] = value; + } + } + return merged; + }); + } }; loadSelectOptions(); - }, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경 + }, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]); // 높이 변화 감지 및 알림 (실제 화면에서만) useEffect(() => { @@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table - +
{uniqueOptions.length === 0 ? (
옵션 없음
@@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)} onClick={(e) => e.stopPropagation()} /> - {option.label} + {option.label}
))}
-- 2.43.0 From e622013b3d822c6a7316ef82dedec46d24d3f424 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 17:32:39 +0900 Subject: [PATCH 06/23] feat: Enhance image handling in TableCellImage component - Updated the TableCellImage component to support multiple image inputs, displaying a representative image when available. - Implemented a new helper function `loadImageBlob` for loading images from blob URLs, improving image loading efficiency. - Refactored image loading logic to handle both single and multiple objid cases, ensuring robust error handling and loading states. - Enhanced user experience by allowing direct URL usage for non-objid image paths. --- .../v2-table-list/TableListComponent.tsx | 120 ++++++++++++------ 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index ebdf9d2b..4170360d 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -14,53 +14,71 @@ import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; // 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 // objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 +// 다중 이미지(콤마 구분)인 경우 대표 이미지를 우선 표시 const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { const [imgSrc, setImgSrc] = React.useState(null); + const [displayObjid, setDisplayObjid] = React.useState(""); const [error, setError] = React.useState(false); const [loading, setLoading] = React.useState(true); React.useEffect(() => { let mounted = true; - // 다중 이미지인 경우 대표 이미지(첫 번째)만 사용 const rawValue = String(value); - const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; - const isObjid = /^\d+$/.test(strValue); + const parts = rawValue.split(",").map(s => s.trim()).filter(Boolean); - if (isObjid) { - // objid인 경우: 인증된 API로 blob 다운로드 - const loadImage = async () => { - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/files/preview/${strValue}`, { - responseType: "blob", - }); - if (mounted) { - const blob = new Blob([response.data]); - const url = window.URL.createObjectURL(blob); - setImgSrc(url); - setLoading(false); - } - } catch { - if (mounted) { - setError(true); - setLoading(false); - } - } - }; - loadImage(); - } else { - // 경로인 경우: 직접 URL 사용 - setImgSrc(getFullImageUrl(strValue)); - setLoading(false); + // 단일 값 또는 경로인 경우 + if (parts.length <= 1) { + const strValue = parts[0] || rawValue; + setDisplayObjid(strValue); + const isObjid = /^\d+$/.test(strValue); + + if (isObjid) { + loadImageBlob(strValue, mounted, setImgSrc, setError, setLoading); + } else { + setImgSrc(getFullImageUrl(strValue)); + setLoading(false); + } + return () => { mounted = false; }; } - return () => { - mounted = false; - // blob URL 해제 - if (imgSrc && imgSrc.startsWith("blob:")) { - window.URL.revokeObjectURL(imgSrc); + // 다중 objid: 대표 이미지를 찾아서 표시 + const objids = parts.filter(s => /^\d+$/.test(s)); + if (objids.length === 0) { + setLoading(false); + setError(true); + return () => { mounted = false; }; + } + + (async () => { + try { + const { getFileInfoByObjid } = await import("@/lib/api/file"); + let representativeId: string | null = null; + + // 각 objid의 대표 여부를 확인 + for (const objid of objids) { + const info = await getFileInfoByObjid(objid); + if (info.success && info.data?.isRepresentative) { + representativeId = objid; + break; + } + } + + // 대표 이미지가 없으면 첫 번째 사용 + const targetObjid = representativeId || objids[0]; + if (mounted) { + setDisplayObjid(targetObjid); + loadImageBlob(targetObjid, mounted, setImgSrc, setError, setLoading); + } + } catch { + if (mounted) { + // 대표 조회 실패 시 첫 번째 사용 + setDisplayObjid(objids[0]); + loadImageBlob(objids[0], mounted, setImgSrc, setError, setLoading); + } } - }; + })(); + + return () => { mounted = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); @@ -91,10 +109,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { style={{ maxWidth: "40px", maxHeight: "40px" }} onClick={(e) => { e.stopPropagation(); - const rawValue = String(value); - const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; - const isObjid = /^\d+$/.test(strValue); - const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); + const isObjid = /^\d+$/.test(displayObjid); + const openUrl = isObjid ? getFilePreviewUrl(displayObjid) : getFullImageUrl(displayObjid); window.open(openUrl, "_blank"); }} onError={() => setError(true)} @@ -104,6 +120,32 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { }); TableCellImage.displayName = "TableCellImage"; +// 이미지 blob 로딩 헬퍼 +function loadImageBlob( + objid: string, + mounted: boolean, + setImgSrc: (url: string) => void, + setError: (err: boolean) => void, + setLoading: (loading: boolean) => void, +) { + import("@/lib/api/client").then(({ apiClient }) => { + apiClient.get(`/files/preview/${objid}`, { responseType: "blob" }) + .then((response) => { + if (mounted) { + const blob = new Blob([response.data]); + setImgSrc(window.URL.createObjectURL(blob)); + setLoading(false); + } + }) + .catch(() => { + if (mounted) { + setError(true); + setLoading(false); + } + }); + }); +} + // 🆕 RelatedDataButtons 전역 레지스트리 타입 선언 declare global { interface Window { -- 2.43.0 From 385a10e2e77a61596b8c14dbcca6acd477f17948 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 26 Feb 2026 20:48:56 +0900 Subject: [PATCH 07/23] feat: Add BOM version initialization feature and enhance version handling - Implemented a new endpoint to initialize BOM versions, automatically creating the first version and updating related details. - Enhanced the BOM service to include logic for version name handling and duplication checks during version creation. - Updated the BOM controller to support the new initialization functionality, improving BOM management capabilities. - Improved the BOM version modal to allow users to specify version names during creation, enhancing user experience and flexibility. --- backend-node/src/controllers/bomController.ts | 18 ++- backend-node/src/routes/bomRoutes.ts | 1 + backend-node/src/services/bomService.ts | 90 ++++++++++-- .../BomItemEditorComponent.tsx | 129 +++++++++++------- .../v2-bom-tree/BomDetailEditModal.tsx | 55 +++++++- .../v2-bom-tree/BomTreeComponent.tsx | 40 +++++- .../v2-bom-tree/BomVersionModal.tsx | 70 ++++++++-- 7 files changed, 321 insertions(+), 82 deletions(-) diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 8355b148..3508fca4 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) { const { bomId } = req.params; const companyCode = (req as any).user?.companyCode || "*"; const createdBy = (req as any).user?.userName || (req as any).user?.userId || ""; - const { tableName, detailTable } = req.body || {}; + const { tableName, detailTable, versionName } = req.body || {}; - const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable); + const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName); res.json({ success: true, data: result }); } catch (error: any) { logger.error("BOM 버전 생성 실패", { error: error.message }); @@ -129,6 +129,20 @@ export async function activateBomVersion(req: Request, res: Response) { } } +export async function initializeBomVersion(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const createdBy = (req as any).user?.userName || (req as any).user?.userId || ""; + + const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 초기 버전 생성 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function deleteBomVersion(req: Request, res: Response) { try { const { bomId, versionId } = req.params; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index f6e3ee62..4aa8838d 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -20,6 +20,7 @@ router.post("/:bomId/history", bomController.addBomHistory); // 버전 router.get("/:bomId/versions", bomController.getBomVersions); router.post("/:bomId/versions", bomController.createBomVersion); +router.post("/:bomId/initialize-version", bomController.initializeBomVersion); router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion); router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 89da38a9..b5cff246 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -98,6 +98,7 @@ export async function getBomVersions(bomId: string, companyCode: string, tableNa export async function createBomVersion( bomId: string, companyCode: string, createdBy: string, versionTableName?: string, detailTableName?: string, + inputVersionName?: string, ) { const vTable = safeTableName(versionTableName || "", "bom_version"); const dTable = safeTableName(detailTableName || "", "bom_detail"); @@ -107,17 +108,24 @@ export async function createBomVersion( if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); const bomData = bomRow.rows[0]; - // 다음 버전 번호 결정 - const lastVersion = await client.query( - `SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`, - [bomId], - ); - let nextVersionNum = 1; - if (lastVersion.rows.length > 0) { - const parsed = parseFloat(lastVersion.rows[0].version_name); - if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1; + // 버전명: 사용자 입력 > 순번 자동 생성 + let versionName = inputVersionName?.trim(); + if (!versionName) { + const countResult = await client.query( + `SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`, + [bomId], + ); + versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`; + } + + // 중복 체크 + const dupCheck = await client.query( + `SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`, + [bomId, versionName], + ); + if (dupCheck.rows.length > 0) { + throw new Error(`이미 존재하는 버전명입니다: ${versionName}`); } - const versionName = `${nextVersionNum}.0`; // 새 버전 레코드 생성 (snapshot_data 없이) const insertSql = ` @@ -249,6 +257,68 @@ export async function activateBomVersion(bomId: string, versionId: string, table }); } +/** + * 신규 BOM 초기화: 첫 번째 버전 자동 생성 + version_id null인 디테일 보정 + * BOM 헤더의 version 필드를 그대로 버전명으로 사용 (사용자 입력값 존중) + */ +export async function initializeBomVersion( + bomId: string, companyCode: string, createdBy: string, +) { + return transaction(async (client) => { + const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]); + if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); + const bomData = bomRow.rows[0]; + + if (bomData.current_version_id) { + await client.query( + `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, + [bomData.current_version_id, bomId], + ); + return { versionId: bomData.current_version_id, created: false }; + } + + // 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지) + const existingVersion = await client.query( + `SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`, + [bomId], + ); + if (existingVersion.rows.length > 0) { + const existId = existingVersion.rows[0].id; + await client.query( + `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, + [existId, bomId], + ); + await client.query( + `UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`, + [existId, bomId], + ); + return { versionId: existId, created: false }; + } + + const versionName = bomData.version || "1.0"; + + const versionResult = await client.query( + `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`, + [bomId, versionName, createdBy, companyCode], + ); + const versionId = versionResult.rows[0].id; + + const updated = await client.query( + `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, + [versionId, bomId], + ); + + await client.query( + `UPDATE bom SET current_version_id = $1 WHERE id = $2`, + [versionId, bomId], + ); + + logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount }); + return { versionId, versionName, created: true }; + }); +} + /** * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 */ diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index e4521ac0..75c1909b 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -86,6 +86,7 @@ interface ItemSearchModalProps { onClose: () => void; onSelect: (items: ItemInfo[]) => void; companyCode?: string; + existingItemIds?: Set; } function ItemSearchModal({ @@ -93,6 +94,7 @@ function ItemSearchModal({ onClose, onSelect, companyCode, + existingItemIds, }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); @@ -182,7 +184,7 @@ function ItemSearchModal({
) : (
- + - {items.map((item) => ( - { - setSelectedItems((prev) => { - const next = new Set(prev); - if (next.has(item.id)) next.delete(item.id); - else next.add(item.id); - return next; - }); - }} - className={cn( - "cursor-pointer border-t transition-colors", - selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", - )} - > - - - - - - - ))} + {items.map((item) => { + const alreadyAdded = existingItemIds?.has(item.id) || false; + return ( + { + if (alreadyAdded) return; + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); + }} + className={cn( + "border-t transition-colors", + alreadyAdded + ? "cursor-not-allowed opacity-40" + : "cursor-pointer", + !alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "", + )} + > + + + + + + + ); + })}
e.stopPropagation()}> - { - setSelectedItems((prev) => { - const next = new Set(prev); - if (checked) next.add(item.id); - else next.delete(item.id); - return next; - }); - }} - /> - - {item.item_number} - {item.item_name}{item.type}{item.unit}
e.stopPropagation()}> + { + if (alreadyAdded) return; + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }); + }} + /> + + {item.item_number} + {alreadyAdded && (추가됨)} + {item.item_name}{item.type}{item.unit}
)} @@ -739,37 +751,40 @@ export function BomItemEditorComponent({ [originalNotifyChange, markChanged], ); + const handleSaveAllRef = React.useRef<(() => Promise) | null>(null); + // EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장 useEffect(() => { if (isDesignMode || !bomId) return; const handler = (e: Event) => { const detail = (e as CustomEvent).detail; - console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", { - bomId, - treeDataLength: treeData.length, - hasRef: !!handleSaveAllRef.current, - }); - if (treeData.length > 0 && handleSaveAllRef.current) { + if (handleSaveAllRef.current) { const savePromise = handleSaveAllRef.current(); if (detail?.pendingPromises) { detail.pendingPromises.push(savePromise); - console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료"); } } }; window.addEventListener("beforeFormSave", handler); - console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode }); return () => window.removeEventListener("beforeFormSave", handler); - }, [isDesignMode, bomId, treeData.length]); - - const handleSaveAllRef = React.useRef<(() => Promise) | null>(null); + }, [isDesignMode, bomId]); const handleSaveAll = useCallback(async () => { if (!bomId) return; setSaving(true); try { - // 저장 시점에도 최신 version_id 조회 - const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; + // version_id 확보: 없으면 서버에서 자동 초기화 + let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; + if (!saveVersionId) { + try { + const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`); + if (initRes.data?.success && initRes.data.data?.versionId) { + saveVersionId = initRes.data.data.versionId; + } + } catch (e) { + console.error("[BomItemEditor] 버전 초기화 실패:", e); + } + } const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => { const result: any[] = []; @@ -1338,6 +1353,18 @@ export function BomItemEditorComponent({ onClose={() => setItemSearchOpen(false)} onSelect={handleItemSelect} companyCode={companyCode} + existingItemIds={useMemo(() => { + const ids = new Set(); + const collect = (nodes: BomItemNode[]) => { + for (const n of nodes) { + const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; + if (fk) ids.add(fk); + collect(n.children); + } + }; + collect(treeData); + return ids; + }, [treeData, cfg])} />
); diff --git a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx index cfff4a0c..6b5d4a40 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx @@ -13,6 +13,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Loader2 } from "lucide-react"; import { apiClient } from "@/lib/api/client"; @@ -35,6 +42,20 @@ export function BomDetailEditModal({ }: BomDetailEditModalProps) { const [formData, setFormData] = useState>({}); const [saving, setSaving] = useState(false); + const [processOptions, setProcessOptions] = useState<{ value: string; label: string }[]>([]); + + useEffect(() => { + if (open && !isRootNode) { + apiClient.get("/table-categories/bom_detail/process_type/values") + .then((res) => { + const values = res.data?.data || []; + if (values.length > 0) { + setProcessOptions(values.map((v: any) => ({ value: v.value_code, label: v.value_label }))); + } + }) + .catch(() => { /* 카테고리 없으면 빈 배열 유지 */ }); + } + }, [open, isRootNode]); useEffect(() => { if (node && open) { @@ -67,11 +88,15 @@ export function BomDetailEditModal({ try { const targetTable = isRootNode ? "bom" : tableName; const realId = isRootNode ? node.id?.replace("__root_", "") : node.id; - await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData); + await apiClient.put(`/table-management/tables/${targetTable}/edit`, { + originalData: { id: realId }, + updatedData: { id: realId, ...formData }, + }); onSaved?.(); onOpenChange(false); } catch (error) { console.error("[BomDetailEdit] 저장 실패:", error); + alert("저장 중 오류가 발생했습니다."); } finally { setSaving(false); } @@ -139,12 +164,28 @@ export function BomDetailEditModal({
- handleChange("process_type", e.target.value)} - placeholder="예: 조립공정" - className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" - /> + {processOptions.length > 0 ? ( + + ) : ( + handleChange("process_type", e.target.value)} + placeholder="예: 조립공정" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> + )}
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 957b8d85..5234a74d 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -138,6 +138,23 @@ export function BomTreeComponent({ const showHistory = features.showHistory !== false; const showVersion = features.showVersion !== false; + // 카테고리 라벨 캐시 (process_type 등) + const [categoryLabels, setCategoryLabels] = useState>>({}); + useEffect(() => { + const loadLabels = async () => { + try { + const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`); + const vals = res.data?.data || []; + if (vals.length > 0) { + const map: Record = {}; + vals.forEach((v: any) => { map[v.value_code] = v.value_label; }); + setCategoryLabels((prev) => ({ ...prev, process_type: map })); + } + } catch { /* 무시 */ } + }; + loadLabels(); + }, [detailTable]); + // ─── 데이터 로드 ─── // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 @@ -168,7 +185,18 @@ export function BomTreeComponent({ setLoading(true); try { const searchFilter: Record = { [foreignKey]: bomId }; - const versionId = headerData?.current_version_id; + let versionId = headerData?.current_version_id; + + // version_id가 없으면 서버에서 자동 초기화 + if (!versionId) { + try { + const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`); + if (initRes.data?.success && initRes.data.data?.versionId) { + versionId = initRes.data.data.versionId; + } + } catch { /* 무시 */ } + } + if (versionId) { searchFilter.version_id = versionId; } @@ -461,6 +489,11 @@ export function BomTreeComponent({ return {value || "-"}; } + if (col.key === "status") { + const statusMap: Record = { active: "사용", inactive: "미사용", developing: "개발중" }; + return {statusMap[String(value)] || value || "-"}; + } + if (col.key === "quantity" || col.key === "base_qty") { return ( @@ -469,6 +502,11 @@ export function BomTreeComponent({ ); } + if (col.key === "process_type" && value) { + const label = categoryLabels.process_type?.[String(value)] || String(value); + return {label}; + } + if (col.key === "loss_rate") { const num = Number(value); if (!num) return -; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx index d36bfe6e..48c27cc9 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx @@ -43,6 +43,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); const [actionId, setActionId] = useState(null); + const [newVersionName, setNewVersionName] = useState(""); + const [showNewInput, setShowNewInput] = useState(false); useEffect(() => { if (open && bomId) loadVersions(); @@ -63,11 +65,26 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve const handleCreateVersion = async () => { if (!bomId) return; + const trimmed = newVersionName.trim(); + if (!trimmed) { + alert("버전명을 입력해주세요."); + return; + } setCreating(true); try { - const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable }); - if (res.data?.success) loadVersions(); - } catch (error) { + const res = await apiClient.post(`/bom/${bomId}/versions`, { + tableName, detailTable, versionName: trimmed, + }); + if (res.data?.success) { + setNewVersionName(""); + setShowNewInput(false); + loadVersions(); + } else { + alert(res.data?.message || "버전 생성 실패"); + } + } catch (error: any) { + const msg = error.response?.data?.message || "버전 생성 실패"; + alert(msg); console.error("[BomVersion] 생성 실패:", error); } finally { setCreating(false); @@ -230,15 +247,46 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve )}
+ {showNewInput && ( +
+ setNewVersionName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()} + placeholder="버전명 입력 (예: 2.0, B, 개선판)" + className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm" + autoFocus + /> + + +
+ )} + - + {!showNewInput && ( + + )} + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+ + )} ); })} @@ -3429,6 +3465,10 @@ export const SplitPanelLayoutComponent: React.FC } // 🔧 일반 테이블 렌더링 (그룹화 없음) + const hasLeftTableActions = !isDesignMode && ( + (componentConfig.leftPanel?.showEdit !== false) || + (componentConfig.leftPanel?.showDelete !== false) + ); return (
@@ -3447,6 +3487,10 @@ export const SplitPanelLayoutComponent: React.FC {col.label} ))} + {hasLeftTableActions && ( + + )} @@ -3461,7 +3505,7 @@ export const SplitPanelLayoutComponent: React.FC handleLeftItemSelect(item)} - className={`hover:bg-accent cursor-pointer transition-colors ${ + className={`group hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" }`} > @@ -3479,6 +3523,34 @@ export const SplitPanelLayoutComponent: React.FC )} ))} + {hasLeftTableActions && ( + + )} ); })} -- 2.43.0 From 708a0fbd1f50856614520458c707da0d56ae30ed Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 20:55:15 +0900 Subject: [PATCH 09/23] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node -- 2.43.0 From bfc89501ba4e3182d30d8baf2eeb001750ccc498 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 07:33:54 +0900 Subject: [PATCH 10/23] feat: Enhance BOM and UI components with improved label handling and data mapping - Updated the BOM service to include additional fields in the BOM header retrieval, enhancing data richness. - Enhanced the EditModal to automatically map foreign key fields to dot notation, improving data handling and user experience. - Improved the rendering of labels in various components, allowing for customizable label positions and styles, enhancing UI flexibility. - Added new properties for label positioning and spacing in the V2 component styles, allowing for better layout control. - Enhanced the BomTreeComponent to support additional data mapping for entity joins, improving data accessibility and management. --- backend-node/src/services/bomService.ts | 5 +- frontend/components/screen/EditModal.tsx | 21 +++- .../EnhancedInteractiveScreenViewer.tsx | 53 ++++++--- .../screen/InteractiveScreenViewer.tsx | 53 +++++++-- .../screen/InteractiveScreenViewerDynamic.tsx | 62 +++++++++-- .../screen/panels/V2PropertiesPanel.tsx | 59 ++++++++-- frontend/components/v2/V2Date.tsx | 71 ++++++++---- frontend/components/v2/V2Input.tsx | 101 ++++++++++-------- frontend/components/v2/V2Select.tsx | 94 ++++++++++------ .../v2-bom-tree/BomDetailEditModal.tsx | 20 ++-- .../v2-bom-tree/BomTreeComponent.tsx | 13 +++ frontend/types/v2-core.ts | 2 + 12 files changed, 409 insertions(+), 145 deletions(-) diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index b5cff246..f1d6fd84 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -59,7 +59,10 @@ export async function getBomHeader(bomId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom"); const sql = ` SELECT b.*, - i.item_name, i.item_number, i.division as item_type, i.unit + i.item_name, i.item_number, i.division as item_type, + COALESCE(b.unit, i.unit) as unit, + i.unit as item_unit, + i.division, i.size, i.material FROM ${table} b LEFT JOIN item_info i ON b.item_id = i.id WHERE b.id = $1 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 8dad77db..41c51d85 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -274,7 +274,26 @@ export const EditModal: React.FC = ({ className }) => { }); // 편집 데이터로 폼 데이터 초기화 - setFormData(editData || {}); + // entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑 + const enriched = { ...(editData || {}) }; + if (editData) { + Object.keys(editData).forEach((key) => { + // item_id_item_name → item_info.item_name 패턴 변환 + const match = key.match(/^(.+?)_([a-z_]+)$/); + if (match && editData[key] != null) { + const [, fkCol, fieldName] = match; + // FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info) + if (fkCol.endsWith("_id")) { + const refTable = fkCol.replace(/_id$/, "_info"); + const dotKey = `${refTable}.${fieldName}`; + if (!(dotKey in enriched)) { + enriched[dotKey] = editData[key]; + } + } + } + }); + } + setFormData(enriched); // originalData: changedData 계산(PATCH)에만 사용 // INSERT/UPDATE 판단에는 사용하지 않음 setOriginalData(isCreateMode ? {} : editData || {}); diff --git a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx index f1acae0b..d0a99d91 100644 --- a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx +++ b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx @@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC { if (hideLabel) return null; - const labelStyle = widget.style || {}; + const ls = widget.style || {}; const labelElement = (
+
+
+ {(componentConfig.leftPanel?.showEdit !== false) && ( + + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+
+ + + + + + + + + + + + + + + {parsedRows.map((row) => ( + + + + + + + + + + + + ))} + +
#구분레벨품번품명소요량단위공정비고
{row.rowIndex} + {row.isHeader ? ( + + {isVersionMode ? "건너뜀" : "마스터"} + + ) : row.valid ? ( + + ) : ( + + + + )} + + + {row.level} + + {row.item_number}{row.item_name}{row.quantity}{row.unit}{row.process_type}{row.remark}
+
+ + {invalidCount > 0 && ( +
+
유효하지 않은 행 ({invalidCount}건)
+
    + {parsedRows.filter(r => !r.valid).slice(0, 5).map(r => ( +
  • {r.rowIndex}행: {r.error}
  • + ))} + {invalidCount > 5 &&
  • ...외 {invalidCount - 5}건
  • } +
+
+ )} + +
+ {isVersionMode + ? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다." + : "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다." + } +
+
+ )} + + {/* Step 3: 결과 */} + {step === "result" && uploadResult && ( +
+
+
+ +
+

+ {isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"} +

+

+ 하위품목 {uploadResult.insertedCount}건이 등록되었습니다. +

+
+ +
+ {!isVersionMode && ( +
+
1
+
BOM 마스터
+
+ )} +
+
{uploadResult.insertedCount}
+
하위품목
+
+
+
+ )} + + + {step === "upload" && ( + + )} + {step === "preview" && ( + <> + + + + )} + {step === "result" && ( + + )} + + + + ); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 5234a74d..1aede9de 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -14,6 +14,7 @@ import { History, GitBranch, Check, + FileSpreadsheet, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { BomDetailEditModal } from "./BomDetailEditModal"; import { BomHistoryModal } from "./BomHistoryModal"; import { BomVersionModal } from "./BomVersionModal"; +import { BomExcelUploadModal } from "./BomExcelUploadModal"; interface BomTreeNode { id: string; @@ -77,6 +79,7 @@ export function BomTreeComponent({ const [editTargetNode, setEditTargetNode] = useState(null); const [historyModalOpen, setHistoryModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false); + const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [colWidths, setColWidths] = useState>({}); const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => { @@ -824,6 +827,15 @@ export function BomTreeComponent({ 버전 )} +
); } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 427f2da5..5a839620 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -20,6 +20,7 @@ import { Trash2, Settings, Move, + FileSpreadsheet, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { PanelInlineComponent } from "./types"; import { cn } from "@/lib/utils"; +import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props @@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); + const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false); // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); @@ -3010,12 +3013,20 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.leftPanel?.title || "좌측 패널"} - {!isDesignMode && componentConfig.leftPanel?.showAdd && ( - - )} +
+ {!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && ( + + )} + {!isDesignMode && componentConfig.leftPanel?.showAdd && ( + + )} +
{componentConfig.leftPanel?.showSearch && ( @@ -5070,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC + + {(componentConfig.leftPanel as any)?.showBomExcelUpload && ( + { + loadLeftData(); + }} + /> + )}
); }; -- 2.43.0 From 4e997ae36b37a8ce3a35d6473de257a934e9c480 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 08:48:21 +0900 Subject: [PATCH 12/23] feat: Enhance V2Select component with automatic value normalization and update handling - Implemented automatic normalization of legacy plain text values to category codes, improving data consistency. - Added logic to handle comma-separated values, allowing for better processing of complex input formats. - Integrated automatic updates to the onChange handler when the normalized value differs from the original, ensuring accurate data saving. - Updated various select components to utilize the resolved value for consistent behavior across different selection types. --- frontend/components/v2/V2Select.tsx | 52 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index bd81c3ca..a7769227 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -820,6 +820,42 @@ export const V2Select = forwardRef( loadOptions(); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + // 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응) + const resolvedValue = useMemo(() => { + if (!value || options.length === 0) return value; + + const resolveOne = (v: string): string => { + if (options.some(o => o.value === v)) return v; + const trimmed = v.trim(); + const match = options.find(o => { + const cleanLabel = o.label.replace(/^[\s└]+/, '').trim(); + return cleanLabel === trimmed; + }); + return match ? match.value : v; + }; + + if (Array.isArray(value)) { + const resolved = value.map(resolveOne); + return resolved.every((v, i) => v === value[i]) ? value : resolved; + } + + // 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx") + if (typeof value === "string" && value.includes(",")) { + const parts = value.split(","); + const resolved = parts.map(p => resolveOne(p.trim())); + const result = resolved.join(","); + return result === value ? value : result; + } + + return resolveOne(value); + }, [value, options]); + + // 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환) + useEffect(() => { + if (!onChange || options.length === 0 || !value || value === resolvedValue) return; + onChange(resolvedValue as string | string[]); + }, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps + // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 const autoFillTargets = useMemo(() => { if (source !== "entity" || !entityTable || !allComponents) return []; @@ -945,7 +981,7 @@ export const V2Select = forwardRef( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -972,7 +1008,7 @@ export const V2Select = forwardRef( return ( ( return ( ( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -1017,7 +1053,7 @@ export const V2Select = forwardRef( return ( ( return ( Date: Fri, 27 Feb 2026 11:01:22 +0900 Subject: [PATCH 13/23] refactor: Hide selected rows information in TableListComponent - Removed the display of selected rows count and the deselect button from the TableListComponent. - Updated the comment to indicate that the selected information is now hidden, improving code clarity and maintainability. --- .../lib/registry/DynamicComponentRenderer.tsx | 3 ++- .../v2-table-list/TableListComponent.tsx | 24 ++++++------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 72af2a34..88eaf946 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC = // 1. componentType이 "select-basic" 또는 "v2-select"인 경우 // 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등) const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode; + const isMultipleSelect = (component as any).componentConfig?.multiple; const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"]; const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode); - const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode; + const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect; if ( (inputType === "category" || webType === "category") && diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 4170360d..717ea6ef 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2243,6 +2243,12 @@ export const TableListComponent: React.FC = ({ // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); + + // 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지) + if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) { + return; + } + setFocusedCell({ rowIndex, colIndex }); tableContainerRef.current?.focus(); @@ -5462,23 +5468,7 @@ export const TableListComponent: React.FC = ({ )} - {/* 선택 정보 */} - {selectedRows.size > 0 && ( -
- - {selectedRows.size}개 선택됨 - - -
- )} + {/* 선택 정보 - 숨김 처리 */} {/* 🆕 통합 검색 패널 */} {(tableConfig.toolbar?.showSearch ?? false) && ( -- 2.43.0 From 0f52c3adc28b7619ace1b68bdb9261ef1a9c0fef Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 11:46:43 +0900 Subject: [PATCH 14/23] refactor: Improve V2Repeater integration and event handling - Updated the EditModal component to check for registered V2Repeater instances before saving detail data, enhancing the reliability of the repeater save process. - Simplified the V2Repeater component by removing unnecessary groupedData handling, ensuring it manages its own data effectively. - Enhanced the DynamicComponentRenderer to correctly handle V2Repeater's data management, improving overall component behavior. - Refactored button actions to wait for V2Repeater save completion only when active repeaters are present, optimizing performance and user experience. --- frontend/components/screen/EditModal.tsx | 114 +++++++++--------- frontend/components/v2/V2Repeater.tsx | 104 ++-------------- .../lib/registry/DynamicComponentRenderer.tsx | 6 +- frontend/lib/utils/buttonActions.ts | 93 +++++++------- 4 files changed, 118 insertions(+), 199 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 442c51cb..1f4d4dcc 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -1202,38 +1202,35 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } - // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) - try { - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만) + const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; + if (hasRepeaterForInsert) { + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", { - parentId: masterRecordId, - tableName: screenData.screenInfo.tableName, - }); + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId, + }, + }), + ); - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: masterRecordId, - tableName: screenData.screenInfo.tableName, - mainFormData: formData, - masterRecordId, - }, - }), - ); - - await repeaterSavePromise; - console.log("✅ [EditModal] INSERT 후 repeaterSave 완료"); - } catch (repeaterError) { - console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + await repeaterSavePromise; + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } } handleClose(); @@ -1332,38 +1329,35 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } - // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) - try { - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만) + const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; + if (hasRepeaterForUpdate) { + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", { - parentId: recordId, - tableName: screenData.screenInfo.tableName, - }); + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId: recordId, + }, + }), + ); - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: recordId, - tableName: screenData.screenInfo.tableName, - mainFormData: formData, - masterRecordId: recordId, - }, - }), - ); - - await repeaterSavePromise; - console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료"); - } catch (repeaterError) { - console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + await repeaterSavePromise; + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } } // 리피터 저장 완료 후 메인 테이블 새로고침 diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 8b769b56..b60617e6 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -50,9 +50,6 @@ export const V2Repeater: React.FC = ({ formData: parentFormData, ...restProps }) => { - // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) - const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; - // componentId 결정: 직접 전달 또는 component 객체에서 추출 const effectiveComponentId = componentId || (restProps as any).component?.id; @@ -214,21 +211,20 @@ export const V2Repeater: React.FC = ({ const isModalMode = config.renderMode === "modal"; // 전역 리피터 등록 - // 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) + // tableName이 비어있어도 반드시 등록 (repeaterSave 이벤트 발행 가드에 필요) useEffect(() => { const targetTableName = config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + const registrationKey = targetTableName || "__v2_repeater_same_table__"; - if (targetTableName) { - if (!window.__v2RepeaterInstances) { - window.__v2RepeaterInstances = new Set(); - } - window.__v2RepeaterInstances.add(targetTableName); + if (!window.__v2RepeaterInstances) { + window.__v2RepeaterInstances = new Set(); } + window.__v2RepeaterInstances.add(registrationKey); return () => { - if (targetTableName && window.__v2RepeaterInstances) { - window.__v2RepeaterInstances.delete(targetTableName); + if (window.__v2RepeaterInstances) { + window.__v2RepeaterInstances.delete(registrationKey); } }; }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); @@ -968,90 +964,8 @@ export const V2Repeater: React.FC = ({ [], ); - // 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함) - const groupedDataProcessedRef = useRef(false); - useEffect(() => { - if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return; - if (groupedDataProcessedRef.current) return; - - groupedDataProcessedRef.current = true; - - const newRows = groupedData.map((item: any, index: number) => { - const row: any = { _id: `grouped_${Date.now()}_${index}` }; - - for (const col of config.columns) { - let sourceValue = item[(col as any).sourceKey || col.key]; - - // 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반) - if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) { - sourceValue = categoryLabelMap[sourceValue]; - } - - if (col.isSourceDisplay) { - row[col.key] = sourceValue ?? ""; - row[`_display_${col.key}`] = sourceValue ?? ""; - } else if (col.autoFill && col.autoFill.type !== "none") { - const autoValue = generateAutoFillValueSync(col, index, parentFormData); - if (autoValue !== undefined) { - row[col.key] = autoValue; - } else { - row[col.key] = ""; - } - } else if (sourceValue !== undefined) { - row[col.key] = sourceValue; - } else { - row[col.key] = ""; - } - } - return row; - }); - - // 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관) - const categoryColSet = new Set(allCategoryColumns); - const codesToResolve = new Set(); - for (const row of newRows) { - for (const col of config.columns) { - const val = row[col.key] || row[`_display_${col.key}`]; - if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) { - if (!categoryLabelMap[val]) { - codesToResolve.add(val); - } - } - } - } - - if (codesToResolve.size > 0) { - apiClient.post("/table-categories/labels-by-codes", { - valueCodes: Array.from(codesToResolve), - }).then((resp) => { - if (resp.data?.success && resp.data.data) { - const labelData = resp.data.data as Record; - setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); - const convertedRows = newRows.map((row) => { - const updated = { ...row }; - for (const col of config.columns) { - const val = updated[col.key]; - if (typeof val === "string" && labelData[val]) { - updated[col.key] = labelData[val]; - } - const dispKey = `_display_${col.key}`; - const dispVal = updated[dispKey]; - if (typeof dispVal === "string" && labelData[dispVal]) { - updated[dispKey] = labelData[dispVal]; - } - } - return updated; - }); - setData(convertedRows); - onDataChange?.(convertedRows); - } - }).catch(() => {}); - } - - setData(newRows); - onDataChange?.(newRows); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [groupedData, config.columns, generateAutoFillValueSync]); + // V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. + // EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 useEffect(() => { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 88eaf946..85532c36 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -546,10 +546,12 @@ export const DynamicComponentRenderer: React.FC = let currentValue; if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal" || - componentType === "selected-items-detail-input" || - componentType === "v2-repeater") { + componentType === "selected-items-detail-input") { // EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용 currentValue = props.groupedData || formData?.[fieldName] || []; + } else if (componentType === "v2-repeater") { + // V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음) + currentValue = formData?.[fieldName] || []; } else { currentValue = formData?.[fieldName] || ""; } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 390404ce..7f8514ab 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1893,29 +1893,34 @@ export class ButtonActionExecutor { mainFormDataKeys: Object.keys(mainFormData), }); - // V2Repeater 저장 완료를 기다리기 위한 Promise - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // V2Repeater가 등록된 경우에만 저장 완료를 기다림 + // @ts-ignore + const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: savedId, - tableName: context.tableName, - mainFormData, - masterRecordId: savedId, - }, - }), - ); + if (hasActiveRepeaters) { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - await repeaterSavePromise; + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: savedId, + tableName: context.tableName, + mainFormData, + masterRecordId: savedId, + }, + }), + ); + + await repeaterSavePromise; + } // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); @@ -1951,29 +1956,33 @@ export class ButtonActionExecutor { formDataKeys: Object.keys(formData), }); - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // @ts-ignore + const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: savedId, - tableName: context.tableName, - mainFormData: formData, - masterRecordId: savedId, - }, - }), - ); + if (hasActiveRepeaters) { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - await repeaterSavePromise; - console.log("✅ [dispatchRepeaterSave] repeaterSave 완료"); + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: savedId, + tableName: context.tableName, + mainFormData: formData, + masterRecordId: savedId, + }, + }), + ); + + await repeaterSavePromise; + } } /** -- 2.43.0 From d686c385e00b891fc84261aae4e58ae8b70a03e6 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 11:57:21 +0900 Subject: [PATCH 15/23] feat: Implement edit mode detection in SelectedItemsDetailInputComponent - Added logic to detect edit mode based on URL parameters and existing data IDs. - Enhanced value retrieval for form fields to prioritize original data in edit mode, ensuring accurate updates. - Removed redundant edit mode detection comments to streamline the code and improve clarity. --- .../SelectedItemsDetailInputComponent.tsx | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c2be4bb4..ff94b8dc 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -568,6 +568,12 @@ export const SelectedItemsDetailInputComponent: React.FC !!item.originalData?.id); + const isEditMode = urlEditMode || dataHasDbId; + // 부모 키 추출 (parentDataMapping에서) const parentKeys: Record = {}; @@ -581,16 +587,25 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 1차: formData(sourceData)에서 찾기 - let value = getFieldValue(sourceData, mapping.sourceField); + let value: any; - // 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기 - // v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용 - if ((value === undefined || value === null) && mapping.sourceTable) { - const registryData = dataRegistry[mapping.sourceTable]; - if (registryData && registryData.length > 0) { - const registryItem = registryData[0].originalData || registryData[0]; - value = registryItem[mapping.sourceField]; + // 수정 모드: originalData의 targetField 값 우선 사용 + // 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야 + // 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능 + if (isEditMode && items.length > 0 && items[0].originalData) { + value = items[0].originalData[mapping.targetField]; + } + + // 신규 모드 또는 originalData에 값 없으면 기존 로직 + if (value === undefined || value === null) { + value = getFieldValue(sourceData, mapping.sourceField); + + if ((value === undefined || value === null) && mapping.sourceTable) { + const registryData = dataRegistry[mapping.sourceTable]; + if (registryData && registryData.length > 0) { + const registryItem = registryData[0].originalData || registryData[0]; + value = registryItem[mapping.sourceField]; + } } } @@ -646,15 +661,6 @@ export const SelectedItemsDetailInputComponent: React.FC !!item.originalData?.id); - const isEditMode = urlEditMode || dataHasDbId; - console.log("[SelectedItemsDetailInput] 수정 모드 감지:", { urlEditMode, dataHasDbId, -- 2.43.0 From c1f7f27005952d0f950a872fb005dd7be7c7fbb1 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 12:06:49 +0900 Subject: [PATCH 16/23] fix: Improve option filtering in V2Select component - Updated the option filtering logic to handle null and undefined values, preventing potential crashes when cmdk encounters these values. - Introduced a safeOptions variable to ensure that only valid options are processed in the dropdown and command list. - Enhanced the setOptions function to sanitize fetched options, ensuring that only valid values are set, improving overall stability and user experience. --- frontend/components/v2/V2Select.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index f0021eeb..bdc1ae24 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -80,7 +80,7 @@ const DropdownSelect = forwardRef {options - .filter((option) => option.value !== "") + .filter((option) => option.value != null && option.value !== "") .map((option) => ( {option.label} @@ -112,6 +112,12 @@ const DropdownSelect = forwardRef + options.filter((o) => o.value != null && o.value !== ""), + [options] + ); + const selectedValues = useMemo(() => { if (!value) return []; return Array.isArray(value) ? value : [value]; @@ -119,9 +125,9 @@ const DropdownSelect = forwardRef { return selectedValues - .map((v) => options.find((o) => o.value === v)?.label) + .map((v) => safeOptions.find((o) => o.value === v)?.label) .filter(Boolean) as string[]; - }, [selectedValues, options]); + }, [selectedValues, safeOptions]); const handleSelect = useCallback((selectedValue: string) => { if (multiple) { @@ -191,7 +197,7 @@ const DropdownSelect = forwardRef { if (!search) return 1; - const option = options.find((o) => o.value === itemValue); + const option = safeOptions.find((o) => o.value === itemValue); const label = (option?.label || option?.value || "").toLowerCase(); if (label.includes(search.toLowerCase())) return 1; return 0; @@ -201,7 +207,7 @@ const DropdownSelect = forwardRef 검색 결과가 없습니다. - {options.map((option) => { + {safeOptions.map((option) => { const displayLabel = option.label || option.value || "(빈 값)"; return ( ( } } - setOptions(fetchedOptions); + // null/undefined value 필터링 (cmdk 크래시 방지) + const sanitized = fetchedOptions.filter( + (o) => o.value != null && String(o.value) !== "" + ).map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) })); + setOptions(sanitized); setOptionsLoaded(true); } catch (error) { console.error("옵션 로딩 실패:", error); -- 2.43.0 From 8bfc2ba4f57f2d7dfb7eb212d3351ea8c1986b72 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 13:00:22 +0900 Subject: [PATCH 17/23] feat: Enhance dynamic form service to handle VIEW tables - Introduced a new method `resolveBaseTable` to determine the original table name for VIEWs, allowing for seamless data operations. - Updated existing methods (`saveFormData`, `updateFormDataPartial`, `updateFormData`, and `deleteFormData`) to utilize `resolveBaseTable`, ensuring that operations are performed on the correct base table. - Improved logging to provide clearer insights into the operations being performed, including handling of original table names when dealing with VIEWs. --- .../src/services/dynamicFormService.ts | 71 ++++++++++++++++--- .../components/common/ExcelUploadModal.tsx | 45 ++++++++---- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e1242afd..7383e02b 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -210,19 +210,62 @@ export class DynamicFormService { } } + /** + * VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환 + */ + async resolveBaseTable(tableName: string): Promise { + try { + const result = await query<{ table_type: string }>( + `SELECT table_type FROM information_schema.tables + WHERE table_name = $1 AND table_schema = 'public'`, + [tableName] + ); + + if (result.length === 0 || result[0].table_type !== 'VIEW') { + return tableName; + } + + // VIEW의 FROM 절에서 첫 번째 테이블을 추출 + const viewDef = await query<{ view_definition: string }>( + `SELECT view_definition FROM information_schema.views + WHERE table_name = $1 AND table_schema = 'public'`, + [tableName] + ); + + if (viewDef.length > 0) { + const definition = viewDef[0].view_definition; + // PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장 + const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i); + if (fromMatch) { + const baseTable = fromMatch[1]; + console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`); + return baseTable; + } + } + + return tableName; + } catch (error) { + console.error(`❌ VIEW 원본 테이블 조회 실패:`, error); + return tableName; + } + } + /** * 폼 데이터 저장 (실제 테이블에 직접 저장) */ async saveFormData( screenId: number, - tableName: string, + tableNameInput: string, data: Record, ipAddress?: string ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { screenId, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, data, }); @@ -813,14 +856,17 @@ export class DynamicFormService { */ async updateFormDataPartial( id: string | number, // 🔧 UUID 문자열도 지원 - tableName: string, + tableNameInput: string, originalData: Record, newData: Record ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("🔄 서비스: 부분 업데이트 시작:", { id, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, originalData, newData, }); @@ -1008,13 +1054,16 @@ export class DynamicFormService { */ async updateFormData( id: string | number, - tableName: string, + tableNameInput: string, data: Record ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { id, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, data, }); @@ -1212,9 +1261,13 @@ export class DynamicFormService { screenId?: number ): Promise { try { + // VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로) + const actualTable = await this.resolveBaseTable(tableName); + console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { id, - tableName, + tableName: actualTable, + originalTable: tableName !== actualTable ? tableName : undefined, }); // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 @@ -1232,15 +1285,15 @@ export class DynamicFormService { `; console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); - console.log("🔍 테이블명:", tableName); + console.log("🔍 테이블명:", actualTable); const primaryKeyResult = await query<{ column_name: string; data_type: string; - }>(primaryKeyQuery, [tableName]); + }>(primaryKeyQuery, [actualTable]); if (!primaryKeyResult || primaryKeyResult.length === 0) { - throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); + throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`); } const primaryKeyInfo = primaryKeyResult[0]; @@ -1272,7 +1325,7 @@ export class DynamicFormService { // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 const deleteQuery = ` - DELETE FROM ${tableName} + DELETE FROM ${actualTable} WHERE ${primaryKeyColumn} = $1${typeCastSuffix} RETURNING * `; @@ -1292,7 +1345,7 @@ export class DynamicFormService { // 삭제된 행이 없으면 레코드를 찾을 수 없는 것 if (!result || !Array.isArray(result) || result.length === 0) { - throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); + throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); } console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 81b5ed61..f3c2ff2d 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC = ({ } } - for (const row of filteredData) { + for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) { + const row = filteredData[rowIdx]; try { let dataToSave = { ...row }; let shouldSkip = false; @@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC = ({ if (existingDataMap.has(key)) { existingRow = existingDataMap.get(key); - // 중복 발견 - 전역 설정에 따라 처리 if (duplicateAction === "skip") { shouldSkip = true; skipCount++; - console.log(`⏭️ 중복으로 건너뛰기: ${key}`); + console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`); } else { shouldUpdate = true; - console.log(`🔄 중복으로 덮어쓰기: ${key}`); + console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`); } + } else { + console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`); } } @@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC = ({ } // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 - if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { + if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) { const existingValue = dataToSave[numberingInfo.columnName]; const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; @@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC = ({ tableName, data: dataToSave, }; + console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave); const result = await DynamicFormApi.updateFormData(existingRow.id, formData); if (result.success) { overwriteCount++; successCount++; } else { + console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message); failCount++; } - } else if (uploadMode === "insert") { - // 신규 등록 + } else if (uploadMode === "insert" || uploadMode === "upsert") { + // 신규 등록 (insert, upsert 모드) const formData = { screenId: 0, tableName, data: dataToSave }; + console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave); const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; + console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`); } else { + console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message); failCount++; } + } else if (uploadMode === "update") { + // update 모드에서 기존 데이터가 없는 행은 건너뛰기 + console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`); + skipCount++; } - } catch (error) { + } catch (error: any) { + console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error); failCount++; } } @@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC = ({ } } + console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`); + if (successCount > 0 || skipCount > 0) { - // 상세 결과 메시지 생성 let message = ""; if (successCount > 0) { message += `${successCount}개 행 업로드`; @@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC = ({ message += `중복 건너뛰기 ${skipCount}개`; } if (failCount > 0) { - message += ` (실패: ${failCount}개)`; + message += `, 실패 ${failCount}개`; } - toast.success(message); + if (failCount > 0 && successCount === 0) { + toast.warning(message); + } else { + toast.success(message); + } // 매핑 템플릿 저장 await saveMappingTemplateInternal(); - onSuccess?.(); + if (successCount > 0 || overwriteCount > 0) { + onSuccess?.(); + } + } else if (failCount > 0) { + toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`); } else { toast.error("업로드에 실패했습니다."); } -- 2.43.0 From 649bd77bbb8c10709d88dfb5740d77dac0eb3b36 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 13:09:20 +0900 Subject: [PATCH 18/23] feat: Enhance dynamic form and BOM item editor functionality - Added support for updating the `updated_date` field in the DynamicFormService, ensuring accurate timestamp management. - Refactored the BomItemEditorComponent to improve data handling by filtering valid fields before saving, enhancing data integrity. - Introduced a mechanism to track existing item IDs to prevent duplicates during item addition, improving user experience and data consistency. - Streamlined the save process in ButtonActionExecutor by reorganizing the event handling logic, ensuring better integration with EditModal components. --- .../src/services/dynamicFormService.ts | 3 + bom-save-console-logs.txt | 271 ++++++++++++++++++ .../BomItemEditorComponent.tsx | 52 ++-- frontend/lib/utils/buttonActions.ts | 42 +-- 4 files changed, 315 insertions(+), 53 deletions(-) create mode 100644 bom-save-console-logs.txt diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e1242afd..4c24e206 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1033,6 +1033,9 @@ export class DynamicFormService { if (tableColumns.includes("updated_at")) { dataToUpdate.updated_at = new Date(); } + if (tableColumns.includes("updated_date")) { + dataToUpdate.updated_date = new Date(); + } if (tableColumns.includes("regdate") && !dataToUpdate.regdate) { dataToUpdate.regdate = new Date(); } diff --git a/bom-save-console-logs.txt b/bom-save-console-logs.txt new file mode 100644 index 00000000..f962f536 --- /dev/null +++ b/bom-save-console-logs.txt @@ -0,0 +1,271 @@ +[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[warning] Image with src "/images/vexplor.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. +[log] 첫 번째 접근 가능한 메뉴로 이동: /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active} +[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active} +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404} +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404} +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404} +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404} +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404} +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404} +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null +[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object} +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 📦 [SplitPanelLayout] Context에서 분할 패널 해제: split-panel-comp_split_panel +[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object} +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드} +[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] ✅ 분할 패널 좌측 선택: bom {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔴 [ButtonPrimary] 저장 시 formData 디버그: {propsFormDataKeys: Array(70), screenContextFormDataKeys: Array(0), effectiveFormDataKeys: Array(70), process_code: undefined, equipment_code: undefined} +[log] [BomTree] openEditModal 가로채기 - editData 보정 {oldVersion: 1.0, newVersion: 1.0, oldCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd, newCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd} +[log] 🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침 +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] [EditModal] 모달 열림: {mode: UPDATE (수정), hasEditData: true, editDataId: 64617576-fec9-4caa-8e72-653f9e83ba45, isCreateMode: false} +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] [EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작: 4154 +[log] [EditModal] loadConditionalLayersAndZones 호출됨: 4154 +[log] [EditModal] API 호출 시작: getScreenLayers, getScreenZones +[log] [EditModal] API 응답: {layers: 1, zones: 0} +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 \ No newline at end of file diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 75c1909b..fcb7b710 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -812,7 +812,7 @@ export function BomItemEditorComponent({ : null; if (node._isNew) { - const payload: Record = { + const raw: Record = { ...node.data, [fkColumn]: bomId, [parentKeyColumn]: realParentId, @@ -821,10 +821,16 @@ export function BomItemEditorComponent({ company_code: companyCode || undefined, version_id: saveVersionId || undefined, }; - delete payload.id; - delete payload.tempId; - delete payload._isNew; - delete payload._isDeleted; + // bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거) + const payload: Record = {}; + const validKeys = new Set([ + fkColumn, parentKeyColumn, "seq_no", "level", "child_item_id", + "quantity", "unit", "loss_rate", "remark", "process_type", + "base_qty", "revision", "version_id", "company_code", "writer", + ]); + Object.keys(raw).forEach((k) => { + if (validKeys.has(k)) payload[k] = raw[k]; + }); const resp = await apiClient.post( `/table-management/tables/${mainTableName}/add`, @@ -835,17 +841,14 @@ export function BomItemEditorComponent({ savedCount++; } else if (node.id) { const updatedData: Record = { - ...node.data, id: node.id, + [fkColumn]: bomId, [parentKeyColumn]: realParentId, seq_no: String(seqNo), level: String(level), }; - delete updatedData.tempId; - delete updatedData._isNew; - delete updatedData._isDeleted; - Object.keys(updatedData).forEach((k) => { - if (k.startsWith(`${sourceFk}_`)) delete updatedData[k]; + ["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => { + if (node.data[k] !== undefined) updatedData[k] = node.data[k]; }); await apiClient.put( @@ -934,6 +937,20 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); + // 이미 추가된 품목 ID 목록 (중복 방지용) + const existingItemIds = useMemo(() => { + const ids = new Set(); + const collect = (nodes: BomItemNode[]) => { + for (const n of nodes) { + const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; + if (fk) ids.add(fk); + collect(n.children); + } + }; + collect(treeData); + return ids; + }, [treeData, cfg]); + // 루트 품목 추가 시작 const handleAddRoot = useCallback(() => { setAddTargetParentId(null); @@ -1353,18 +1370,7 @@ export function BomItemEditorComponent({ onClose={() => setItemSearchOpen(false)} onSelect={handleItemSelect} companyCode={companyCode} - existingItemIds={useMemo(() => { - const ids = new Set(); - const collect = (nodes: BomItemNode[]) => { - for (const n of nodes) { - const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; - if (fk) ids.add(fk); - collect(n.children); - } - }; - collect(treeData); - return ids; - }, [treeData, cfg])} + existingItemIds={existingItemIds} /> ); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 7f8514ab..054b257f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -558,31 +558,7 @@ export class ButtonActionExecutor { return false; } - // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 - // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림 - if (onSave) { - try { - await onSave(); - return true; - } catch (error) { - console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); - throw error; - } - } - - console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); - - // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) - // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 - // skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음 - - // 🔧 디버그: beforeFormSave 이벤트 전 formData 확인 - console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", { - keys: Object.keys(context.formData || {}), - hasCompanyImage: "company_image" in (context.formData || {}), - companyImageValue: context.formData?.company_image, - }); - + // beforeFormSave 이벤트 발송 (BomItemEditor 등 서브 컴포넌트의 저장 처리) const beforeSaveEventDetail = { formData: context.formData, skipDefaultSave: false, @@ -596,22 +572,28 @@ export class ButtonActionExecutor { }), ); - // 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기 if (beforeSaveEventDetail.pendingPromises.length > 0) { - console.log( - `[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`, - ); await Promise.all(beforeSaveEventDetail.pendingPromises); } else { await new Promise((resolve) => setTimeout(resolve, 100)); } - // 검증 실패 시 저장 중단 if (beforeSaveEventDetail.validationFailed) { console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); return false; } + // EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 + if (onSave) { + try { + await onSave(); + return true; + } catch (error) { + console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); + throw error; + } + } + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림 -- 2.43.0 From 1a6d78df43ab75f1c545060df84aa2c93405ab03 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 13:30:57 +0900 Subject: [PATCH 19/23] refactor: Improve existing item ID handling in BomItemEditorComponent - Updated the logic for tracking existing item IDs to prevent duplicates during item addition, ensuring that sibling items are checked for duplicates at the same level while allowing duplicates in child levels. - Enhanced the existingItemIds calculation to differentiate between root level and child level additions, improving data integrity and user experience. - Refactored the useMemo hook to include addTargetParentId as a dependency, ensuring accurate updates when the target parent ID changes. --- .../BomItemEditorComponent.tsx | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index fcb7b710..bd5f3d92 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -937,19 +937,38 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); - // 이미 추가된 품목 ID 목록 (중복 방지용) + // 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용) const existingItemIds = useMemo(() => { const ids = new Set(); - const collect = (nodes: BomItemNode[]) => { - for (const n of nodes) { - const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; + const fkField = cfg.dataSource?.foreignKey || "child_item_id"; + + if (addTargetParentId === null) { + // 루트 레벨 추가: 루트 노드의 형제들만 체크 + for (const n of treeData) { + const fk = n.data[fkField]; if (fk) ids.add(fk); - collect(n.children); } - }; - collect(treeData); + } else { + // 하위 추가: 해당 부모의 직속 자식들만 체크 + const findParent = (nodes: BomItemNode[]): BomItemNode | null => { + for (const n of nodes) { + if (n.tempId === addTargetParentId) return n; + const found = findParent(n.children); + if (found) return found; + } + return null; + }; + const parent = findParent(treeData); + if (parent) { + for (const child of parent.children) { + const fk = child.data[fkField]; + if (fk) ids.add(fk); + } + } + } + return ids; - }, [treeData, cfg]); + }, [treeData, cfg, addTargetParentId]); // 루트 품목 추가 시작 const handleAddRoot = useCallback(() => { -- 2.43.0 From 21c0c2b95c347e48870e43977f37d550bd43e3be Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 14:00:06 +0900 Subject: [PATCH 20/23] fix: Enhance layout loading logic in screen management - Updated the ScreenManagementService to allow SUPER_ADMIN or users with companyCode as "*" to load layouts based on the screen's company code. - Improved layout loading in ScreenViewPage and EditModal components by implementing fallback mechanisms to ensure a valid layout is always set. - Added console warnings for better debugging when layout loading fails, enhancing error visibility and user experience. - Refactored label display logic in various components to ensure consistent behavior across input types. --- .../src/services/screenManagementService.ts | 4 +- .../app/(main)/screens/[screenId]/page.tsx | 20 ++- frontend/components/screen/EditModal.tsx | 23 +++- .../screen/InteractiveScreenViewer.tsx | 7 +- .../screen/InteractiveScreenViewerDynamic.tsx | 2 +- frontend/components/v2/V2Date.tsx | 2 +- frontend/components/v2/V2Input.tsx | 2 +- frontend/components/v2/V2Select.tsx | 2 +- .../table-list/TableListConfigPanel.tsx | 124 +++++++++++++++++- .../v2-table-list/TableListConfigPanel.tsx | 124 ++++++++++++++++++ frontend/lib/utils/buttonActions.ts | 14 +- 11 files changed, 304 insertions(+), 20 deletions(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6f412de5..74506a39 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5083,8 +5083,8 @@ export class ScreenManagementService { let layout: { layout_data: any } | null = null; // 🆕 기본 레이어(layer_id=1)를 우선 로드 - // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 - if (isSuperAdmin) { + // SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회 + if (isSuperAdmin || companyCode === "*") { // 1. 화면 정의의 회사 코드 + 기본 레이어 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 160883ad..d1e07abe 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -179,7 +179,25 @@ function ScreenViewPage() { } else { // V1 레이아웃 또는 빈 레이아웃 const layoutData = await screenApi.getLayout(screenId); - setLayout(layoutData); + if (layoutData?.components?.length > 0) { + setLayout(layoutData); + } else { + console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId); + const baseLayerData = await screenApi.getLayerLayout(screenId, 1); + if (baseLayerData && isValidV2Layout(baseLayerData)) { + const converted = convertV2ToLegacy(baseLayerData); + if (converted) { + setLayout({ + ...converted, + screenResolution: baseLayerData.screenResolution || converted.screenResolution, + } as LayoutData); + } else { + setLayout(layoutData); + } + } else { + setLayout(layoutData); + } + } } } catch (layoutError) { console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index fe6ba4fa..ec36096d 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -413,9 +413,28 @@ export const EditModal: React.FC = ({ className }) => { // V2 없으면 기존 API fallback if (!layoutData) { + console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId); layoutData = await screenApi.getLayout(screenId); } + // getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드 + if (!layoutData || !layoutData.components || layoutData.components.length === 0) { + console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId); + try { + const baseLayerData = await screenApi.getLayerLayout(screenId, 1); + if (baseLayerData && isValidV2Layout(baseLayerData)) { + layoutData = convertV2ToLegacy(baseLayerData); + if (layoutData) { + layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution; + } + } else if (baseLayerData?.components) { + layoutData = baseLayerData; + } + } catch (fallbackErr) { + console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr); + } + } + if (screenInfo && layoutData) { const components = layoutData.components || []; @@ -1440,7 +1459,7 @@ export const EditModal: React.FC = ({ className }) => { -
+
{loading ? (
@@ -1455,7 +1474,7 @@ export const EditModal: React.FC = ({ className }) => { >
= ( // 라벨 표시 여부 계산 const shouldShowLabel = - !hideLabel && // hideLabel이 true면 라벨 숨김 - (component.style?.labelDisplay ?? true) && + !hideLabel && + (component.style?.labelDisplay ?? true) !== false && + component.style?.labelDisplay !== "false" && (component.label || component.style?.labelText) && - !templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함 + !templateTypes.includes(component.type); const labelText = component.style?.labelText || component.label || ""; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index a35c5ed2..253c886d 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1109,7 +1109,7 @@ export const InteractiveScreenViewerDynamic: React.FC((props, ref) => { } }; - const showLabel = label && style?.labelDisplay !== false; + const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d76802e8..219fa275 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -962,7 +962,7 @@ export const V2Input = forwardRef((props, ref) => }; const actualLabel = label || style?.labelText; - const showLabel = actualLabel && style?.labelDisplay === true; + const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index e7dbfd86..690791d5 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1135,7 +1135,7 @@ export const V2Select = forwardRef( } }; - const showLabel = label && style?.labelDisplay !== false; + const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 8cd8b0c5..f3a28c4c 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -10,7 +10,7 @@ import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react"; +import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, Pencil } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; @@ -1213,6 +1213,34 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 */} + {config.columns && config.columns.length > 0 && ( +
+
+

컬럼 순서 / 설정

+

+ 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 +

+
+
+
+ {[...(config.columns || [])] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((column, idx) => ( + moveColumn(column.columnName, direction)} + onRemove={() => removeColumn(column.columnName)} + onUpdate={(updates) => updateColumn(column.columnName, updates)} + /> + ))} +
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1240,3 +1268,97 @@ export const TableListConfigPanel: React.FC = ({
); }; + +/** + * 선택된 컬럼 항목 컴포넌트 + * 순서 이동, 삭제, 표시명 수정 기능 제공 + */ +const SelectedColumnItem: React.FC<{ + column: ColumnConfig; + index: number; + total: number; + onMove: (direction: "up" | "down") => void; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(column.displayName || column.columnName); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== column.displayName) { + onUpdate({ displayName: trimmed }); + } + setIsEditing(false); + }; + + return ( +
+ + + {index + 1} + + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditValue(column.displayName || column.columnName); + setIsEditing(false); + } + }} + className="h-5 flex-1 px-1 text-xs" + autoFocus + /> + ) : ( + + )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index 35f15596..ad250a16 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -22,6 +22,8 @@ import { Database, Table2, Link2, + GripVertical, + Pencil, } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; @@ -1458,6 +1460,34 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 */} + {config.columns && config.columns.length > 0 && ( +
+
+

컬럼 순서 / 설정

+

+ 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 +

+
+
+
+ {[...(config.columns || [])] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((column, idx) => ( + moveColumn(column.columnName, direction)} + onRemove={() => removeColumn(column.columnName)} + onUpdate={(updates) => updateColumn(column.columnName, updates)} + /> + ))} +
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1484,3 +1514,97 @@ export const TableListConfigPanel: React.FC = ({
); }; + +/** + * 선택된 컬럼 항목 컴포넌트 + * 순서 이동, 삭제, 표시명 수정 기능 제공 + */ +const SelectedColumnItem: React.FC<{ + column: ColumnConfig; + index: number; + total: number; + onMove: (direction: "up" | "down") => void; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(column.displayName || column.columnName); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== column.displayName) { + onUpdate({ displayName: trimmed }); + } + setIsEditing(false); + }; + + return ( +
+ + + {index + 1} + + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditValue(column.displayName || column.columnName); + setIsEditing(false); + } + }} + className="h-5 flex-1 px-1 text-xs" + autoFocus + /> + ) : ( + + )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 054b257f..2ed4db87 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -3173,16 +3173,16 @@ export class ButtonActionExecutor { return false; } - // 1. 화면 설명 가져오기 - let description = config.modalDescription || ""; - if (!description) { + // 1. 화면 정보 가져오기 (제목/설명이 미설정 시 화면명에서 가져옴) + let screenInfo: any = null; + if (!config.modalTitle || !config.modalDescription) { try { - const screenInfo = await screenApi.getScreen(config.targetScreenId); - description = screenInfo?.description || ""; + screenInfo = await screenApi.getScreen(config.targetScreenId); } catch (error) { - console.warn("화면 설명을 가져오지 못했습니다:", error); + console.warn("화면 정보를 가져오지 못했습니다:", error); } } + let description = config.modalDescription || screenInfo?.description || ""; // 2. 데이터 소스 및 선택된 데이터 수집 let selectedData: any[] = []; @@ -3288,7 +3288,7 @@ export class ButtonActionExecutor { } // 3. 동적 모달 제목 생성 - let finalTitle = config.modalTitle || "화면"; + let finalTitle = config.modalTitle || screenInfo?.screenName || "데이터 등록"; // 블록 기반 제목 처리 if (config.modalTitleBlocks?.length) { -- 2.43.0 From 026e99511cce366bb0e543868709792190db6996 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 14:30:31 +0900 Subject: [PATCH 21/23] refactor: Enhance label display and drag-and-drop functionality in table configuration - Updated the InteractiveScreenViewer and InteractiveScreenViewerDynamic components to include label positioning and size adjustments based on horizontal label settings. - Improved the DynamicComponentRenderer to handle label display logic more robustly, allowing for string values in addition to boolean. - Introduced drag-and-drop functionality in the TableListConfigPanel for reordering selected columns, enhancing user experience and flexibility in column management. - Refactored the display name resolution logic to prioritize available column labels, ensuring accurate representation in the UI. --- .../screen/InteractiveScreenViewer.tsx | 11 +- .../screen/InteractiveScreenViewerDynamic.tsx | 17 +- .../lib/registry/DynamicComponentRenderer.tsx | 4 +- .../table-list/TableListConfigPanel.tsx | 228 +++++++++-------- .../v2-table-list/TableListConfigPanel.tsx | 229 +++++++++--------- 5 files changed, 255 insertions(+), 234 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 1d64b597..7a9a3ff3 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -2233,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC = ( ...component, style: { ...component.style, - labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김 + labelDisplay: false, + labelPosition: "top" as const, + ...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}), }, + ...(isHorizontalLabel ? { + size: { + ...component.size, + width: undefined as unknown as number, + height: undefined as unknown as number, + }, + } : {}), } : component; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 253c886d..bcf9959c 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1292,7 +1292,22 @@ export const InteractiveScreenViewerDynamic: React.FC = componentType === "modal-repeater-table" || componentType === "v2-input"; - // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시) + // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시) const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; - const effectiveLabel = labelDisplay === true + const effectiveLabel = (labelDisplay === true || labelDisplay === "true") ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) : undefined; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index f3a28c4c..8526b0c9 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, Pencil } from "lucide-react"; +import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +/** + * 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴) + */ +function SortableColumnRow({ + id, + col, + index, + isEntityJoin, + onLabelChange, + onWidthChange, + onRemove, +}: { + id: string; + col: ColumnConfig; + index: number; + isEntityJoin?: boolean; + onLabelChange: (value: string) => void; + onWidthChange: (value: number) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="표시명" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC = ({ const existingColumn = config.columns?.find((col) => col.columnName === columnName); if (existingColumn) return; - // tableColumns에서 해당 컬럼의 라벨 정보 찾기 + // tableColumns → availableColumns 순서로 한국어 라벨 찾기 const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); + const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName); - // 라벨명 우선 사용, 없으면 컬럼명 사용 - const displayName = columnInfo?.label || columnInfo?.displayName || columnName; + const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName; const newColumn: ColumnConfig = { columnName, @@ -1213,31 +1276,59 @@ export const TableListConfigPanel: React.FC = ({ )} - {/* 선택된 컬럼 순서 변경 */} + {/* 선택된 컬럼 순서 변경 (DnD) */} {config.columns && config.columns.length > 0 && (
-

컬럼 순서 / 설정

+

표시할 컬럼 ({config.columns.length}개 선택)

- 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 + 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다


-
- {[...(config.columns || [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((column, idx) => ( - moveColumn(column.columnName, direction)} - onRemove={() => removeColumn(column.columnName)} - onUpdate={(updates) => updateColumn(column.columnName, updates)} - /> - ))} -
+ { + const { active, over } = event; + if (!over || active.id === over.id) return; + const columns = [...(config.columns || [])]; + const oldIndex = columns.findIndex((c) => c.columnName === active.id); + const newIndex = columns.findIndex((c) => c.columnName === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(columns, oldIndex, newIndex); + reordered.forEach((col, idx) => { col.order = idx; }); + handleChange("columns", reordered); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(config.columns || []).map((column, idx) => { + const resolvedLabel = + column.displayName && column.displayName !== column.columnName + ? column.displayName + : availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName; + + const colWithLabel = { ...column, displayName: resolvedLabel }; + return ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
)} @@ -1269,96 +1360,3 @@ export const TableListConfigPanel: React.FC = ({ ); }; -/** - * 선택된 컬럼 항목 컴포넌트 - * 순서 이동, 삭제, 표시명 수정 기능 제공 - */ -const SelectedColumnItem: React.FC<{ - column: ColumnConfig; - index: number; - total: number; - onMove: (direction: "up" | "down") => void; - onRemove: () => void; - onUpdate: (updates: Partial) => void; -}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(column.displayName || column.columnName); - - const handleSave = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed !== column.displayName) { - onUpdate({ displayName: trimmed }); - } - setIsEditing(false); - }; - - return ( -
- - - {index + 1} - - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); - if (e.key === "Escape") { - setEditValue(column.displayName || column.columnName); - setIsEditing(false); - } - }} - className="h-5 flex-1 px-1 text-xs" - autoFocus - /> - ) : ( - - )} - -
- - - -
-
- ); -}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index ad250a16..7de8a533 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -23,12 +23,75 @@ import { Table2, Link2, GripVertical, - Pencil, + X, } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +/** + * 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴) + */ +function SortableColumnRow({ + id, + col, + index, + isEntityJoin, + onLabelChange, + onWidthChange, + onRemove, +}: { + id: string; + col: ColumnConfig; + index: number; + isEntityJoin?: boolean; + onLabelChange: (value: string) => void; + onWidthChange: (value: number) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="표시명" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -368,11 +431,11 @@ export const TableListConfigPanel: React.FC = ({ const existingColumn = config.columns?.find((col) => col.columnName === columnName); if (existingColumn) return; - // tableColumns에서 해당 컬럼의 라벨 정보 찾기 + // tableColumns → availableColumns 순서로 한국어 라벨 찾기 const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); + const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName); - // 라벨명 우선 사용, 없으면 컬럼명 사용 - const displayName = columnInfo?.label || columnInfo?.displayName || columnName; + const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName; const newColumn: ColumnConfig = { columnName, @@ -1460,31 +1523,60 @@ export const TableListConfigPanel: React.FC = ({ )} - {/* 선택된 컬럼 순서 변경 */} + {/* 선택된 컬럼 순서 변경 (DnD) */} {config.columns && config.columns.length > 0 && (
-

컬럼 순서 / 설정

+

표시할 컬럼 ({config.columns.length}개 선택)

- 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 + 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다


-
- {[...(config.columns || [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((column, idx) => ( - moveColumn(column.columnName, direction)} - onRemove={() => removeColumn(column.columnName)} - onUpdate={(updates) => updateColumn(column.columnName, updates)} - /> - ))} -
+ { + const { active, over } = event; + if (!over || active.id === over.id) return; + const columns = [...(config.columns || [])]; + const oldIndex = columns.findIndex((c) => c.columnName === active.id); + const newIndex = columns.findIndex((c) => c.columnName === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(columns, oldIndex, newIndex); + reordered.forEach((col, idx) => { col.order = idx; }); + handleChange("columns", reordered); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(config.columns || []).map((column, idx) => { + // displayName이 columnName과 같으면 한국어 라벨 미설정 → availableColumns에서 찾기 + const resolvedLabel = + column.displayName && column.displayName !== column.columnName + ? column.displayName + : availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName; + + const colWithLabel = { ...column, displayName: resolvedLabel }; + return ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
)} @@ -1515,96 +1607,3 @@ export const TableListConfigPanel: React.FC = ({ ); }; -/** - * 선택된 컬럼 항목 컴포넌트 - * 순서 이동, 삭제, 표시명 수정 기능 제공 - */ -const SelectedColumnItem: React.FC<{ - column: ColumnConfig; - index: number; - total: number; - onMove: (direction: "up" | "down") => void; - onRemove: () => void; - onUpdate: (updates: Partial) => void; -}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(column.displayName || column.columnName); - - const handleSave = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed !== column.displayName) { - onUpdate({ displayName: trimmed }); - } - setIsEditing(false); - }; - - return ( -
- - - {index + 1} - - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); - if (e.key === "Escape") { - setEditValue(column.displayName || column.columnName); - setIsEditing(false); - } - }} - className="h-5 flex-1 px-1 text-xs" - autoFocus - /> - ) : ( - - )} - -
- - - -
-
- ); -}; -- 2.43.0 From a8ad26cf305b07bfed182107cd366b5cf7d3bce3 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 15:24:55 +0900 Subject: [PATCH 22/23] refactor: Enhance horizontal label handling in dynamic components - Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to improve horizontal label rendering and style management. - Refactored the DynamicComponentRenderer to support external horizontal labels, ensuring proper display and positioning based on component styles. - Cleaned up style handling by removing unnecessary border properties for horizontal labels, enhancing visual consistency. - Improved the logic for determining label display requirements, streamlining the rendering process for dynamic components. --- .../screen/InteractiveScreenViewerDynamic.tsx | 86 +++++++--- .../screen/RealtimePreviewDynamic.tsx | 15 +- .../lib/registry/DynamicComponentRenderer.tsx | 154 +++++++++++++++--- 3 files changed, 213 insertions(+), 42 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index bcf9959c..1bb04e97 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1119,6 +1119,12 @@ export const InteractiveScreenViewerDynamic: React.FC { const compType = (component as any).componentType || ""; const isSplitLine = type === "component" && compType === "v2-split-line"; @@ -1194,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC { + const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize; + return rest; + })() + : safeStyleWithoutSize; + const componentStyle = { position: "absolute" as const, - ...safeStyleWithoutSize, + ...cleanedStyle, // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게) left: adjustedX, top: position?.y || 0, @@ -1267,11 +1281,7 @@ export const InteractiveScreenViewerDynamic: React.FC
{needsExternalLabel ? ( -
- {externalLabelComponent} -
- {renderInteractiveWidget(componentToRender)} + isHorizLabel ? ( +
+ +
+ {renderInteractiveWidget(componentToRender)} +
-
+ ) : ( +
+ {externalLabelComponent} +
+ {renderInteractiveWidget(componentToRender)} +
+
+ ) ) : ( renderInteractiveWidget(componentToRender) )} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index b95506d9..dcca4d0d 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC = ({ const origWidth = size?.width || 100; const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth; + // v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리) + const isV2HorizLabel = !!( + componentStyle && + (componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") && + (componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right") + ); + const safeComponentStyle = isV2HorizLabel + ? (() => { + const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any; + return rest; + })() + : componentStyle; + const baseStyle = { left: `${adjustedPositionX}px`, top: `${position.y}px`, - ...componentStyle, + ...safeComponentStyle, width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth, height: displayHeight, zIndex: component.type === "layout" ? 1 : position.z || 2, diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index ed98561c..50c4bee4 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -371,15 +371,18 @@ export const DynamicComponentRenderer: React.FC = try { const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); const fieldName = columnName || component.id; - const currentValue = props.formData?.[fieldName] || ""; - const handleChange = (value: any) => { - if (props.onFormDataChange) { - props.onFormDataChange(fieldName, value); - } - }; - - // V2SelectRenderer용 컴포넌트 데이터 구성 + // 수평 라벨 감지 + const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; + const catLabelPosition = component.style?.labelPosition; + const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true") + ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) + : undefined; + const catNeedsExternalHorizLabel = !!( + catLabelText && + (catLabelPosition === "left" || catLabelPosition === "right") + ); + const selectComponent = { ...component, componentConfig: { @@ -395,6 +398,24 @@ export const DynamicComponentRenderer: React.FC = webType: "category", }; + const catStyle = catNeedsExternalHorizLabel + ? { + ...(component as any).style, + labelDisplay: false, + labelPosition: "top" as const, + width: "100%", + height: "100%", + borderWidth: undefined, + borderColor: undefined, + borderStyle: undefined, + border: undefined, + borderRadius: undefined, + } + : (component as any).style; + const catSize = catNeedsExternalHorizLabel + ? { ...(component as any).size, width: undefined, height: undefined } + : (component as any).size; + const rendererProps = { component: selectComponent, formData: props.formData, @@ -402,12 +423,47 @@ export const DynamicComponentRenderer: React.FC = isDesignMode: props.isDesignMode, isInteractive: props.isInteractive ?? !props.isDesignMode, tableName, - style: (component as any).style, - size: (component as any).size, + style: catStyle, + size: catSize, }; const rendererInstance = new V2SelectRenderer(rendererProps); - return rendererInstance.render(); + const renderedCatSelect = rendererInstance.render(); + + if (catNeedsExternalHorizLabel) { + const labelGap = component.style?.labelGap || "8px"; + const labelFontSize = component.style?.labelFontSize || "14px"; + const labelColor = component.style?.labelColor || "#64748b"; + const labelFontWeight = component.style?.labelFontWeight || "500"; + const isRequired = component.required || (component as any).required; + const isLeft = catLabelPosition === "left"; + return ( +
+ +
+ {renderedCatSelect} +
+
+ ); + } + return renderedCatSelect; } catch (error) { console.error("❌ V2SelectRenderer 로드 실패:", error); } @@ -625,12 +681,33 @@ export const DynamicComponentRenderer: React.FC = ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) : undefined; + // 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리 + const labelPosition = component.style?.labelPosition; + const isV2Component = componentType?.startsWith("v2-"); + const needsExternalHorizLabel = !!( + isV2Component && + effectiveLabel && + (labelPosition === "left" || labelPosition === "right") + ); + // 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀 const mergedStyle = { ...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저! // CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고) width: finalStyle.width, height: finalStyle.height, + // 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리) + ...(needsExternalHorizLabel ? { + labelDisplay: false, + labelPosition: "top" as const, + width: "100%", + height: "100%", + borderWidth: undefined, + borderColor: undefined, + borderStyle: undefined, + border: undefined, + borderRadius: undefined, + } : {}), }; // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선) @@ -649,7 +726,9 @@ export const DynamicComponentRenderer: React.FC = onClick, onDragStart, onDragEnd, - size: component.size || newComponent.defaultSize, + size: needsExternalHorizLabel + ? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined } + : (component.size || newComponent.defaultSize), position: component.position, config: mergedComponentConfig, componentConfig: mergedComponentConfig, @@ -657,8 +736,8 @@ export const DynamicComponentRenderer: React.FC = ...(mergedComponentConfig || {}), // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) style: mergedStyle, - // 🆕 라벨 표시 (labelDisplay가 true일 때만) - label: effectiveLabel, + // 수평 라벨 → 외부에서 처리하므로 label 전달 안 함 + label: needsExternalHorizLabel ? undefined : effectiveLabel, // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선) inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, @@ -759,16 +838,51 @@ export const DynamicComponentRenderer: React.FC = NewComponentRenderer.prototype && NewComponentRenderer.prototype.render; + let renderedElement: React.ReactElement; if (isClass) { - // 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속) const rendererInstance = new NewComponentRenderer(rendererProps); - return rendererInstance.render(); + renderedElement = rendererInstance.render(); } else { - // 함수형 컴포넌트 - // refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제 - - return ; + renderedElement = ; } + + // 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움 + if (needsExternalHorizLabel) { + const labelGap = component.style?.labelGap || "8px"; + const labelFontSize = component.style?.labelFontSize || "14px"; + const labelColor = component.style?.labelColor || "#64748b"; + const labelFontWeight = component.style?.labelFontWeight || "500"; + const isRequired = component.required || (component as any).required; + const isLeft = labelPosition === "left"; + + return ( +
+ +
+ {renderedElement} +
+
+ ); + } + + return renderedElement; } } catch (error) { console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error); -- 2.43.0 From e16d76936b13dd5a5c3789a37485f8a3f8091760 Mon Sep 17 00:00:00 2001 From: kjs Date: Sat, 28 Feb 2026 14:33:18 +0900 Subject: [PATCH 23/23] feat: Enhance V2Repeater and configuration panel with source detail auto-fetching - Added support for automatic fetching of detail rows from the master data in the V2Repeater component, improving data management. - Introduced a new configuration option in the V2RepeaterConfigPanel to enable source detail auto-fetching, allowing users to specify detail table and foreign key settings. - Enhanced the V2Repeater component to handle entity joins for loading data, optimizing data retrieval processes. - Updated the V2RepeaterProps and V2RepeaterConfig interfaces to include new properties for grouped data and source detail configuration, ensuring type safety and clarity in component usage. - Improved logging for data loading processes to provide better insights during development and debugging. --- frontend/components/v2/V2Repeater.tsx | 256 +++++++++--- .../config-panels/V2RepeaterConfigPanel.tsx | 128 ++++++ .../modal-repeater-table/RepeaterTable.tsx | 10 +- .../SplitPanelLayout2Component.tsx | 139 ++++++- .../TableSectionRenderer.tsx | 375 +++++++++++++----- .../UniversalFormModalComponent.tsx | 85 ++-- .../UniversalFormModalConfigPanel.tsx | 20 +- .../modals/TableSectionSettingsModal.tsx | 9 +- .../components/DetailFormModal.tsx | 9 +- .../v2-repeater/V2RepeaterRenderer.tsx | 3 + frontend/types/v2-repeater.ts | 24 +- 11 files changed, 858 insertions(+), 200 deletions(-) diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index b60617e6..f6f1fc6b 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -48,6 +48,7 @@ export const V2Repeater: React.FC = ({ onRowClick, className, formData: parentFormData, + groupedData, ...restProps }) => { // componentId 결정: 직접 전달 또는 component 객체에서 추출 @@ -419,65 +420,113 @@ export const V2Repeater: React.FC = ({ fkValue, }); - const response = await apiClient.post( - `/table-management/tables/${config.mainTableName}/data`, - { + let rows: any[] = []; + const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin; + + if (useEntityJoinForLoad) { + // 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인) + const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue }); + const params: Record = { page: 1, size: 1000, - dataFilter: { - enabled: true, - filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], - }, - autoFilter: true, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns; + if (addJoinCols && addJoinCols.length > 0) { + params.additionalJoinColumns = JSON.stringify(addJoinCols); } - ); + const response = await apiClient.get( + `/table-management/tables/${config.mainTableName}/data-with-joins`, + { params } + ); + const resultData = response.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거 + const seenIds = new Set(); + rows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + const response = await apiClient.post( + `/table-management/tables/${config.mainTableName}/data`, + { + page: 1, + size: 1000, + dataFilter: { + enabled: true, + filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], + }, + autoFilter: true, + } + ); + rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; + } - const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; if (Array.isArray(rows) && rows.length > 0) { - console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); + console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : ""); - // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 - const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); - const sourceTable = config.dataSource?.sourceTable; - const fkColumn = config.dataSource?.foreignKey; - const refKey = config.dataSource?.referenceKey || "id"; + // 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강 + const columnMapping = config.sourceDetailConfig?.columnMapping; + if (useEntityJoinForLoad && columnMapping) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + rows.forEach((row: any) => { + sourceDisplayColumns.forEach((col) => { + const mappedKey = columnMapping[col.key]; + const value = mappedKey ? row[mappedKey] : row[col.key]; + row[`_display_${col.key}`] = value ?? ""; + }); + }); + console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료"); + } - if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { - try { - const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); - const uniqueValues = [...new Set(fkValues)]; + // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시) + if (!useEntityJoinForLoad) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + const sourceTable = config.dataSource?.sourceTable; + const fkColumn = config.dataSource?.foreignKey; + const refKey = config.dataSource?.referenceKey || "id"; - if (uniqueValues.length > 0) { - // FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 - const sourcePromises = uniqueValues.map((val) => - apiClient.post(`/table-management/tables/${sourceTable}/data`, { - page: 1, size: 1, - search: { [refKey]: val }, - autoFilter: true, - }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) - .catch(() => []) - ); - const sourceResults = await Promise.all(sourcePromises); - const sourceMap = new Map(); - sourceResults.flat().forEach((sr: any) => { - if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); - }); + if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { + try { + const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); + const uniqueValues = [...new Set(fkValues)]; - // 각 행에 소스 테이블의 표시 데이터 병합 - rows.forEach((row: any) => { - const sourceRecord = sourceMap.get(String(row[fkColumn])); - if (sourceRecord) { - sourceDisplayColumns.forEach((col) => { - const displayValue = sourceRecord[col.key] ?? null; - row[col.key] = displayValue; - row[`_display_${col.key}`] = displayValue; - }); - } - }); - console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + if (uniqueValues.length > 0) { + const sourcePromises = uniqueValues.map((val) => + apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, size: 1, + search: { [refKey]: val }, + autoFilter: true, + }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) + .catch(() => []) + ); + const sourceResults = await Promise.all(sourcePromises); + const sourceMap = new Map(); + sourceResults.flat().forEach((sr: any) => { + if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); + }); + + rows.forEach((row: any) => { + const sourceRecord = sourceMap.get(String(row[fkColumn])); + if (sourceRecord) { + sourceDisplayColumns.forEach((col) => { + const displayValue = sourceRecord[col.key] ?? null; + row[col.key] = displayValue; + row[`_display_${col.key}`] = displayValue; + }); + } + }); + console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + } + } catch (sourceError) { + console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } - } catch (sourceError) { - console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } } @@ -964,8 +1013,113 @@ export const V2Repeater: React.FC = ({ [], ); - // V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. - // EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. + // sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면 + // 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅 + const sourceDetailLoadedRef = useRef(false); + useEffect(() => { + if (sourceDetailLoadedRef.current) return; + if (!groupedData || groupedData.length === 0) return; + if (!config.sourceDetailConfig) return; + + const { tableName, foreignKey, parentKey } = config.sourceDetailConfig; + if (!tableName || !foreignKey || !parentKey) return; + + const parentKeys = groupedData + .map((row) => row[parentKey]) + .filter((v) => v !== undefined && v !== null && v !== ""); + + if (parentKeys.length === 0) return; + + sourceDetailLoadedRef.current = true; + + const loadSourceDetails = async () => { + try { + const uniqueKeys = [...new Set(parentKeys)] as string[]; + const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!; + + let detailRows: any[] = []; + + if (useEntityJoin) { + // data-with-joins GET API 사용 (엔티티 조인 자동 적용) + const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") }); + const params: Record = { + page: 1, + size: 9999, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + if (additionalJoinColumns && additionalJoinColumns.length > 0) { + params.additionalJoinColumns = JSON.stringify(additionalJoinColumns); + } + const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params }); + const resultData = resp.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거 + const seenIds = new Set(); + detailRows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + // 기존 POST API 사용 + const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 9999, + search: { [foreignKey]: uniqueKeys }, + }); + const resultData = resp.data?.data; + detailRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + } + + if (detailRows.length === 0) { + console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys }); + return; + } + + console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : ""); + + // 디테일 행을 리피터 컬럼에 매핑 + const newRows = detailRows.map((detail, index) => { + const row: any = { _id: `src_detail_${Date.now()}_${index}` }; + for (const col of config.columns) { + if (col.isSourceDisplay) { + // columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용) + const mappedKey = columnMapping?.[col.key]; + const value = mappedKey ? detail[mappedKey] : detail[col.key]; + row[`_display_${col.key}`] = value ?? ""; + // 원본 값도 저장 (DB persist용 - _display_ 접두사 없이) + if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } + } else if (col.autoFill) { + const autoValue = generateAutoFillValueSync(col, index, parentFormData); + row[col.key] = autoValue ?? ""; + } else if (col.sourceKey && detail[col.sourceKey] !== undefined) { + row[col.key] = detail[col.sourceKey]; + } else if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } else { + row[col.key] = ""; + } + } + return row; + }); + + setData(newRows); + onDataChange?.(newRows); + } catch (error) { + console.error("[V2Repeater] sourceDetail 조회 실패:", error); + } + }; + + loadSourceDetails(); + }, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]); // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 useEffect(() => { diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 1f89ae12..66f0f18b 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -31,6 +31,7 @@ import { Wand2, Check, ChevronsUpDown, + ListTree, } from "lucide-react"; import { Command, @@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC = ({ + {/* 소스 디테일 자동 조회 설정 */} +
+
+ { + if (checked) { + updateConfig({ + sourceDetailConfig: { + tableName: "", + foreignKey: "", + parentKey: "", + }, + }); + } else { + updateConfig({ sourceDetailConfig: undefined }); + } + }} + /> + +
+

+ 모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다. +

+ + {config.sourceDetailConfig && ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + tableName: table.tableName, + }, + }); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + foreignKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + parentKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ +

+ 마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 → + {" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회 +

+
+ )} +
+ + + {/* 기능 옵션 */}
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index d57ae60b..532881b7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -553,14 +553,20 @@ export function RepeaterTable({ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; - // 카테고리 라벨 변환 함수 + // 카테고리/셀렉트 라벨 변환 함수 const getCategoryDisplayValue = (val: any): string => { if (!val || typeof val !== "string") return val || "-"; + // select 타입 컬럼의 selectOptions에서 라벨 찾기 + if (column.selectOptions && column.selectOptions.length > 0) { + const matchedOption = column.selectOptions.find((opt) => opt.value === val); + if (matchedOption) return matchedOption.label; + } + const fieldName = column.field.replace(/^_display_/, ""); const isCategoryColumn = categoryColumns.includes(fieldName); - // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) + // categoryLabelMap에 직접 매핑이 있으면 바로 변환 if (categoryLabelMap[val]) return categoryLabelMap[val]; // 카테고리 컬럼이 아니면 원래 값 반환 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index a06c046f..6c631d83 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props @@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC(null); const [rightActiveTab, setRightActiveTab] = useState(null); + // 카테고리 코드→라벨 매핑 + const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + // 프론트엔드 그룹핑 함수 const groupData = useCallback( (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { @@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC ({ id: value, - label: value, + label: categoryLabelMap[value] || value, count: tabConfig.showCount ? count : 0, })); console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); return tabs; }, - [], + [categoryLabelMap], ); // 탭으로 필터링된 데이터 반환 @@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC { + loadLeftData(); + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(editEvent); + console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem); break; + } case "delete": // 좌측 패널에서 삭제 (필요시 구현) @@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC { + if (isDesignMode) return; + + const loadCategoryLabels = async () => { + const allColumns = new Set(); + const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName; + if (!tableName) return; + + // 좌우 패널의 표시 컬럼에서 카테고리 후보 수집 + for (const col of config.leftPanel?.displayColumns || []) { + allColumns.add(col.name); + } + for (const col of config.rightPanel?.displayColumns || []) { + allColumns.add(col.name); + } + // 탭 소스 컬럼도 추가 + if (config.rightPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.rightPanel.tabConfig.tabSourceColumn); + } + if (config.leftPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.leftPanel.tabConfig.tabSourceColumn); + } + + const labelMap: Record = {}; + + for (const columnName of allColumns) { + try { + const result = await getCategoryValues(tableName, columnName); + if (result.success && Array.isArray(result.data) && result.data.length > 0) { + for (const item of result.data) { + if (item.valueCode && item.valueLabel) { + labelMap[item.valueCode] = item.valueLabel; + } + } + } + } catch { + // 카테고리가 아닌 컬럼은 무시 + } + } + + if (Object.keys(labelMap).length > 0) { + setCategoryLabelMap(labelMap); + } + }; + + loadCategoryLabels(); + }, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]); + // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { @@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC { + if (value === null || value === undefined) return ""; + const strVal = String(value); + if (categoryLabelMap[strVal]) return categoryLabelMap[strVal]; + // 콤마 구분 다중 값 처리 + if (strVal.includes(",")) { + const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean); + const labels = codes.map((code) => categoryLabelMap[code] || code); + return labels.join(", "); + } + return strVal; + }, + [categoryLabelMap], + ); + // 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려) const getColumnValue = useCallback( (item: any, col: ColumnConfig): any => { @@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC { const value = item[col.name]; if (value === null || value === undefined) return "-"; @@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC {value.map((v, vIdx) => ( - {formatValue(v, col.format)} + {resolveCategoryLabel(v) || formatValue(v, col.format)} ))}
@@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC - {formatValue(value, col.format)} + {label !== String(value) ? label : formatValue(value, col.format)} ); } - // 기본 텍스트 + // 카테고리 라벨 변환 시도 후 기본 텍스트 + const label = resolveCategoryLabel(value); + if (label !== String(value)) return label; return formatValue(value, col.format); }; @@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC )} - {displayColumns.map((col, colIdx) => ( - {formatValue(getColumnValue(item, col), col.format)} - ))} + {displayColumns.map((col, colIdx) => { + const rawVal = getColumnValue(item, col); + const resolved = resolveCategoryLabel(rawVal); + const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format); + return {display || "-"}; + })} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
@@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC 0 && (
- {config.leftPanel.actionButtons.map((btn, idx) => ( + {config.leftPanel.actionButtons + .filter((btn) => { + if (btn.showCondition === "selected") return !!selectedLeftItem; + return true; + }) + .map((btn, idx) => (