diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 4a6a1e03..5dd7dc21 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -144,8 +144,9 @@ export class FlowController { try { const { id } = req.params; const flowId = parseInt(id); + const userCompanyCode = (req as any).user?.companyCode; - const definition = await this.flowDefinitionService.findById(flowId); + const definition = await this.flowDefinitionService.findById(flowId, userCompanyCode); if (!definition) { res.status(404).json({ success: false, @@ -182,12 +183,13 @@ export class FlowController { const { id } = req.params; const flowId = parseInt(id); const { name, description, isActive } = req.body; + const userCompanyCode = (req as any).user?.companyCode; const flowDef = await this.flowDefinitionService.update(flowId, { name, description, isActive, - }); + }, userCompanyCode); if (!flowDef) { res.status(404).json({ @@ -217,8 +219,9 @@ export class FlowController { try { const { id } = req.params; const flowId = parseInt(id); + const userCompanyCode = (req as any).user?.companyCode; - const success = await this.flowDefinitionService.delete(flowId); + const success = await this.flowDefinitionService.delete(flowId, userCompanyCode); if (!success) { res.status(404).json({ @@ -275,6 +278,7 @@ export class FlowController { try { const { flowId } = req.params; const flowDefinitionId = parseInt(flowId); + const userCompanyCode = (req as any).user?.companyCode; const { stepName, stepOrder, @@ -293,6 +297,16 @@ export class FlowController { return; } + // 플로우 소유권 검증 + const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(404).json({ + success: false, + message: "Flow definition not found or access denied", + }); + return; + } + const step = await this.flowStepService.create({ flowDefinitionId, stepName, @@ -324,6 +338,7 @@ export class FlowController { try { const { stepId } = req.params; const id = parseInt(stepId); + const userCompanyCode = (req as any).user?.companyCode; const { stepName, stepOrder, @@ -342,6 +357,19 @@ export class FlowController { displayConfig, } = req.body; + // 스텝 소유권 검증: 스텝이 속한 플로우가 사용자 회사 소유인지 확인 + const existingStep = await this.flowStepService.findById(id); + if (existingStep) { + const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(403).json({ + success: false, + message: "Access denied: flow does not belong to your company", + }); + return; + } + } + const step = await this.flowStepService.update(id, { stepName, stepOrder, @@ -388,6 +416,20 @@ export class FlowController { try { const { stepId } = req.params; const id = parseInt(stepId); + const userCompanyCode = (req as any).user?.companyCode; + + // 스텝 소유권 검증 + const existingStep = await this.flowStepService.findById(id); + if (existingStep) { + const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(403).json({ + success: false, + message: "Access denied: flow does not belong to your company", + }); + return; + } + } const success = await this.flowStepService.delete(id); @@ -446,6 +488,7 @@ export class FlowController { createConnection = async (req: Request, res: Response): Promise => { try { const { flowDefinitionId, fromStepId, toStepId, label } = req.body; + const userCompanyCode = (req as any).user?.companyCode; if (!flowDefinitionId || !fromStepId || !toStepId) { res.status(400).json({ @@ -455,6 +498,28 @@ export class FlowController { return; } + // 플로우 소유권 검증 + const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(404).json({ + success: false, + message: "Flow definition not found or access denied", + }); + return; + } + + // fromStepId, toStepId가 해당 flow에 속하는지 검증 + const fromStep = await this.flowStepService.findById(fromStepId); + const toStep = await this.flowStepService.findById(toStepId); + if (!fromStep || fromStep.flowDefinitionId !== flowDefinitionId || + !toStep || toStep.flowDefinitionId !== flowDefinitionId) { + res.status(400).json({ + success: false, + message: "fromStepId and toStepId must belong to the specified flow", + }); + return; + } + const connection = await this.flowConnectionService.create({ flowDefinitionId, fromStepId, @@ -482,6 +547,20 @@ export class FlowController { try { const { connectionId } = req.params; const id = parseInt(connectionId); + const userCompanyCode = (req as any).user?.companyCode; + + // 연결 소유권 검증 + const existingConn = await this.flowConnectionService.findById(id); + if (existingConn) { + const flowDef = await this.flowDefinitionService.findById(existingConn.flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(403).json({ + success: false, + message: "Access denied: flow does not belong to your company", + }); + return; + } + } const success = await this.flowConnectionService.delete(id); @@ -670,23 +749,24 @@ export class FlowController { */ moveData = async (req: Request, res: Response): Promise => { try { - const { flowId, recordId, toStepId, note } = req.body; + const { flowId, fromStepId, recordId, toStepId, note } = req.body; const userId = (req as any).user?.userId || "system"; - if (!flowId || !recordId || !toStepId) { + if (!flowId || !fromStepId || !recordId || !toStepId) { res.status(400).json({ success: false, - message: "flowId, recordId, and toStepId are required", + message: "flowId, fromStepId, recordId, and toStepId are required", }); return; } await this.flowDataMoveService.moveDataToStep( flowId, - recordId, + fromStepId, toStepId, + recordId, userId, - note + note ? { note } : undefined ); res.json({ diff --git a/backend-node/src/services/flowConditionParser.ts b/backend-node/src/services/flowConditionParser.ts index 5f2e648a..c3a930ea 100644 --- a/backend-node/src/services/flowConditionParser.ts +++ b/backend-node/src/services/flowConditionParser.ts @@ -132,14 +132,23 @@ export class FlowConditionParser { /** * SQL 인젝션 방지를 위한 컬럼명 검증 */ - private static sanitizeColumnName(columnName: string): string { - // 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원) + static sanitizeColumnName(columnName: string): string { if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) { throw new Error(`Invalid column name: ${columnName}`); } return columnName; } + /** + * SQL 인젝션 방지를 위한 테이블명 검증 + */ + static sanitizeTableName(tableName: string): string { + if (!/^[a-zA-Z0-9_.]+$/.test(tableName)) { + throw new Error(`Invalid table name: ${tableName}`); + } + return tableName; + } + /** * 조건 검증 */ diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 09058502..bec1d4d8 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -25,6 +25,7 @@ import { buildInsertQuery, buildSelectQuery, } from "./dbQueryBuilder"; +import { FlowConditionParser } from "./flowConditionParser"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; @@ -236,18 +237,19 @@ export class FlowDataMoveService { ); } - const statusColumn = toStep.statusColumn; - const tableName = fromStep.tableName; + const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn); + const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName); // 추가 필드 업데이트 준비 const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`]; const values: any[] = [dataId, toStep.statusValue]; let paramIndex = 3; - // 추가 데이터가 있으면 함께 업데이트 + // 추가 데이터가 있으면 함께 업데이트 (키 검증 포함) if (additionalData) { for (const [key, value] of Object.entries(additionalData)) { - updates.push(`${key} = $${paramIndex}`); + const safeKey = FlowConditionParser.sanitizeColumnName(key); + updates.push(`${safeKey} = $${paramIndex}`); values.push(value); paramIndex++; } @@ -276,33 +278,38 @@ export class FlowDataMoveService { dataId: any, additionalData?: Record ): Promise { - const sourceTable = fromStep.tableName; - const targetTable = toStep.targetTable || toStep.tableName; + const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName); + const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName); const fieldMappings = toStep.fieldMappings || {}; // 1. 소스 데이터 조회 const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`; const sourceResult = await client.query(selectQuery, [dataId]); - if (sourceResult.length === 0) { + if (sourceResult.rows.length === 0) { throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); } - const sourceData = sourceResult[0]; + const sourceData = sourceResult.rows[0]; // 2. 필드 매핑 적용 const mappedData: Record = {}; - // 매핑 정의가 있으면 적용 + // 매핑 정의가 있으면 적용 (컬럼명 검증) for (const [sourceField, targetField] of Object.entries(fieldMappings)) { + FlowConditionParser.sanitizeColumnName(sourceField); + FlowConditionParser.sanitizeColumnName(targetField as string); if (sourceData[sourceField] !== undefined) { mappedData[targetField as string] = sourceData[sourceField]; } } - // 추가 데이터 병합 + // 추가 데이터 병합 (키 검증) if (additionalData) { - Object.assign(mappedData, additionalData); + for (const [key, value] of Object.entries(additionalData)) { + const safeKey = FlowConditionParser.sanitizeColumnName(key); + mappedData[safeKey] = value; + } } // 3. 타겟 테이블에 데이터 삽입 @@ -321,7 +328,7 @@ export class FlowDataMoveService { `; const insertResult = await client.query(insertQuery, values); - return insertResult[0].id; + return insertResult.rows[0].id; } /** @@ -349,12 +356,12 @@ export class FlowDataMoveService { ]); const stepDataMap: Record = - mappingResult.length > 0 ? mappingResult[0].step_data_map : {}; + mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {}; // 새 단계 데이터 추가 stepDataMap[String(currentStepId)] = String(targetDataId); - if (mappingResult.length > 0) { + if (mappingResult.rows.length > 0) { // 기존 매핑 업데이트 const updateQuery = ` UPDATE flow_data_mapping @@ -366,7 +373,7 @@ export class FlowDataMoveService { await client.query(updateQuery, [ currentStepId, JSON.stringify(stepDataMap), - mappingResult[0].id, + mappingResult.rows[0].id, ]); } else { // 새 매핑 생성 diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 80c920ad..d43b2fe0 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -19,7 +19,8 @@ export class FlowDefinitionService { userId: string, userCompanyCode?: string ): Promise { - const companyCode = request.companyCode || userCompanyCode || "*"; + // 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용 + const companyCode = userCompanyCode || "*"; console.log("🔥 flowDefinitionService.create called with:", { name: request.name, @@ -118,10 +119,21 @@ export class FlowDefinitionService { /** * 플로우 정의 단일 조회 + * companyCode가 전달되면 해당 회사 소유 플로우만 반환 */ - async findById(id: number): Promise { - const query = "SELECT * FROM flow_definition WHERE id = $1"; - const result = await db.query(query, [id]); + async findById(id: number, companyCode?: string): Promise { + let query: string; + let params: any[]; + + if (companyCode && companyCode !== "*") { + query = "SELECT * FROM flow_definition WHERE id = $1 AND company_code = $2"; + params = [id, companyCode]; + } else { + query = "SELECT * FROM flow_definition WHERE id = $1"; + params = [id]; + } + + const result = await db.query(query, params); if (result.length === 0) { return null; @@ -132,10 +144,12 @@ export class FlowDefinitionService { /** * 플로우 정의 수정 + * companyCode가 전달되면 해당 회사 소유 플로우만 수정 가능 */ async update( id: number, - request: UpdateFlowDefinitionRequest + request: UpdateFlowDefinitionRequest, + companyCode?: string ): Promise { const fields: string[] = []; const params: any[] = []; @@ -160,18 +174,27 @@ export class FlowDefinitionService { } if (fields.length === 0) { - return this.findById(id); + return this.findById(id, companyCode); } fields.push(`updated_at = NOW()`); + let whereClause = `WHERE id = $${paramIndex}`; + params.push(id); + paramIndex++; + + if (companyCode && companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + const query = ` UPDATE flow_definition SET ${fields.join(", ")} - WHERE id = $${paramIndex} + ${whereClause} RETURNING * `; - params.push(id); const result = await db.query(query, params); @@ -184,10 +207,21 @@ export class FlowDefinitionService { /** * 플로우 정의 삭제 + * companyCode가 전달되면 해당 회사 소유 플로우만 삭제 가능 */ - async delete(id: number): Promise { - const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; - const result = await db.query(query, [id]); + async delete(id: number, companyCode?: string): Promise { + let query: string; + let params: any[]; + + if (companyCode && companyCode !== "*") { + query = "DELETE FROM flow_definition WHERE id = $1 AND company_code = $2 RETURNING id"; + params = [id, companyCode]; + } else { + query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; + params = [id]; + } + + const result = await db.query(query, params); return result.length > 0; } diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 7a6825f0..54a668e6 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService"; import { FlowConditionParser } from "./flowConditionParser"; import { executeExternalQuery } from "./externalDbHelper"; import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; +import { FlowConditionParser } from "./flowConditionParser"; export class FlowExecutionService { private flowDefinitionService: FlowDefinitionService; @@ -42,7 +43,7 @@ export class FlowExecutionService { } // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 - const tableName = step.tableName || flowDef.tableName; + const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName); // 4. 조건 JSON을 SQL WHERE절로 변환 const { where, params } = FlowConditionParser.toSqlWhere( @@ -96,7 +97,7 @@ export class FlowExecutionService { } // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 - const tableName = step.tableName || flowDef.tableName; + const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName); // 4. 조건 JSON을 SQL WHERE절로 변환 const { where, params } = FlowConditionParser.toSqlWhere( @@ -267,11 +268,12 @@ export class FlowExecutionService { throw new Error(`Flow step not found: ${stepId}`); } - // 3. 테이블명 결정 - const tableName = step.tableName || flowDef.tableName; - if (!tableName) { + // 3. 테이블명 결정 (SQL 인젝션 방지) + const rawTableName = step.tableName || flowDef.tableName; + if (!rawTableName) { throw new Error("Table name not found"); } + const tableName = FlowConditionParser.sanitizeTableName(rawTableName); // 4. Primary Key 컬럼 결정 (기본값: id) const primaryKeyColumn = flowDef.primaryKey || "id"; @@ -280,8 +282,10 @@ export class FlowExecutionService { `🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}` ); - // 5. SET 절 생성 - const updateColumns = Object.keys(updateData); + // 5. SET 절 생성 (컬럼명 SQL 인젝션 방지) + const updateColumns = Object.keys(updateData).map((col) => + FlowConditionParser.sanitizeColumnName(col) + ); if (updateColumns.length === 0) { throw new Error("No columns to update"); } 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/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 9f105a49..7cf9b9de 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -260,6 +260,7 @@ export interface FlowStepDataList { // 데이터 이동 요청 export interface MoveDataRequest { flowId: number; + fromStepId: number; recordId: string; toStepId: number; note?: string; diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 693b4be5..c95a5c30 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -189,7 +189,25 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = { } 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/flow/FlowDataListModal.tsx b/frontend/components/flow/FlowDataListModal.tsx index 61264ffb..8981e472 100644 --- a/frontend/components/flow/FlowDataListModal.tsx +++ b/frontend/components/flow/FlowDataListModal.tsx @@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Loader2, AlertCircle, ArrowRight } from "lucide-react"; -import { getStepDataList, moveDataToNextStep } from "@/lib/api/flow"; +import { getStepDataList, moveBatchData, getFlowConnections } from "@/lib/api/flow"; import { toast } from "sonner"; interface FlowDataListModalProps { @@ -102,15 +102,28 @@ export function FlowDataListModal({ try { setMovingData(true); - // 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음) - const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id); + // 다음 스텝 결정 (연결 정보에서 조회) + const connResponse = await getFlowConnections(flowId); + if (!connResponse.success || !connResponse.data) { + throw new Error("플로우 연결 정보를 가져올 수 없습니다"); + } + const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId); + if (!nextConn) { + throw new Error("다음 단계가 연결되어 있지 않습니다"); + } - // 데이터 이동 API 호출 - for (const dataId of selectedDataIds) { - const response = await moveDataToNextStep(flowId, stepId, dataId); - if (!response.success) { - throw new Error(`데이터 이동 실패: ${response.message}`); - } + // 선택된 행의 ID 추출 + const selectedDataIds = Array.from(selectedRows).map((index) => String(data[index].id)); + + // 배치 이동 API 호출 + const response = await moveBatchData({ + flowId, + fromStepId: stepId, + toStepId: nextConn.toStepId, + dataIds: selectedDataIds, + }); + if (!response.success) { + throw new Error(`데이터 이동 실패: ${response.error || "알 수 없는 오류"}`); } toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 5128d32a..a97c1b1e 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -422,9 +422,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 || []; @@ -1449,7 +1468,7 @@ export const EditModal: React.FC = ({ className }) => { -
+
{loading ? (
@@ -1464,7 +1483,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 || ""; @@ -2232,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 249f92eb..8b509309 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -14,7 +14,6 @@ import { DynamicWebTypeRenderer } from "@/lib/registry"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; -import { isFieldEmpty } from "@/lib/utils/formValidation"; import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; import { FlowVisibilityConfig } from "@/types/control-management"; import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; @@ -1111,7 +1110,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const compType = (component as any).componentType || ""; @@ -1232,9 +1201,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, @@ -1242,7 +1219,7 @@ export const InteractiveScreenViewerDynamic: React.FC 0 || showRequiredError) ? "visible" : undefined), + overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined), willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined, transition: isSplitActive ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out") @@ -1305,11 +1282,7 @@ export const InteractiveScreenViewerDynamic: React.FC
{needsExternalLabel ? ( -
- {externalLabelComponent} -
- {renderInteractiveWidget(componentToRender)} + isHorizLabel ? ( +
+ +
+ {renderInteractiveWidget(componentToRender)} +
-
+ ) : ( +
+ {externalLabelComponent} +
+ {renderInteractiveWidget(componentToRender)} +
+
+ ) ) : ( renderInteractiveWidget(componentToRender) )} - {showRequiredError && ( -

- 필수 입력 항목입니다 -

- )}
{/* 팝업 화면 렌더링 */} 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/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index 18c9127b..c06e1536 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -11,8 +11,6 @@ import { screenApi } from "@/lib/api/screen"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { ComponentData } from "@/lib/types/screen"; import { useAuth } from "@/hooks/useAuth"; -import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation"; - interface SaveModalProps { isOpen: boolean; onClose: () => void; @@ -104,46 +102,6 @@ export const SaveModal: React.FC = ({ }; }, [onClose]); - // 필수 항목 검증 - const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => { - const missingFields: string[] = []; - - components.forEach((component) => { - // 컴포넌트의 required 속성 확인 (여러 위치에서 체크) - const isRequired = - component.required === true || - component.style?.required === true || - component.componentConfig?.required === true; - - const columnName = component.columnName || component.style?.columnName; - const label = component.label || component.style?.label || columnName; - - console.log("🔍 필수 항목 검증:", { - componentId: component.id, - columnName, - label, - isRequired, - "component.required": component.required, - "style.required": component.style?.required, - "componentConfig.required": component.componentConfig?.required, - value: formData[columnName || ""], - }); - - if (isRequired && columnName) { - const value = formData[columnName]; - // 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열) - if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) { - missingFields.push(label || columnName); - } - } - }); - - return { - isValid: missingFields.length === 0, - missingFields, - }; - }; - // 저장 핸들러 const handleSave = async () => { if (!screenData || !screenId) return; @@ -154,13 +112,6 @@ export const SaveModal: React.FC = ({ return; } - // ✅ 필수 항목 검증 - const validation = validateRequiredFields(); - if (!validation.isValid) { - toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`); - return; - } - try { setIsSaving(true); @@ -305,7 +256,6 @@ export const SaveModal: React.FC = ({ }; const dynamicSize = calculateDynamicSize(); - const isRequiredFieldsMissing = !checkAllRequiredFieldsFilled(components, formData); return ( !isSaving && !open && onClose()}> @@ -324,8 +274,7 @@ export const SaveModal: React.FC = ({
+ + + + + + 테이블을 찾을 수 없습니다. + + {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/api/flow.ts b/frontend/lib/api/flow.ts index ff2a81a2..3cb835cd 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -451,13 +451,15 @@ export async function moveData(data: MoveDataRequest): Promise> { return moveData({ flowId, - currentStepId, - dataId, + fromStepId, + recordId: String(recordId), + toStepId, }); } diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 85532c36..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); } @@ -619,18 +675,39 @@ export const DynamicComponentRenderer: 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; + // 🔧 수평 라벨(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); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 529f78fb..86027b01 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -27,8 +27,6 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { applyMappingRules } from "@/lib/utils/dataMapping"; import { apiClient } from "@/lib/api/client"; -import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation"; - export interface ButtonPrimaryComponentProps extends ComponentRendererProps { config?: ButtonPrimaryConfig; // 추가 props @@ -1259,16 +1257,8 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 모달 내 저장 버튼: 필수 필드 미입력 시 비활성화 - const isInModalContext = (props as any).isInModal === true; - const isSaveAction = processedConfig.action?.type === "save"; - const isRequiredFieldsMissing = isSaveAction && isInModalContext && allComponents - ? !checkAllRequiredFieldsFilled(allComponents, formData || {}) - : false; - - // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수 + 필수 필드 미입력) const finalDisabled = - componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading || isRequiredFieldsMissing; + componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; // 공통 버튼 스타일 // 🔧 component.style에서 background/backgroundColor 충돌 방지 @@ -1325,7 +1315,6 @@ export const ButtonPrimaryComponent: React.FC = ({
@@ -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) => ( +
+ ); +} 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,6 +1276,62 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 (DnD) */} + {config.columns && config.columns.length > 0 && ( +
+
+

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

+

+ 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다 +

+
+
+ { + 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)} + /> + ); + })} +
+
+
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1240,3 +1359,4 @@ export const TableListConfigPanel: React.FC = ({
); }; + diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 06226c9e..0b7aa47f 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -514,29 +514,38 @@ export function TableSectionRenderer({ loadColumnLabels(); }, [tableConfig.source.tableName, tableConfig.source.columnLabels]); - // 카테고리 타입 컬럼의 옵션 로드 + // 카테고리 타입 컬럼 + referenceDisplay 소스 카테고리 컬럼의 옵션 로드 useEffect(() => { const loadCategoryOptions = async () => { const sourceTableName = tableConfig.source.tableName; if (!sourceTableName) return; if (!tableConfig.columns) return; - // 카테고리 타입인 컬럼만 필터링 - const categoryColumns = tableConfig.columns.filter((col) => col.type === "category"); - if (categoryColumns.length === 0) return; - const newOptionsMap: Record = {}; + const loadedSourceColumns = new Set(); - for (const col of categoryColumns) { - // 소스 필드 또는 필드명으로 카테고리 값 조회 - const actualColumnName = col.sourceField || col.field; - if (!actualColumnName) continue; + const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); + + for (const col of tableConfig.columns) { + let sourceColumnName: string | undefined; + + if (col.type === "category") { + sourceColumnName = col.sourceField || col.field; + } else { + // referenceDisplay로 소스 카테고리 컬럼을 참조하는 컬럼도 포함 + const refSource = (col as any).saveConfig?.referenceDisplay?.sourceColumn; + if (refSource && sourceCategoryColumns.includes(refSource)) { + sourceColumnName = refSource; + } + } + + if (!sourceColumnName || loadedSourceColumns.has(`${col.field}:${sourceColumnName}`)) continue; + loadedSourceColumns.add(`${col.field}:${sourceColumnName}`); try { - const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); - const result = await getCategoryValues(sourceTableName, actualColumnName, false); - - if (result && result.success && Array.isArray(result.data)) { + const result = await getCategoryValues(sourceTableName, sourceColumnName, false); + + if (result?.success && Array.isArray(result.data)) { const options = result.data.map((item: any) => ({ value: item.valueCode || item.value_code || item.value || "", label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "", @@ -548,11 +557,13 @@ export function TableSectionRenderer({ } } - setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); + if (Object.keys(newOptionsMap).length > 0) { + setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); + } }; loadCategoryOptions(); - }, [tableConfig.source.tableName, tableConfig.columns]); + }, [tableConfig.source.tableName, tableConfig.columns, sourceCategoryColumns]); // receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드 useEffect(() => { @@ -630,42 +641,81 @@ export function TableSectionRenderer({ const loadDynamicOptions = async () => { setDynamicOptionsLoading(true); try { - // DISTINCT 값을 가져오기 위한 API 호출 - const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { - search: filterCondition ? { _raw: filterCondition } : {}, - size: 1000, - page: 1, - }); - - if (response.data.success && response.data.data?.data) { - const rows = response.data.data.data; - - // 중복 제거하여 고유 값 추출 - const uniqueValues = new Map(); - for (const row of rows) { - const value = row[valueColumn]; - if (value && !uniqueValues.has(value)) { - const label = labelColumn ? row[labelColumn] || value : value; - uniqueValues.set(value, label); + // 카테고리 값이 있는 컬럼인지 확인 (category_values 테이블에서 라벨 해결) + let categoryLabelMap: Record = {}; + try { + const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); + const catResult = await getCategoryValues(tableName, valueColumn, false); + if (catResult?.success && Array.isArray(catResult.data)) { + for (const item of catResult.data) { + const code = item.valueCode || item.value_code || item.value || ""; + const label = item.valueLabel || item.displayLabel || item.display_label || item.label || code; + if (code) categoryLabelMap[code] = label; } } + } catch { + // 카테고리 값이 없으면 무시 + } - // 옵션 배열로 변환 - const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({ + const hasCategoryValues = Object.keys(categoryLabelMap).length > 0; + + if (hasCategoryValues) { + // 카테고리 값이 정의되어 있으면 그대로 옵션으로 사용 + const options = Object.entries(categoryLabelMap).map(([code, label], index) => ({ id: `dynamic_${index}`, - value, + value: code, label, })); - console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { + console.log("[TableSectionRenderer] 카테고리 기반 옵션 로드 완료:", { tableName, valueColumn, optionCount: options.length, - options, }); setDynamicOptions(options); dynamicOptionsLoadedRef.current = true; + } else { + // 카테고리 값이 없으면 기존 방식: DISTINCT 값에서 추출 (쉼표 다중값 분리) + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + search: filterCondition ? { _raw: filterCondition } : {}, + size: 1000, + page: 1, + }); + + if (response.data.success && response.data.data?.data) { + const rows = response.data.data.data; + + const uniqueValues = new Map(); + for (const row of rows) { + const rawValue = row[valueColumn]; + if (!rawValue) continue; + + // 쉼표 구분 다중값을 개별로 분리 + const values = String(rawValue).split(",").map((v: string) => v.trim()).filter(Boolean); + for (const v of values) { + if (!uniqueValues.has(v)) { + const label = labelColumn ? row[labelColumn] || v : v; + uniqueValues.set(v, label); + } + } + } + + const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({ + id: `dynamic_${index}`, + value, + label, + })); + + console.log("[TableSectionRenderer] DISTINCT 기반 옵션 로드 완료:", { + tableName, + valueColumn, + optionCount: options.length, + }); + + setDynamicOptions(options); + dynamicOptionsLoadedRef.current = true; + } } } catch (error) { console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error); @@ -1019,34 +1069,24 @@ export function TableSectionRenderer({ ); // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) + // 조건부 테이블은 별도 useEffect에서 applyConditionalGrouping으로 처리 useEffect(() => { - // 이미 초기화되었으면 스킵 if (initialDataLoadedRef.current) return; + if (isConditionalMode) return; const tableSectionKey = `__tableSection_${sectionId}`; const initialData = formData[tableSectionKey]; - console.log("[TableSectionRenderer] 초기 데이터 확인:", { - sectionId, - tableSectionKey, - hasInitialData: !!initialData, - initialDataLength: Array.isArray(initialData) ? initialData.length : 0, - formDataKeys: Object.keys(formData).filter(k => k.startsWith("__tableSection_")), - }); - if (Array.isArray(initialData) && initialData.length > 0) { - console.log("[TableSectionRenderer] 초기 데이터 로드:", { + console.warn("[TableSectionRenderer] 비조건부 초기 데이터 로드:", { sectionId, itemCount: initialData.length, - firstItem: initialData[0], }); setTableData(initialData); initialDataLoadedRef.current = true; - - // 참조 컬럼 값 조회 (saveToTarget: false인 컬럼) loadReferenceColumnValues(initialData); } - }, [sectionId, formData, loadReferenceColumnValues]); + }, [sectionId, formData, isConditionalMode, loadReferenceColumnValues]); // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) const columns: RepeaterColumnConfig[] = useMemo(() => { @@ -1068,10 +1108,23 @@ export function TableSectionRenderer({ }); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); - // categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생 + // categoryOptionsMap + dynamicOptions에서 RepeaterTable용 카테고리 정보 파생 const tableCategoryColumns = useMemo(() => { - return Object.keys(categoryOptionsMap); - }, [categoryOptionsMap]); + const cols = new Set(Object.keys(categoryOptionsMap)); + // 조건부 테이블의 conditionColumn과 매핑된 컬럼도 카테고리 컬럼으로 추가 + if (isConditionalMode && conditionalConfig?.conditionColumn && dynamicOptions.length > 0) { + // 조건 컬럼 자체 + cols.add(conditionalConfig.conditionColumn); + // referenceDisplay로 조건 컬럼의 소스를 참조하는 컬럼도 추가 + for (const col of tableConfig.columns || []) { + const refDisplay = (col as any).saveConfig?.referenceDisplay; + if (refDisplay?.sourceColumn === conditionalConfig.conditionColumn) { + cols.add(col.field); + } + } + } + return Array.from(cols); + }, [categoryOptionsMap, isConditionalMode, conditionalConfig?.conditionColumn, dynamicOptions, tableConfig.columns]); const tableCategoryLabelMap = useMemo(() => { const map: Record = {}; @@ -1082,8 +1135,14 @@ export function TableSectionRenderer({ } } } + // 조건부 테이블 동적 옵션의 카테고리 코드→라벨 매핑도 추가 + for (const opt of dynamicOptions) { + if (opt.value && opt.label && opt.value !== opt.label) { + map[opt.value] = opt.label; + } + } return map; - }, [categoryOptionsMap]); + }, [categoryOptionsMap, dynamicOptions]); // 원본 계산 규칙 (조건부 계산 포함) const originalCalculationRules: TableCalculationRule[] = useMemo( @@ -1606,10 +1665,9 @@ export function TableSectionRenderer({ const multiSelect = uiConfig?.multiSelect ?? true; // 버튼 표시 설정 (두 버튼 동시 표시 가능) - // 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환 - const legacyAddButtonType = uiConfig?.addButtonType; - const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true); - const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false); + // showSearchButton/showAddRowButton 신규 필드 우선, 레거시 addButtonType은 신규 필드 없을 때만 참고 + const showSearchButton = uiConfig?.showSearchButton ?? true; + const showAddRowButton = uiConfig?.showAddRowButton ?? false; const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색"; const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력"; @@ -1641,8 +1699,9 @@ export function TableSectionRenderer({ const filter = { ...baseFilterCondition }; // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용 + // __like 연산자로 ILIKE 포함 검색 (쉼표 구분 다중값 매칭 지원) if (conditionalConfig?.sourceFilter?.enabled && modalCondition) { - filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition; + filter[`${conditionalConfig.sourceFilter.filterColumn}__like`] = modalCondition; } return filter; @@ -1771,7 +1830,29 @@ export function TableSectionRenderer({ async (items: any[]) => { if (!modalCondition) return; - // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 + // autoFillColumns 매핑 빌드: targetField → sourceColumn + const autoFillMap: Record = {}; + for (const col of tableConfig.columns) { + const dso = (col as any).dynamicSelectOptions; + if (dso?.sourceField) { + autoFillMap[col.field] = dso.sourceField; + } + if (dso?.rowSelectionMode?.autoFillColumns) { + for (const af of dso.rowSelectionMode.autoFillColumns) { + autoFillMap[af.targetField] = af.sourceColumn; + } + } + } + // referenceDisplay에서도 매핑 추가 + for (const col of tableConfig.columns) { + if (!autoFillMap[col.field]) { + const refDisplay = (col as any).saveConfig?.referenceDisplay; + if (refDisplay?.sourceColumn) { + autoFillMap[col.field] = refDisplay.sourceColumn; + } + } + } + const mappedItems = await Promise.all( items.map(async (sourceItem) => { const newItem: any = {}; @@ -1779,6 +1860,15 @@ export function TableSectionRenderer({ for (const col of tableConfig.columns) { const mapping = col.valueMapping; + // autoFill 또는 referenceDisplay 매핑이 있으면 우선 사용 + const autoFillSource = autoFillMap[col.field]; + if (!mapping && autoFillSource) { + if (sourceItem[autoFillSource] !== undefined) { + newItem[col.field] = sourceItem[autoFillSource]; + } + continue; + } + // 소스 필드에서 값 복사 (기본) if (!mapping) { const sourceField = col.sourceField || col.field; @@ -1896,45 +1986,146 @@ export function TableSectionRenderer({ [addEmptyRowToCondition], ); + // 조건부 테이블: 초기 데이터를 그룹핑하여 표시하는 헬퍼 + const applyConditionalGrouping = useCallback((data: any[]) => { + const conditionColumn = conditionalConfig?.conditionColumn; + console.warn(`[applyConditionalGrouping] 호출됨:`, { + conditionColumn, + dataLength: data.length, + sampleConditions: data.slice(0, 3).map(r => r[conditionColumn || ""]), + }); + if (!conditionColumn || data.length === 0) return; + + const grouped: ConditionalTableData = {}; + const conditions = new Set(); + + for (const row of data) { + const conditionValue = row[conditionColumn] || ""; + if (conditionValue) { + if (!grouped[conditionValue]) { + grouped[conditionValue] = []; + } + grouped[conditionValue].push(row); + conditions.add(conditionValue); + } + } + + setConditionalTableData(grouped); + setSelectedConditions(Array.from(conditions)); + + if (conditions.size > 0) { + setActiveConditionTab(Array.from(conditions)[0]); + } + + initialDataLoadedRef.current = true; + }, [conditionalConfig?.conditionColumn]); + // 조건부 테이블: 초기 데이터 로드 (수정 모드) useEffect(() => { if (!isConditionalMode) return; if (initialDataLoadedRef.current) return; - const tableSectionKey = `_tableSection_${sectionId}`; - const initialData = formData[tableSectionKey]; + const initialData = + formData[`_tableSection_${sectionId}`] || + formData[`__tableSection_${sectionId}`]; + + console.warn(`[TableSectionRenderer] 초기 데이터 로드 체크:`, { + sectionId, + hasUnderscoreData: !!formData[`_tableSection_${sectionId}`], + hasDoubleUnderscoreData: !!formData[`__tableSection_${sectionId}`], + dataLength: Array.isArray(initialData) ? initialData.length : "not array", + initialDataLoaded: initialDataLoadedRef.current, + }); if (Array.isArray(initialData) && initialData.length > 0) { - const conditionColumn = conditionalConfig?.conditionColumn; - - if (conditionColumn) { - // 조건별로 데이터 그룹핑 - const grouped: ConditionalTableData = {}; - const conditions = new Set(); - - for (const row of initialData) { - const conditionValue = row[conditionColumn] || ""; - if (conditionValue) { - if (!grouped[conditionValue]) { - grouped[conditionValue] = []; - } - grouped[conditionValue].push(row); - conditions.add(conditionValue); - } - } - - setConditionalTableData(grouped); - setSelectedConditions(Array.from(conditions)); - - // 첫 번째 조건을 활성 탭으로 설정 - if (conditions.size > 0) { - setActiveConditionTab(Array.from(conditions)[0]); - } - - initialDataLoadedRef.current = true; - } + applyConditionalGrouping(initialData); } - }, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]); + }, [isConditionalMode, sectionId, formData, applyConditionalGrouping]); + + // 조건부 테이블: formData에 데이터가 없으면 editConfig 기반으로 직접 API 로드 + const selfLoadAttemptedRef = React.useRef(false); + useEffect(() => { + if (!isConditionalMode) return; + if (initialDataLoadedRef.current) return; + if (selfLoadAttemptedRef.current) return; + + const editConfig = (tableConfig as any).editConfig; + const saveConfig = tableConfig.saveConfig; + const linkColumn = editConfig?.linkColumn; + const targetTable = saveConfig?.targetTable; + + console.warn(`[TableSectionRenderer] 자체 로드 체크:`, { + sectionId, + hasEditConfig: !!editConfig, + linkColumn, + targetTable, + masterField: linkColumn?.masterField, + masterValue: linkColumn?.masterField ? formData[linkColumn.masterField] : "N/A", + formDataKeys: Object.keys(formData).slice(0, 15), + initialDataLoaded: initialDataLoadedRef.current, + selfLoadAttempted: selfLoadAttemptedRef.current, + existingTableData_: !!formData[`_tableSection_${sectionId}`], + existingTableData__: !!formData[`__tableSection_${sectionId}`], + }); + + if (!linkColumn?.masterField || !linkColumn?.detailField || !targetTable) { + console.warn(`[TableSectionRenderer] 자체 로드 스킵: linkColumn/targetTable 미설정`); + return; + } + + const masterValue = formData[linkColumn.masterField]; + if (!masterValue) { + console.warn(`[TableSectionRenderer] 자체 로드 대기: masterField=${linkColumn.masterField} 값 없음`); + return; + } + + // formData에 테이블 섹션 데이터가 이미 있으면 해당 데이터 사용 + const existingData = + formData[`_tableSection_${sectionId}`] || + formData[`__tableSection_${sectionId}`]; + if (Array.isArray(existingData) && existingData.length > 0) { + console.warn(`[TableSectionRenderer] 기존 데이터 발견, applyConditionalGrouping 호출: ${existingData.length}건`); + applyConditionalGrouping(existingData); + return; + } + + selfLoadAttemptedRef.current = true; + console.warn(`[TableSectionRenderer] 자체 API 로드 시작: ${targetTable}, ${linkColumn.detailField}=${masterValue}`); + + const loadDetailData = async () => { + try { + const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, { + search: { + [linkColumn.detailField]: { value: masterValue, operator: "equals" }, + }, + page: 1, + size: 1000, + autoFilter: { enabled: true }, + }); + + if (response.data?.success) { + let items: any[] = []; + const data = response.data.data; + if (Array.isArray(data)) items = data; + else if (data?.items && Array.isArray(data.items)) items = data.items; + else if (data?.rows && Array.isArray(data.rows)) items = data.rows; + else if (data?.data && Array.isArray(data.data)) items = data.data; + + console.warn(`[TableSectionRenderer] 자체 데이터 로드 완료: ${items.length}건`); + + if (items.length > 0) { + applyConditionalGrouping(items); + } + } else { + console.warn(`[TableSectionRenderer] API 응답 실패:`, response.data); + } + } catch (error) { + console.error(`[TableSectionRenderer] 자체 데이터 로드 실패:`, error); + } + }; + + loadDetailData(); + }, [isConditionalMode, sectionId, formData, tableConfig, applyConditionalGrouping]); // 조건부 테이블: 전체 항목 수 계산 const totalConditionalItems = useMemo(() => { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 6d55b650..7e91d8b9 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -224,23 +224,38 @@ export function UniversalFormModalComponent({ // 설정 병합 const config: UniversalFormModalConfig = useMemo(() => { const componentConfig = component?.config || {}; + + // V2 레이아웃에서 overrides 전체가 config로 전달되는 경우 + // 실제 설정이 propConfig.componentConfig에 이중 중첩되어 있을 수 있음 + const nestedPropConfig = propConfig?.componentConfig; + const hasFlatPropConfig = propConfig?.modal !== undefined || propConfig?.sections !== undefined; + const effectivePropConfig = hasFlatPropConfig + ? propConfig + : (nestedPropConfig?.modal ? nestedPropConfig : propConfig); + + const nestedCompConfig = componentConfig?.componentConfig; + const hasFlatCompConfig = componentConfig?.modal !== undefined || componentConfig?.sections !== undefined; + const effectiveCompConfig = hasFlatCompConfig + ? componentConfig + : (nestedCompConfig?.modal ? nestedCompConfig : componentConfig); + return { ...defaultConfig, - ...propConfig, - ...componentConfig, + ...effectivePropConfig, + ...effectiveCompConfig, modal: { ...defaultConfig.modal, - ...propConfig?.modal, - ...componentConfig.modal, + ...effectivePropConfig?.modal, + ...effectiveCompConfig?.modal, }, saveConfig: { ...defaultConfig.saveConfig, - ...propConfig?.saveConfig, - ...componentConfig.saveConfig, + ...effectivePropConfig?.saveConfig, + ...effectiveCompConfig?.saveConfig, afterSave: { ...defaultConfig.saveConfig.afterSave, - ...propConfig?.saveConfig?.afterSave, - ...componentConfig.saveConfig?.afterSave, + ...effectivePropConfig?.saveConfig?.afterSave, + ...effectiveCompConfig?.saveConfig?.afterSave, }, }, }; @@ -295,6 +310,7 @@ export function UniversalFormModalComponent({ const hasInitialized = useRef(false); // 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요) const lastInitializedId = useRef(undefined); + const tableSectionLoadedRef = useRef(false); // 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행 useEffect(() => { @@ -316,7 +332,7 @@ export function UniversalFormModalComponent({ if (hasInitialized.current && lastInitializedId.current === currentIdString) { // 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요 if (!createModeDataHash || capturedInitialData.current) { - // console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨"); + // console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨", { currentIdString }); // 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요 // (컴포넌트 remount로 인해 state가 초기화된 경우) return; @@ -350,21 +366,13 @@ export function UniversalFormModalComponent({ // console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current); } - // console.log("[UniversalFormModal] initializeForm 호출 예정"); + // console.log("[UniversalFormModal] initializeForm 호출 예정", { currentIdString }); hasInitialized.current = true; + tableSectionLoadedRef.current = false; initializeForm(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialData]); // initialData 전체 변경 시 재초기화 - // config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외 - useEffect(() => { - if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵 - - // console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)"); - // initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config]); - // 컴포넌트 unmount 시 채번 플래그 초기화 useEffect(() => { return () => { @@ -728,9 +736,13 @@ export function UniversalFormModalComponent({ // 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조) // 수정 모드일 때 디테일 테이블에서 데이터 가져오기 if (effectiveInitialData) { - console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { - sectionsCount: config.sections.length, - effectiveInitialDataKeys: Object.keys(effectiveInitialData), + // console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { sectionsCount: config.sections.length }); + + console.warn("[initializeForm] 테이블 섹션 순회 시작:", { + sectionCount: config.sections.length, + tableSections: config.sections.filter(s => s.type === "table").map(s => s.id), + hasInitialData: !!effectiveInitialData, + initialDataKeys: effectiveInitialData ? Object.keys(effectiveInitialData).slice(0, 10) : [], }); for (const section of config.sections) { @@ -739,16 +751,14 @@ export function UniversalFormModalComponent({ } const tableConfig = section.tableConfig; - // editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음 const editConfig = (tableConfig as any).editConfig; const saveConfig = tableConfig.saveConfig; - console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, { - hasEditConfig: !!editConfig, - loadOnEdit: editConfig?.loadOnEdit, - hasSaveConfig: !!saveConfig, + console.warn(`[initializeForm] 테이블 섹션 ${section.id}:`, { + editConfig, targetTable: saveConfig?.targetTable, - linkColumn: editConfig?.linkColumn, + masterField: editConfig?.linkColumn?.masterField, + masterValue: effectiveInitialData?.[editConfig?.linkColumn?.masterField], }); // 수정 모드 로드 설정 확인 (기본값: true) @@ -1073,6 +1083,25 @@ export function UniversalFormModalComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) + // config 변경 시 테이블 섹션 데이터 로드 보완 + // initializeForm은 initialData useEffect에서 호출되지만, config(화면 설정)이 + // 비동기 로드로 늦게 도착하면 테이블 섹션 로드를 놓칠 수 있음 + useEffect(() => { + if (!hasInitialized.current) return; + + const hasTableSection = config.sections.some(s => s.type === "table" && s.tableConfig?.saveConfig?.targetTable); + if (!hasTableSection) return; + + const editData = capturedInitialData.current || initialData; + if (!editData || Object.keys(editData).length === 0) return; + + if (tableSectionLoadedRef.current) return; + + tableSectionLoadedRef.current = true; + initializeForm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.sections, initializeForm]); + // 반복 섹션 아이템 생성 const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { const item: RepeatSectionItem = { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 16b0fc81..575b9482 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -47,14 +47,22 @@ export function UniversalFormModalConfigPanel({ onChange, allComponents = [], }: UniversalFormModalConfigPanelProps) { - // config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용 + // V2 레이아웃에서 overrides 전체가 componentConfig로 전달되는 경우 + // 실제 설정이 rawConfig.componentConfig에 이중 중첩되어 있을 수 있음 + // 평탄화된 구조(save 후)가 있으면 우선, 아니면 중첩 구조에서 추출 + const nestedConfig = rawConfig?.componentConfig; + const hasFlatConfig = rawConfig?.modal !== undefined || rawConfig?.sections !== undefined; + const effectiveConfig = hasFlatConfig + ? rawConfig + : (nestedConfig?.modal ? nestedConfig : rawConfig); + const config: UniversalFormModalConfig = { ...defaultConfig, - ...rawConfig, - modal: { ...defaultConfig.modal, ...rawConfig?.modal }, - sections: rawConfig?.sections ?? defaultConfig.sections, - saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig }, - editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode }, + ...effectiveConfig, + modal: { ...defaultConfig.modal, ...effectiveConfig?.modal }, + sections: effectiveConfig?.sections ?? defaultConfig.sections, + saveConfig: { ...defaultConfig.saveConfig, ...effectiveConfig?.saveConfig }, + editMode: { ...defaultConfig.editMode, ...effectiveConfig?.editMode }, }; // 테이블 목록 diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index 1970f1a5..7bda67b2 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -2721,9 +2721,12 @@ export function TableSectionSettingsModal({ }; const updateUiConfig = (updates: Partial>) => { - updateTableConfig({ - uiConfig: { ...tableConfig.uiConfig, ...updates }, - }); + const newUiConfig = { ...tableConfig.uiConfig, ...updates }; + // 새 버튼 설정이 사용되면 레거시 addButtonType 제거 + if ("showSearchButton" in updates || "showAddRowButton" in updates) { + delete (newUiConfig as any).addButtonType; + } + updateTableConfig({ uiConfig: newUiConfig }); }; const updateSaveConfig = (updates: Partial>) => { 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(() => { diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 4e10f77c..c1014ec2 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -28,8 +28,6 @@ import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelC import { applyMappingRules } from "@/lib/utils/dataMapping"; import { apiClient } from "@/lib/api/client"; import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core"; -import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation"; - export interface ButtonPrimaryComponentProps extends ComponentRendererProps { config?: ButtonPrimaryConfig; // 추가 props @@ -1373,16 +1371,8 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 모달 내 저장 버튼: 필수 필드 미입력 시 비활성화 - const isInModalContext = (props as any).isInModal === true; - const isSaveAction = processedConfig.action?.type === "save"; - const isRequiredFieldsMissing = isSaveAction && isInModalContext && allComponents - ? !checkAllRequiredFieldsFilled(allComponents, formData || {}) - : false; - - // 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수 + 필수 필드 미입력) const finalDisabled = - componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading || isRequiredFieldsMissing; + componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; // 공통 버튼 스타일 // 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용) @@ -1469,7 +1459,6 @@ export const ButtonPrimaryComponent: React.FC = ({ +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -366,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, @@ -1458,6 +1523,63 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 (DnD) */} + {config.columns && config.columns.length > 0 && ( +
+
+

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

+

+ 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다 +

+
+
+ { + 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)} + /> + ); + })} +
+
+
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1484,3 +1606,4 @@ export const TableListConfigPanel: React.FC = ({
); }; + 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) { diff --git a/frontend/lib/utils/formValidation.ts b/frontend/lib/utils/formValidation.ts index b51c4ead..78f0cd03 100644 --- a/frontend/lib/utils/formValidation.ts +++ b/frontend/lib/utils/formValidation.ts @@ -662,75 +662,3 @@ const calculateStringSimilarity = (str1: string, str2: string): number => { return maxLen === 0 ? 1 : (maxLen - distance) / maxLen; }; -/** - * 값이 비어있는지 판별 - */ -export const isFieldEmpty = (value: any): boolean => { - return ( - value === null || - value === undefined || - (typeof value === "string" && value.trim() === "") || - (Array.isArray(value) && value.length === 0) - ); -}; - -/** - * 입력 가능한 컴포넌트인지 판별 (위젯 또는 V2 입력 컴포넌트) - */ -const isInputComponent = (comp: any): boolean => { - if (comp.type === "widget") return true; - - if (comp.type === "component") { - const ct = comp.componentType || ""; - return ct.startsWith("v2-input") || - ct.startsWith("v2-select") || - ct.startsWith("v2-date") || - ct.startsWith("v2-textarea") || - ct.startsWith("v2-number") || - ct === "entity-search-input" || - ct === "autocomplete-search-input"; - } - return false; -}; - -/** - * 모든 필수 필드가 채워졌는지 판별 (모달 저장 버튼 비활성화용) - * auto 입력 필드, readonly 필드는 검증 제외 - */ -export const checkAllRequiredFieldsFilled = ( - allComponents: any[], - formData: Record, -): boolean => { - for (const comp of allComponents) { - if (!isInputComponent(comp)) continue; - - const isRequired = - comp.required === true || - comp.style?.required === true || - comp.componentConfig?.required === true || - comp.overrides?.required === true; - if (!isRequired) continue; - - const isAutoInput = - comp.inputType === "auto" || - comp.componentConfig?.inputType === "auto" || - comp.overrides?.inputType === "auto"; - const isReadonly = - comp.readonly === true || - comp.componentConfig?.readonly === true || - comp.overrides?.readonly === true; - if (isAutoInput || isReadonly) continue; - - const fieldName = - comp.columnName || - comp.componentConfig?.columnName || - comp.overrides?.columnName || - comp.id; - const value = formData[fieldName]; - - if (isFieldEmpty(value)) { - return false; - } - } - return true; -}; diff --git a/frontend/types/flow.ts b/frontend/types/flow.ts index 9cfa1eb2..878f8b35 100644 --- a/frontend/types/flow.ts +++ b/frontend/types/flow.ts @@ -235,6 +235,7 @@ export interface FlowStepDataList { export interface MoveDataRequest { flowId: number; + fromStepId: number; recordId: string; toStepId: number; note?: string; diff --git a/frontend/types/v2-repeater.ts b/frontend/types/v2-repeater.ts index 96441d48..2d9199b4 100644 --- a/frontend/types/v2-repeater.ts +++ b/frontend/types/v2-repeater.ts @@ -50,11 +50,13 @@ export interface RepeaterColumnConfig { width: ColumnWidthOption; visible: boolean; editable?: boolean; // 편집 가능 여부 (inline 모드) - hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨) + hidden?: boolean; // 히든 처리 (화면에 안 보이지만 저장됨) isJoinColumn?: boolean; sourceTable?: string; - // 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시) + // 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시) isSourceDisplay?: boolean; + // 소스 데이터의 다른 컬럼명에서 값을 매핑 (예: qty ← order_qty) + sourceKey?: string; // 입력 타입 (테이블 타입 관리의 inputType을 따름) inputType?: string; // text, number, date, code, entity 등 // 🆕 자동 입력 설정 @@ -140,6 +142,20 @@ export interface CalculationRule { label?: string; } +// 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회) +export interface SourceDetailConfig { + tableName: string; // 디테일 테이블명 (예: "sales_order_detail") + foreignKey: string; // 디테일 테이블의 FK 컬럼 (예: "order_no") + parentKey: string; // 전달받은 마스터 데이터에서 추출할 키 (예: "order_no") + useEntityJoin?: boolean; // 엔티티 조인 사용 여부 (data-with-joins API) + columnMapping?: Record; // 리피터 컬럼 ← 조인 alias 매핑 (예: { "part_name": "part_code_item_name" }) + additionalJoinColumns?: Array<{ + sourceColumn: string; + sourceTable: string; + joinAlias: string; + }>; +} + // 메인 설정 타입 export interface V2RepeaterConfig { // 렌더링 모드 @@ -151,6 +167,9 @@ export interface V2RepeaterConfig { foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id) foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용 + // 소스 디테일 자동 조회 설정 (선택된 마스터의 디테일 행을 리피터로 로드) + sourceDetailConfig?: SourceDetailConfig; + // 데이터 소스 설정 dataSource: RepeaterDataSource; @@ -189,6 +208,7 @@ export interface V2RepeaterProps { onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; className?: string; formData?: Record; // 수정 모드에서 FK 기반 데이터 로드용 + groupedData?: Record[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용) } // 기본 설정값