Compare commits

...

8 Commits

Author SHA1 Message Date
syc0123 dca89a698f Merge remote-tracking branch 'origin/ycshin-node' into ycshin-node
Resolve conflict in InteractiveScreenViewerDynamic.tsx:
- Keep horizontal label code (fd5c61b side)
- Remove old inline required field validation (replaced by useDialogAutoValidation hook)
- Clean up checkAllRequiredFieldsFilled usage from SaveModal, ButtonPrimaryComponent
- Remove isFieldEmpty, isInputComponent, checkAllRequiredFieldsFilled from formValidation.ts

Made-with: Cursor
2026-03-03 13:12:48 +09:00
kjs fd5c61b12a feat: Implement company code validation in flow management
- Enhanced the FlowController to include user company code validation for flow definitions, ensuring that users can only access and modify flows belonging to their company.
- Updated the FlowDefinitionService to accept company code as a parameter for create, update, and delete operations, enforcing ownership checks.
- Introduced sanitization methods in FlowConditionParser to prevent SQL injection for column and table names.
- Modified the FlowDataMoveService to validate table names and column names during data movement operations, enhancing security.
- Updated the frontend components to support batch data movement with proper validation and error handling.
2026-03-03 10:38:38 +09:00
kjs e2d88f01e3 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-28 14:36:12 +09:00
kjs e16d76936b 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.
2026-02-28 14:33:18 +09:00
DDD1542 a8ad26cf30 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.
2026-02-27 15:24:55 +09:00
DDD1542 026e99511c 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.
2026-02-27 14:30:31 +09:00
DDD1542 21c0c2b95c 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.
2026-02-27 14:00:06 +09:00
DDD1542 1a6d78df43 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.
2026-02-27 13:30:57 +09:00
38 changed files with 1642 additions and 525 deletions

View File

@ -144,8 +144,9 @@ export class FlowController {
try { try {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); 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) { if (!definition) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
@ -182,12 +183,13 @@ export class FlowController {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); const flowId = parseInt(id);
const { name, description, isActive } = req.body; const { name, description, isActive } = req.body;
const userCompanyCode = (req as any).user?.companyCode;
const flowDef = await this.flowDefinitionService.update(flowId, { const flowDef = await this.flowDefinitionService.update(flowId, {
name, name,
description, description,
isActive, isActive,
}); }, userCompanyCode);
if (!flowDef) { if (!flowDef) {
res.status(404).json({ res.status(404).json({
@ -217,8 +219,9 @@ export class FlowController {
try { try {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); 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) { if (!success) {
res.status(404).json({ res.status(404).json({
@ -275,6 +278,7 @@ export class FlowController {
try { try {
const { flowId } = req.params; const { flowId } = req.params;
const flowDefinitionId = parseInt(flowId); const flowDefinitionId = parseInt(flowId);
const userCompanyCode = (req as any).user?.companyCode;
const { const {
stepName, stepName,
stepOrder, stepOrder,
@ -293,6 +297,16 @@ export class FlowController {
return; 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({ const step = await this.flowStepService.create({
flowDefinitionId, flowDefinitionId,
stepName, stepName,
@ -324,6 +338,7 @@ export class FlowController {
try { try {
const { stepId } = req.params; const { stepId } = req.params;
const id = parseInt(stepId); const id = parseInt(stepId);
const userCompanyCode = (req as any).user?.companyCode;
const { const {
stepName, stepName,
stepOrder, stepOrder,
@ -342,6 +357,19 @@ export class FlowController {
displayConfig, displayConfig,
} = req.body; } = 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, { const step = await this.flowStepService.update(id, {
stepName, stepName,
stepOrder, stepOrder,
@ -388,6 +416,20 @@ export class FlowController {
try { try {
const { stepId } = req.params; const { stepId } = req.params;
const id = parseInt(stepId); 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); const success = await this.flowStepService.delete(id);
@ -446,6 +488,7 @@ export class FlowController {
createConnection = async (req: Request, res: Response): Promise<void> => { createConnection = async (req: Request, res: Response): Promise<void> => {
try { try {
const { flowDefinitionId, fromStepId, toStepId, label } = req.body; const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
const userCompanyCode = (req as any).user?.companyCode;
if (!flowDefinitionId || !fromStepId || !toStepId) { if (!flowDefinitionId || !fromStepId || !toStepId) {
res.status(400).json({ res.status(400).json({
@ -455,6 +498,28 @@ export class FlowController {
return; 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({ const connection = await this.flowConnectionService.create({
flowDefinitionId, flowDefinitionId,
fromStepId, fromStepId,
@ -482,6 +547,20 @@ export class FlowController {
try { try {
const { connectionId } = req.params; const { connectionId } = req.params;
const id = parseInt(connectionId); 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); const success = await this.flowConnectionService.delete(id);
@ -670,23 +749,24 @@ export class FlowController {
*/ */
moveData = async (req: Request, res: Response): Promise<void> => { moveData = async (req: Request, res: Response): Promise<void> => {
try { try {
const { flowId, recordId, toStepId, note } = req.body; const { flowId, fromStepId, recordId, toStepId, note } = req.body;
const userId = (req as any).user?.userId || "system"; const userId = (req as any).user?.userId || "system";
if (!flowId || !recordId || !toStepId) { if (!flowId || !fromStepId || !recordId || !toStepId) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: "flowId, recordId, and toStepId are required", message: "flowId, fromStepId, recordId, and toStepId are required",
}); });
return; return;
} }
await this.flowDataMoveService.moveDataToStep( await this.flowDataMoveService.moveDataToStep(
flowId, flowId,
recordId, fromStepId,
toStepId, toStepId,
recordId,
userId, userId,
note note ? { note } : undefined
); );
res.json({ res.json({

View File

@ -132,14 +132,23 @@ export class FlowConditionParser {
/** /**
* SQL * SQL
*/ */
private static sanitizeColumnName(columnName: string): string { static sanitizeColumnName(columnName: string): string {
// 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원)
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) { if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
throw new Error(`Invalid column name: ${columnName}`); throw new Error(`Invalid column name: ${columnName}`);
} }
return 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;
}
/** /**
* *
*/ */

View File

@ -25,6 +25,7 @@ import {
buildInsertQuery, buildInsertQuery,
buildSelectQuery, buildSelectQuery,
} from "./dbQueryBuilder"; } from "./dbQueryBuilder";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowDataMoveService { export class FlowDataMoveService {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
@ -236,18 +237,19 @@ export class FlowDataMoveService {
); );
} }
const statusColumn = toStep.statusColumn; const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn);
const tableName = fromStep.tableName; const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName);
// 추가 필드 업데이트 준비 // 추가 필드 업데이트 준비
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`]; const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
const values: any[] = [dataId, toStep.statusValue]; const values: any[] = [dataId, toStep.statusValue];
let paramIndex = 3; let paramIndex = 3;
// 추가 데이터가 있으면 함께 업데이트 // 추가 데이터가 있으면 함께 업데이트 (키 검증 포함)
if (additionalData) { if (additionalData) {
for (const [key, value] of Object.entries(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); values.push(value);
paramIndex++; paramIndex++;
} }
@ -276,33 +278,38 @@ export class FlowDataMoveService {
dataId: any, dataId: any,
additionalData?: Record<string, any> additionalData?: Record<string, any>
): Promise<any> { ): Promise<any> {
const sourceTable = fromStep.tableName; const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName);
const targetTable = toStep.targetTable || toStep.tableName; const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName);
const fieldMappings = toStep.fieldMappings || {}; const fieldMappings = toStep.fieldMappings || {};
// 1. 소스 데이터 조회 // 1. 소스 데이터 조회
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`; const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
const sourceResult = await client.query(selectQuery, [dataId]); const sourceResult = await client.query(selectQuery, [dataId]);
if (sourceResult.length === 0) { if (sourceResult.rows.length === 0) {
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
} }
const sourceData = sourceResult[0]; const sourceData = sourceResult.rows[0];
// 2. 필드 매핑 적용 // 2. 필드 매핑 적용
const mappedData: Record<string, any> = {}; const mappedData: Record<string, any> = {};
// 매핑 정의가 있으면 적용 // 매핑 정의가 있으면 적용 (컬럼명 검증)
for (const [sourceField, targetField] of Object.entries(fieldMappings)) { for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
FlowConditionParser.sanitizeColumnName(sourceField);
FlowConditionParser.sanitizeColumnName(targetField as string);
if (sourceData[sourceField] !== undefined) { if (sourceData[sourceField] !== undefined) {
mappedData[targetField as string] = sourceData[sourceField]; mappedData[targetField as string] = sourceData[sourceField];
} }
} }
// 추가 데이터 병합 // 추가 데이터 병합 (키 검증)
if (additionalData) { if (additionalData) {
Object.assign(mappedData, additionalData); for (const [key, value] of Object.entries(additionalData)) {
const safeKey = FlowConditionParser.sanitizeColumnName(key);
mappedData[safeKey] = value;
}
} }
// 3. 타겟 테이블에 데이터 삽입 // 3. 타겟 테이블에 데이터 삽입
@ -321,7 +328,7 @@ export class FlowDataMoveService {
`; `;
const insertResult = await client.query(insertQuery, values); 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<string, string> = const stepDataMap: Record<string, string> =
mappingResult.length > 0 ? mappingResult[0].step_data_map : {}; mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {};
// 새 단계 데이터 추가 // 새 단계 데이터 추가
stepDataMap[String(currentStepId)] = String(targetDataId); stepDataMap[String(currentStepId)] = String(targetDataId);
if (mappingResult.length > 0) { if (mappingResult.rows.length > 0) {
// 기존 매핑 업데이트 // 기존 매핑 업데이트
const updateQuery = ` const updateQuery = `
UPDATE flow_data_mapping UPDATE flow_data_mapping
@ -366,7 +373,7 @@ export class FlowDataMoveService {
await client.query(updateQuery, [ await client.query(updateQuery, [
currentStepId, currentStepId,
JSON.stringify(stepDataMap), JSON.stringify(stepDataMap),
mappingResult[0].id, mappingResult.rows[0].id,
]); ]);
} else { } else {
// 새 매핑 생성 // 새 매핑 생성

View File

@ -19,7 +19,8 @@ export class FlowDefinitionService {
userId: string, userId: string,
userCompanyCode?: string userCompanyCode?: string
): Promise<FlowDefinition> { ): Promise<FlowDefinition> {
const companyCode = request.companyCode || userCompanyCode || "*"; // 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용
const companyCode = userCompanyCode || "*";
console.log("🔥 flowDefinitionService.create called with:", { console.log("🔥 flowDefinitionService.create called with:", {
name: request.name, name: request.name,
@ -118,10 +119,21 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async findById(id: number): Promise<FlowDefinition | null> { async findById(id: number, companyCode?: string): Promise<FlowDefinition | null> {
const query = "SELECT * FROM flow_definition WHERE id = $1"; let query: string;
const result = await db.query(query, [id]); 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) { if (result.length === 0) {
return null; return null;
@ -132,10 +144,12 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async update( async update(
id: number, id: number,
request: UpdateFlowDefinitionRequest request: UpdateFlowDefinitionRequest,
companyCode?: string
): Promise<FlowDefinition | null> { ): Promise<FlowDefinition | null> {
const fields: string[] = []; const fields: string[] = [];
const params: any[] = []; const params: any[] = [];
@ -160,18 +174,27 @@ export class FlowDefinitionService {
} }
if (fields.length === 0) { if (fields.length === 0) {
return this.findById(id); return this.findById(id, companyCode);
} }
fields.push(`updated_at = NOW()`); 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 = ` const query = `
UPDATE flow_definition UPDATE flow_definition
SET ${fields.join(", ")} SET ${fields.join(", ")}
WHERE id = $${paramIndex} ${whereClause}
RETURNING * RETURNING *
`; `;
params.push(id);
const result = await db.query(query, params); const result = await db.query(query, params);
@ -184,10 +207,21 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async delete(id: number): Promise<boolean> { async delete(id: number, companyCode?: string): Promise<boolean> {
const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; let query: string;
const result = await db.query(query, [id]); 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; return result.length > 0;
} }

View File

@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService";
import { FlowConditionParser } from "./flowConditionParser"; import { FlowConditionParser } from "./flowConditionParser";
import { executeExternalQuery } from "./externalDbHelper"; import { executeExternalQuery } from "./externalDbHelper";
import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowExecutionService { export class FlowExecutionService {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
@ -42,7 +43,7 @@ export class FlowExecutionService {
} }
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName; const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
// 4. 조건 JSON을 SQL WHERE절로 변환 // 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere( const { where, params } = FlowConditionParser.toSqlWhere(
@ -96,7 +97,7 @@ export class FlowExecutionService {
} }
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName; const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
// 4. 조건 JSON을 SQL WHERE절로 변환 // 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere( const { where, params } = FlowConditionParser.toSqlWhere(
@ -267,11 +268,12 @@ export class FlowExecutionService {
throw new Error(`Flow step not found: ${stepId}`); throw new Error(`Flow step not found: ${stepId}`);
} }
// 3. 테이블명 결정 // 3. 테이블명 결정 (SQL 인젝션 방지)
const tableName = step.tableName || flowDef.tableName; const rawTableName = step.tableName || flowDef.tableName;
if (!tableName) { if (!rawTableName) {
throw new Error("Table name not found"); throw new Error("Table name not found");
} }
const tableName = FlowConditionParser.sanitizeTableName(rawTableName);
// 4. Primary Key 컬럼 결정 (기본값: id) // 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id"; const primaryKeyColumn = flowDef.primaryKey || "id";
@ -280,8 +282,10 @@ export class FlowExecutionService {
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}` `🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
); );
// 5. SET 절 생성 // 5. SET 절 생성 (컬럼명 SQL 인젝션 방지)
const updateColumns = Object.keys(updateData); const updateColumns = Object.keys(updateData).map((col) =>
FlowConditionParser.sanitizeColumnName(col)
);
if (updateColumns.length === 0) { if (updateColumns.length === 0) {
throw new Error("No columns to update"); throw new Error("No columns to update");
} }

View File

@ -5083,8 +5083,8 @@ export class ScreenManagementService {
let layout: { layout_data: any } | null = null; let layout: { layout_data: any } | null = null;
// 🆕 기본 레이어(layer_id=1)를 우선 로드 // 🆕 기본 레이어(layer_id=1)를 우선 로드
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 // SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) { if (isSuperAdmin || companyCode === "*") {
// 1. 화면 정의의 회사 코드 + 기본 레이어 // 1. 화면 정의의 회사 코드 + 기본 레이어
layout = await queryOne<{ layout_data: any }>( layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 `SELECT layout_data FROM screen_layouts_v2

View File

@ -260,6 +260,7 @@ export interface FlowStepDataList {
// 데이터 이동 요청 // 데이터 이동 요청
export interface MoveDataRequest { export interface MoveDataRequest {
flowId: number; flowId: number;
fromStepId: number;
recordId: string; recordId: string;
toStepId: number; toStepId: number;
note?: string; note?: string;

View File

@ -189,7 +189,25 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
} else { } else {
// V1 레이아웃 또는 빈 레이아웃 // V1 레이아웃 또는 빈 레이아웃
const layoutData = await screenApi.getLayout(screenId); const layoutData = await screenApi.getLayout(screenId);
if (layoutData?.components?.length > 0) {
setLayout(layoutData); 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) { } catch (layoutError) {
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError); console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);

View File

@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, AlertCircle, ArrowRight } from "lucide-react"; 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"; import { toast } from "sonner";
interface FlowDataListModalProps { interface FlowDataListModalProps {
@ -102,15 +102,28 @@ export function FlowDataListModal({
try { try {
setMovingData(true); setMovingData(true);
// 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음) // 다음 스텝 결정 (연결 정보에서 조회)
const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id); const connResponse = await getFlowConnections(flowId);
if (!connResponse.success || !connResponse.data) {
// 데이터 이동 API 호출 throw new Error("플로우 연결 정보를 가져올 수 없습니다");
for (const dataId of selectedDataIds) {
const response = await moveDataToNextStep(flowId, stepId, dataId);
if (!response.success) {
throw new Error(`데이터 이동 실패: ${response.message}`);
} }
const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId);
if (!nextConn) {
throw new Error("다음 단계가 연결되어 있지 않습니다");
}
// 선택된 행의 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}건의 데이터를 다음 단계로 이동했습니다`); toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`);

View File

@ -422,9 +422,28 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// V2 없으면 기존 API fallback // V2 없으면 기존 API fallback
if (!layoutData) { if (!layoutData) {
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
layoutData = await screenApi.getLayout(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) { if (screenInfo && layoutData) {
const components = layoutData.components || []; const components = layoutData.components || [];
@ -1449,7 +1468,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent"> <div className="flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -1464,7 +1483,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
> >
<div <div
data-screen-runtime="true" data-screen-runtime="true"
className="relative bg-white" className="relative m-auto bg-white"
style={{ style={{
width: screenDimensions?.width || 800, width: screenDimensions?.width || 800,
// 조건부 레이어가 활성화되면 높이 자동 확장 // 조건부 레이어가 활성화되면 높이 자동 확장

View File

@ -2191,10 +2191,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 라벨 표시 여부 계산 // 라벨 표시 여부 계산
const shouldShowLabel = const shouldShowLabel =
!hideLabel && // hideLabel이 true면 라벨 숨김 !hideLabel &&
(component.style?.labelDisplay ?? true) && (component.style?.labelDisplay ?? true) !== false &&
component.style?.labelDisplay !== "false" &&
(component.label || component.style?.labelText) && (component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함 !templateTypes.includes(component.type);
const labelText = component.style?.labelText || component.label || ""; const labelText = component.style?.labelText || component.label || "";
@ -2232,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
...component, ...component,
style: { style: {
...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; : component;

View File

@ -14,7 +14,6 @@ import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { isFieldEmpty } from "@/lib/utils/formValidation";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management"; import { FlowVisibilityConfig } from "@/types/control-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
@ -1111,7 +1110,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
type === "v2-input" || type === "v2-select" || type === "v2-date" || type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date"; compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent && const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false && style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
(style?.labelText || (component as any).label); (style?.labelText || (component as any).label);
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요) // 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
@ -1121,41 +1120,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0; const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
// 필수 입력값 검증 (모달 내부에서만 에러 표시) // 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
const reqCompType = component.type; const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const reqCompComponentType = (component as any).componentType || ""; const isHorizLabel = labelPos === "left" || labelPos === "right";
const isInputLikeComponent = reqCompType === "widget" || ( const labelText = style?.labelText || (component as any).label || "";
reqCompType === "component" && ( const labelGapValue = style?.labelGap || "8px";
reqCompComponentType.startsWith("v2-input") ||
reqCompComponentType.startsWith("v2-select") ||
reqCompComponentType.startsWith("v2-date") ||
reqCompComponentType.startsWith("v2-textarea") ||
reqCompComponentType.startsWith("v2-number") ||
reqCompComponentType === "entity-search-input" ||
reqCompComponentType === "autocomplete-search-input"
)
);
const isRequiredWidget = isInputLikeComponent && (
(component as any).required === true ||
(style as any)?.required === true ||
(component as any).componentConfig?.required === true ||
(component as any).overrides?.required === true
);
const isAutoInputField =
(component as any).inputType === "auto" ||
(component as any).componentConfig?.inputType === "auto" ||
(component as any).overrides?.inputType === "auto";
const isReadonlyWidget =
(component as any).readonly === true ||
(component as any).componentConfig?.readonly === true ||
(component as any).overrides?.readonly === true;
const requiredFieldName =
(component as any).columnName ||
(component as any).componentConfig?.columnName ||
(component as any).overrides?.columnName ||
component.id;
const requiredFieldValue = formData[requiredFieldName];
const showRequiredError = isInModal && isRequiredWidget && !isAutoInputField && !isReadonlyWidget && isFieldEmpty(requiredFieldValue);
const calculateCanvasSplitX = (): { x: number; w: number } => { const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || ""; const compType = (component as any).componentType || "";
@ -1232,9 +1201,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지) // styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any; const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
const cleanedStyle = (isHorizLabel && needsExternalLabel)
? (() => {
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
return rest;
})()
: safeStyleWithoutSize;
const componentStyle = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
...safeStyleWithoutSize, ...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게) // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX, left: adjustedX,
top: position?.y || 0, top: position?.y || 0,
@ -1242,7 +1219,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
width: isSplitActive ? adjustedW : (size?.width || 200), width: isSplitActive ? adjustedW : (size?.width || 200),
height: isTableSearchWidget ? "auto" : size?.height || 10, height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined, minHeight: isTableSearchWidget ? "48px" : undefined,
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : ((labelOffset > 0 || showRequiredError) ? "visible" : undefined), overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined, willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition: isSplitActive transition: isSplitActive
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out") ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
@ -1305,11 +1282,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe; return unsubscribe;
}, [component.id, position?.x, size?.width, type]); }, [component.id, position?.x, size?.width, type]);
// 라벨 위치가 top이 아닌 경우: 외부에서 라벨을 렌더링하고 내부 라벨은 숨김 // needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
const externalLabelComponent = needsExternalLabel ? ( const externalLabelComponent = needsExternalLabel ? (
<label <label
@ -1330,36 +1303,80 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
) : null; ) : null;
const componentToRender = needsExternalLabel const componentToRender = needsExternalLabel
? { ...splitAdjustedComponent, style: { ...splitAdjustedComponent.style, labelDisplay: false } } ? {
...splitAdjustedComponent,
style: {
...splitAdjustedComponent.style,
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizLabel ? {
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
},
...(isHorizLabel ? {
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: splitAdjustedComponent; : splitAdjustedComponent;
return ( return (
<> <>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}> <div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{needsExternalLabel ? ( {needsExternalLabel ? (
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
className="text-sm font-medium leading-none"
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(labelPos === "left"
? { right: "100%", marginRight: labelGapValue }
: { left: "100%", marginLeft: labelGapValue }),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
whiteSpace: "nowrap",
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
) : (
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: isHorizLabel ? (labelPos === "left" ? "row" : "row-reverse") : "column-reverse", flexDirection: "column-reverse",
alignItems: isHorizLabel ? "center" : undefined,
gap: isHorizLabel ? labelGapValue : undefined,
width: "100%", width: "100%",
height: "100%", height: "100%",
}} }}
> >
{externalLabelComponent} {externalLabelComponent}
<div style={{ flex: 1, minWidth: 0, height: isHorizLabel ? "100%" : undefined }}> <div style={{ flex: 1, minWidth: 0 }}>
{renderInteractiveWidget(componentToRender)} {renderInteractiveWidget(componentToRender)}
</div> </div>
</div> </div>
)
) : ( ) : (
renderInteractiveWidget(componentToRender) renderInteractiveWidget(componentToRender)
)} )}
{showRequiredError && (
<p className="text-destructive pointer-events-none absolute left-0 text-[11px] leading-tight" style={{ top: "100%", marginTop: 2 }}>
</p>
)}
</div> </div>
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}

View File

@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const origWidth = size?.width || 100; const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth; 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 = { const baseStyle = {
left: `${adjustedPositionX}px`, left: `${adjustedPositionX}px`,
top: `${position.y}px`, top: `${position.y}px`,
...componentStyle, ...safeComponentStyle,
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth, width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight, height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2, zIndex: component.type === "layout" ? 1 : position.z || 2,

View File

@ -11,8 +11,6 @@ import { screenApi } from "@/lib/api/screen";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { ComponentData } from "@/lib/types/screen"; import { ComponentData } from "@/lib/types/screen";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation";
interface SaveModalProps { interface SaveModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@ -104,46 +102,6 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}; };
}, [onClose]); }, [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 () => { const handleSave = async () => {
if (!screenData || !screenId) return; if (!screenData || !screenId) return;
@ -154,13 +112,6 @@ export const SaveModal: React.FC<SaveModalProps> = ({
return; return;
} }
// ✅ 필수 항목 검증
const validation = validateRequiredFields();
if (!validation.isValid) {
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
return;
}
try { try {
setIsSaving(true); setIsSaving(true);
@ -305,7 +256,6 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}; };
const dynamicSize = calculateDynamicSize(); const dynamicSize = calculateDynamicSize();
const isRequiredFieldsMissing = !checkAllRequiredFieldsFilled(components, formData);
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
@ -324,8 +274,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={isSaving || isRequiredFieldsMissing} disabled={isSaving}
title={isRequiredFieldsMissing ? "필수 입력 항목을 모두 채워주세요" : undefined}
size="sm" size="sm"
className="gap-2" className="gap-2"
> >

View File

@ -700,7 +700,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
} }
}; };
const showLabel = label && style?.labelDisplay !== false; const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;

View File

@ -962,7 +962,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}; };
const actualLabel = label || style?.labelText; 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 componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;

View File

@ -48,6 +48,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
onRowClick, onRowClick,
className, className,
formData: parentFormData, formData: parentFormData,
groupedData,
...restProps ...restProps
}) => { }) => {
// componentId 결정: 직접 전달 또는 component 객체에서 추출 // componentId 결정: 직접 전달 또는 component 객체에서 추출
@ -419,6 +420,39 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
fkValue, fkValue,
}); });
let rows: any[] = [];
const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin;
if (useEntityJoinForLoad) {
// 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인)
const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue });
const params: Record<string, any> = {
page: 1,
size: 1000,
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<string>();
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( const response = await apiClient.post(
`/table-management/tables/${config.mainTableName}/data`, `/table-management/tables/${config.mainTableName}/data`,
{ {
@ -431,12 +465,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
autoFilter: true, 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) { if (Array.isArray(rows) && rows.length > 0) {
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : "");
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 // 엔티티 조인 사용 시: 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] 엔티티 조인 표시 데이터 보강 완료");
}
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시)
if (!useEntityJoinForLoad) {
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const sourceTable = config.dataSource?.sourceTable; const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey; const fkColumn = config.dataSource?.foreignKey;
@ -448,7 +498,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const uniqueValues = [...new Set(fkValues)]; const uniqueValues = [...new Set(fkValues)];
if (uniqueValues.length > 0) { if (uniqueValues.length > 0) {
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
const sourcePromises = uniqueValues.map((val) => const sourcePromises = uniqueValues.map((val) =>
apiClient.post(`/table-management/tables/${sourceTable}/data`, { apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1, size: 1, page: 1, size: 1,
@ -463,7 +512,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
}); });
// 각 행에 소스 테이블의 표시 데이터 병합
rows.forEach((row: any) => { rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn])); const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) { if (sourceRecord) {
@ -480,6 +528,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
} }
} }
}
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환 // DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
const codesToResolve = new Set<string>(); const codesToResolve = new Set<string>();
@ -964,8 +1013,113 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
[], [],
); );
// V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. // sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면
// EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. // 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅
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<string, any> = {
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<string>();
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 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
useEffect(() => { useEffect(() => {

View File

@ -1145,7 +1145,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
} }
}; };
const showLabel = label && style?.labelDisplay !== false; const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;

View File

@ -31,6 +31,7 @@ import {
Wand2, Wand2,
Check, Check,
ChevronsUpDown, ChevronsUpDown,
ListTree,
} from "lucide-react"; } from "lucide-react";
import { import {
Command, Command,
@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<Separator /> <Separator />
{/* 소스 디테일 자동 조회 설정 */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enableSourceDetail"
checked={!!config.sourceDetailConfig}
onCheckedChange={(checked) => {
if (checked) {
updateConfig({
sourceDetailConfig: {
tableName: "",
foreignKey: "",
parentKey: "",
},
});
} else {
updateConfig({ sourceDetailConfig: undefined });
}
}}
/>
<label htmlFor="enableSourceDetail" className="text-xs font-medium flex items-center gap-1">
<ListTree className="h-3 w-3" />
</label>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
{config.sourceDetailConfig && (
<div className="space-y-2 rounded border border-violet-200 bg-violet-50 p-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{config.sourceDetailConfig.tableName
? (allTables.find(t => t.tableName === config.sourceDetailConfig!.tableName)?.displayName || config.sourceDetailConfig.tableName)
: "테이블 선택..."
}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-48">
<CommandEmpty className="text-xs py-3 text-center"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => {
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
tableName: table.tableName,
},
});
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.sourceDetailConfig!.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
<span>{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> FK </Label>
<Input
value={config.sourceDetailConfig.foreignKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
foreignKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.sourceDetailConfig.parentKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
parentKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-violet-600">
[{config.sourceDetailConfig.parentKey || "?"}]
{" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"}
</p>
</div>
)}
</div>
<Separator />
{/* 기능 옵션 */} {/* 기능 옵션 */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>

View File

@ -451,13 +451,15 @@ export async function moveData(data: MoveDataRequest): Promise<ApiResponse<{ suc
*/ */
export async function moveDataToNextStep( export async function moveDataToNextStep(
flowId: number, flowId: number,
currentStepId: number, fromStepId: number,
dataId: number, toStepId: number,
recordId: string | number,
): Promise<ApiResponse<{ success: boolean }>> { ): Promise<ApiResponse<{ success: boolean }>> {
return moveData({ return moveData({
flowId, flowId,
currentStepId, fromStepId,
dataId, recordId: String(recordId),
toStepId,
}); });
} }

View File

@ -371,15 +371,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
try { try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
const fieldName = columnName || component.id; const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || "";
const handleChange = (value: any) => { // 수평 라벨 감지
if (props.onFormDataChange) { const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
props.onFormDataChange(fieldName, value); 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")
);
// V2SelectRenderer용 컴포넌트 데이터 구성
const selectComponent = { const selectComponent = {
...component, ...component,
componentConfig: { componentConfig: {
@ -395,6 +398,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
webType: "category", 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 = { const rendererProps = {
component: selectComponent, component: selectComponent,
formData: props.formData, formData: props.formData,
@ -402,12 +423,47 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isDesignMode: props.isDesignMode, isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive ?? !props.isDesignMode, isInteractive: props.isInteractive ?? !props.isDesignMode,
tableName, tableName,
style: (component as any).style, style: catStyle,
size: (component as any).size, size: catSize,
}; };
const rendererInstance = new V2SelectRenderer(rendererProps); 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 (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(isLeft
? { right: "100%", marginRight: labelGap }
: { left: "100%", marginLeft: labelGap }),
fontSize: labelFontSize,
color: labelColor,
fontWeight: labelFontWeight,
whiteSpace: "nowrap",
}}
className="text-sm font-medium"
>
{catLabelText}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedCatSelect}
</div>
</div>
);
}
return renderedCatSelect;
} catch (error) { } catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error); console.error("❌ V2SelectRenderer 로드 실패:", error);
} }
@ -619,18 +675,39 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "modal-repeater-table" || componentType === "modal-repeater-table" ||
componentType === "v2-input"; componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시) // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; 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) ? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined; : 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 기반으로 덮어씀 // 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
const mergedStyle = { const mergedStyle = {
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저! ...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고) // CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
width: finalStyle.width, width: finalStyle.width,
height: finalStyle.height, 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 최신 설정 우선) // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
@ -649,7 +726,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onClick, onClick,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
size: component.size || newComponent.defaultSize, size: needsExternalHorizLabel
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
: (component.size || newComponent.defaultSize),
position: component.position, position: component.position,
config: mergedComponentConfig, config: mergedComponentConfig,
componentConfig: mergedComponentConfig, componentConfig: mergedComponentConfig,
@ -657,8 +736,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
...(mergedComponentConfig || {}), ...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle, style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만) // 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: effectiveLabel, label: needsExternalHorizLabel ? undefined : effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선) // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType, inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName, columnName: (component as any).columnName || component.componentConfig?.columnName,
@ -759,16 +838,51 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
NewComponentRenderer.prototype && NewComponentRenderer.prototype &&
NewComponentRenderer.prototype.render; NewComponentRenderer.prototype.render;
let renderedElement: React.ReactElement;
if (isClass) { if (isClass) {
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
const rendererInstance = new NewComponentRenderer(rendererProps); const rendererInstance = new NewComponentRenderer(rendererProps);
return rendererInstance.render(); renderedElement = rendererInstance.render();
} else { } else {
// 함수형 컴포넌트 renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
} }
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 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 (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(isLeft
? { right: "100%", marginRight: labelGap }
: { left: "100%", marginLeft: labelGap }),
fontSize: labelFontSize,
color: labelColor,
fontWeight: labelFontWeight,
whiteSpace: "nowrap",
}}
className="text-sm font-medium"
>
{effectiveLabel}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedElement}
</div>
</div>
);
}
return renderedElement;
} }
} catch (error) { } catch (error) {
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error); console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);

View File

@ -27,8 +27,6 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping"; import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps { export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig; config?: ButtonPrimaryConfig;
// 추가 props // 추가 props
@ -1259,16 +1257,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 모달 내 저장 버튼: 필수 필드 미입력 시 비활성화
const isInModalContext = (props as any).isInModal === true;
const isSaveAction = processedConfig.action?.type === "save";
const isRequiredFieldsMissing = isSaveAction && isInModalContext && allComponents
? !checkAllRequiredFieldsFilled(allComponents, formData || {})
: false;
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수 + 필수 필드 미입력)
const finalDisabled = const finalDisabled =
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading || isRequiredFieldsMissing; componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일 // 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지 // 🔧 component.style에서 background/backgroundColor 충돌 방지
@ -1325,7 +1315,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
<button <button
type={componentConfig.actionType || "button"} type={componentConfig.actionType || "button"}
disabled={finalDisabled} disabled={finalDisabled}
title={isRequiredFieldsMissing ? "필수 입력 항목을 모두 채워주세요" : undefined}
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95" className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
style={buttonElementStyle} style={buttonElementStyle}
onClick={handleClick} onClick={handleClick}

View File

@ -553,14 +553,20 @@ export function RepeaterTable({
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field]; const value = row[column.field];
// 카테고리 라벨 변환 함수 // 카테고리/셀렉트 라벨 변환 함수
const getCategoryDisplayValue = (val: any): string => { const getCategoryDisplayValue = (val: any): string => {
if (!val || typeof val !== "string") return val || "-"; 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 fieldName = column.field.replace(/^_display_/, "");
const isCategoryColumn = categoryColumns.includes(fieldName); const isCategoryColumn = categoryColumns.includes(fieldName);
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) // categoryLabelMap에 직접 매핑이 있으면 바로 변환
if (categoryLabelMap[val]) return categoryLabelMap[val]; if (categoryLabelMap[val]) return categoryLabelMap[val];
// 카테고리 컬럼이 아니면 원래 값 반환 // 카테고리 컬럼이 아니면 원래 값 반환

View File

@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner"; import { toast } from "sonner";
import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
// 추가 props // 추가 props
@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null); const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null); const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
// 카테고리 코드→라벨 매핑
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 프론트엔드 그룹핑 함수 // 프론트엔드 그룹핑 함수
const groupData = useCallback( const groupData = useCallback(
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => { (data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
} }
}); });
// 탭 목록 생성 // 탭 목록 생성 (카테고리 라벨 변환 적용)
const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({ const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({
id: value, id: value,
label: value, label: categoryLabelMap[value] || value,
count: tabConfig.showCount ? count : 0, count: tabConfig.showCount ? count : 0,
})); }));
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
return tabs; return tabs;
}, },
[], [categoryLabelMap],
); );
// 탭으로 필터링된 데이터 반환 // 탭으로 필터링된 데이터 반환
@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId); console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
break; break;
case "edit": case "edit": {
// 좌측 패널에서 수정 (필요시 구현) if (!selectedLeftItem) {
console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn); toast.error("수정할 항목을 선택해주세요.");
return;
}
const editModalScreenId = btn.modalScreenId || config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId;
if (!editModalScreenId) {
toast.error("연결된 모달 화면이 없습니다.");
return;
}
const editEvent = new CustomEvent("openEditModal", {
detail: {
screenId: editModalScreenId,
title: btn.label || "수정",
modalSize: "lg",
editData: selectedLeftItem,
isCreateMode: false,
onSave: () => {
loadLeftData();
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(editEvent);
console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem);
break; break;
}
case "delete": case "delete":
// 좌측 패널에서 삭제 (필요시 구현) // 좌측 패널에서 삭제 (필요시 구현)
@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
break; break;
} }
}, },
[config.leftPanel?.addModalScreenId, loadLeftData], [config.leftPanel?.addModalScreenId, config.leftPanel?.editModalScreenId, loadLeftData, loadRightData, selectedLeftItem],
); );
// 컬럼 라벨 로드 // 컬럼 라벨 로드
@ -1241,6 +1273,55 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
config.rightPanel?.tableName, config.rightPanel?.tableName,
]); ]);
// 카테고리 컬럼에 대한 라벨 매핑 로드
useEffect(() => {
if (isDesignMode) return;
const loadCategoryLabels = async () => {
const allColumns = new Set<string>();
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<string, string> = {};
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 해제 // 컴포넌트 언마운트 시 DataProvider 해제
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
}; };
}, [screenContext, component.id]); }, [screenContext, component.id]);
// 카테고리 코드를 라벨로 변환
const resolveCategoryLabel = useCallback(
(value: any): string => {
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 및 엔티티 참조 고려) // 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려)
const getColumnValue = useCallback( const getColumnValue = useCallback(
(item: any, col: ColumnConfig): any => { (item: any, col: ColumnConfig): any => {
@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const displayColumns = config.leftPanel?.displayColumns || []; const displayColumns = config.leftPanel?.displayColumns || [];
const pkColumn = getLeftPrimaryKeyColumn(); const pkColumn = getLeftPrimaryKeyColumn();
// 값 렌더링 (배지 지원) // 값 렌더링 (배지 지원 + 카테고리 라벨 변환)
const renderCellValue = (item: any, col: ColumnConfig) => { const renderCellValue = (item: any, col: ColumnConfig) => {
const value = item[col.name]; const value = item[col.name];
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{value.map((v, vIdx) => ( {value.map((v, vIdx) => (
<Badge key={vIdx} variant="secondary" className="text-xs"> <Badge key={vIdx} variant="secondary" className="text-xs">
{formatValue(v, col.format)} {resolveCategoryLabel(v) || formatValue(v, col.format)}
</Badge> </Badge>
))} ))}
</div> </div>
@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 배지 타입이지만 단일 값인 경우 // 배지 타입이지만 단일 값인 경우
if (col.displayConfig?.displayType === "badge") { if (col.displayConfig?.displayType === "badge") {
const label = resolveCategoryLabel(value);
return ( return (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{formatValue(value, col.format)} {label !== String(value) ? label : formatValue(value, col.format)}
</Badge> </Badge>
); );
} }
// 기본 텍스트 // 카테고리 라벨 변환 시도 후 기본 텍스트
const label = resolveCategoryLabel(value);
if (label !== String(value)) return label;
return formatValue(value, col.format); return formatValue(value, col.format);
}; };
@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
/> />
</TableCell> </TableCell>
)} )}
{displayColumns.map((col, colIdx) => ( {displayColumns.map((col, colIdx) => {
<TableCell key={colIdx}>{formatValue(getColumnValue(item, col), col.format)}</TableCell> const rawVal = getColumnValue(item, col);
))} const resolved = resolveCategoryLabel(rawVal);
const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format);
return <TableCell key={colIdx}>{display || "-"}</TableCell>;
})}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex justify-center gap-1"> <div className="flex justify-center gap-1">
@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
config.leftPanel.actionButtons.length > 0 && ( config.leftPanel.actionButtons.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{config.leftPanel.actionButtons.map((btn, idx) => ( {config.leftPanel.actionButtons
.filter((btn) => {
if (btn.showCondition === "selected") return !!selectedLeftItem;
return true;
})
.map((btn, idx) => (
<Button <Button
key={idx} key={idx}
size="sm" size="sm"

View File

@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement"; 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, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; 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 (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
)}
<Input
value={col.displayName || col.columnName}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="표시명"
className="h-6 min-w-0 flex-1 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
className="h-6 w-14 shrink-0 text-xs"
/>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
<X className="h-3 w-3" />
</Button>
</div>
);
}
export interface TableListConfigPanelProps { export interface TableListConfigPanelProps {
config: TableListConfig; config: TableListConfig;
@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const existingColumn = config.columns?.find((col) => col.columnName === columnName); const existingColumn = config.columns?.find((col) => col.columnName === columnName);
if (existingColumn) return; if (existingColumn) return;
// tableColumns에서 해당 컬럼의 라벨 정보 찾기 // tableColumns → availableColumns 순서로 한국어 라벨 찾기
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); 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 || availableColumnInfo?.label || columnName;
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
const newColumn: ColumnConfig = { const newColumn: ColumnConfig = {
columnName, columnName,
@ -1213,6 +1276,62 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</> </>
)} )}
{/* 선택된 컬럼 순서 변경 (DnD) */}
{config.columns && config.columns.length > 0 && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> ({config.columns.length} )</h3>
<p className="text-muted-foreground text-[10px]">
/
</p>
</div>
<hr className="border-border" />
<DndContext
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
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);
}
}}
>
<SortableContext
items={(config.columns || []).map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{(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 (
<SortableColumnRow
key={column.columnName}
id={column.columnName}
col={colWithLabel}
index={idx}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
onRemove={() => removeColumn(column.columnName)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* 🆕 데이터 필터링 설정 */} {/* 🆕 데이터 필터링 설정 */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@ -1240,3 +1359,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
); );
}; };

View File

@ -514,29 +514,38 @@ export function TableSectionRenderer({
loadColumnLabels(); loadColumnLabels();
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]); }, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
// 카테고리 타입 컬럼의 옵션 로드 // 카테고리 타입 컬럼 + referenceDisplay 소스 카테고리 컬럼의 옵션 로드
useEffect(() => { useEffect(() => {
const loadCategoryOptions = async () => { const loadCategoryOptions = async () => {
const sourceTableName = tableConfig.source.tableName; const sourceTableName = tableConfig.source.tableName;
if (!sourceTableName) return; if (!sourceTableName) return;
if (!tableConfig.columns) return; if (!tableConfig.columns) return;
// 카테고리 타입인 컬럼만 필터링
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
if (categoryColumns.length === 0) return;
const newOptionsMap: Record<string, { value: string; label: string }[]> = {}; const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
const loadedSourceColumns = new Set<string>();
for (const col of categoryColumns) { const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
// 소스 필드 또는 필드명으로 카테고리 값 조회
const actualColumnName = col.sourceField || col.field; for (const col of tableConfig.columns) {
if (!actualColumnName) continue; 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 { try {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); const result = await getCategoryValues(sourceTableName, sourceColumnName, false);
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
if (result && result.success && Array.isArray(result.data)) { if (result?.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({ const options = result.data.map((item: any) => ({
value: item.valueCode || item.value_code || item.value || "", 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 || "", label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
@ -548,11 +557,13 @@ export function TableSectionRenderer({
} }
} }
if (Object.keys(newOptionsMap).length > 0) {
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
}
}; };
loadCategoryOptions(); loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]); }, [tableConfig.source.tableName, tableConfig.columns, sourceCategoryColumns]);
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드 // receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
useEffect(() => { useEffect(() => {
@ -630,7 +641,42 @@ export function TableSectionRenderer({
const loadDynamicOptions = async () => { const loadDynamicOptions = async () => {
setDynamicOptionsLoading(true); setDynamicOptionsLoading(true);
try { try {
// DISTINCT 값을 가져오기 위한 API 호출 // 카테고리 값이 있는 컬럼인지 확인 (category_values 테이블에서 라벨 해결)
let categoryLabelMap: Record<string, string> = {};
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 hasCategoryValues = Object.keys(categoryLabelMap).length > 0;
if (hasCategoryValues) {
// 카테고리 값이 정의되어 있으면 그대로 옵션으로 사용
const options = Object.entries(categoryLabelMap).map(([code, label], index) => ({
id: `dynamic_${index}`,
value: code,
label,
}));
console.log("[TableSectionRenderer] 카테고리 기반 옵션 로드 완료:", {
tableName,
valueColumn,
optionCount: options.length,
});
setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true;
} else {
// 카테고리 값이 없으면 기존 방식: DISTINCT 값에서 추출 (쉼표 다중값 분리)
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: filterCondition ? { _raw: filterCondition } : {}, search: filterCondition ? { _raw: filterCondition } : {},
size: 1000, size: 1000,
@ -640,33 +686,37 @@ export function TableSectionRenderer({
if (response.data.success && response.data.data?.data) { if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data; const rows = response.data.data.data;
// 중복 제거하여 고유 값 추출
const uniqueValues = new Map<string, string>(); const uniqueValues = new Map<string, string>();
for (const row of rows) { for (const row of rows) {
const value = row[valueColumn]; const rawValue = row[valueColumn];
if (value && !uniqueValues.has(value)) { if (!rawValue) continue;
const label = labelColumn ? row[labelColumn] || value : value;
uniqueValues.set(value, label); // 쉼표 구분 다중값을 개별로 분리
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) => ({ const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
id: `dynamic_${index}`, id: `dynamic_${index}`,
value, value,
label, label,
})); }));
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { console.log("[TableSectionRenderer] DISTINCT 기반 옵션 로드 완료:", {
tableName, tableName,
valueColumn, valueColumn,
optionCount: options.length, optionCount: options.length,
options,
}); });
setDynamicOptions(options); setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true; dynamicOptionsLoadedRef.current = true;
} }
}
} catch (error) { } catch (error) {
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error); console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
} finally { } finally {
@ -1019,34 +1069,24 @@ export function TableSectionRenderer({
); );
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
// 조건부 테이블은 별도 useEffect에서 applyConditionalGrouping으로 처리
useEffect(() => { useEffect(() => {
// 이미 초기화되었으면 스킵
if (initialDataLoadedRef.current) return; if (initialDataLoadedRef.current) return;
if (isConditionalMode) return;
const tableSectionKey = `__tableSection_${sectionId}`; const tableSectionKey = `__tableSection_${sectionId}`;
const initialData = formData[tableSectionKey]; 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) { if (Array.isArray(initialData) && initialData.length > 0) {
console.log("[TableSectionRenderer] 초기 데이터 로드:", { console.warn("[TableSectionRenderer] 비조건부 초기 데이터 로드:", {
sectionId, sectionId,
itemCount: initialData.length, itemCount: initialData.length,
firstItem: initialData[0],
}); });
setTableData(initialData); setTableData(initialData);
initialDataLoadedRef.current = true; initialDataLoadedRef.current = true;
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
loadReferenceColumnValues(initialData); loadReferenceColumnValues(initialData);
} }
}, [sectionId, formData, loadReferenceColumnValues]); }, [sectionId, formData, isConditionalMode, loadReferenceColumnValues]);
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
const columns: RepeaterColumnConfig[] = useMemo(() => { const columns: RepeaterColumnConfig[] = useMemo(() => {
@ -1068,10 +1108,23 @@ export function TableSectionRenderer({
}); });
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생 // categoryOptionsMap + dynamicOptions에서 RepeaterTable용 카테고리 정보 파생
const tableCategoryColumns = useMemo(() => { const tableCategoryColumns = useMemo(() => {
return Object.keys(categoryOptionsMap); const cols = new Set(Object.keys(categoryOptionsMap));
}, [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 tableCategoryLabelMap = useMemo(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
@ -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; return map;
}, [categoryOptionsMap]); }, [categoryOptionsMap, dynamicOptions]);
// 원본 계산 규칙 (조건부 계산 포함) // 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo( const originalCalculationRules: TableCalculationRule[] = useMemo(
@ -1606,10 +1665,9 @@ export function TableSectionRenderer({
const multiSelect = uiConfig?.multiSelect ?? true; const multiSelect = uiConfig?.multiSelect ?? true;
// 버튼 표시 설정 (두 버튼 동시 표시 가능) // 버튼 표시 설정 (두 버튼 동시 표시 가능)
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환 // showSearchButton/showAddRowButton 신규 필드 우선, 레거시 addButtonType은 신규 필드 없을 때만 참고
const legacyAddButtonType = uiConfig?.addButtonType; const showSearchButton = uiConfig?.showSearchButton ?? true;
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true); const showAddRowButton = uiConfig?.showAddRowButton ?? false;
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색"; const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력"; const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
@ -1641,8 +1699,9 @@ export function TableSectionRenderer({
const filter = { ...baseFilterCondition }; const filter = { ...baseFilterCondition };
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용 // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
// __like 연산자로 ILIKE 포함 검색 (쉼표 구분 다중값 매칭 지원)
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) { if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition; filter[`${conditionalConfig.sourceFilter.filterColumn}__like`] = modalCondition;
} }
return filter; return filter;
@ -1771,7 +1830,29 @@ export function TableSectionRenderer({
async (items: any[]) => { async (items: any[]) => {
if (!modalCondition) return; if (!modalCondition) return;
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 // autoFillColumns 매핑 빌드: targetField → sourceColumn
const autoFillMap: Record<string, string> = {};
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( const mappedItems = await Promise.all(
items.map(async (sourceItem) => { items.map(async (sourceItem) => {
const newItem: any = {}; const newItem: any = {};
@ -1779,6 +1860,15 @@ export function TableSectionRenderer({
for (const col of tableConfig.columns) { for (const col of tableConfig.columns) {
const mapping = col.valueMapping; 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) { if (!mapping) {
const sourceField = col.sourceField || col.field; const sourceField = col.sourceField || col.field;
@ -1896,23 +1986,20 @@ export function TableSectionRenderer({
[addEmptyRowToCondition], [addEmptyRowToCondition],
); );
// 조건부 테이블: 초기 데이터 로드 (수정 모드) // 조건부 테이블: 초기 데이터를 그룹핑하여 표시하는 헬퍼
useEffect(() => { const applyConditionalGrouping = useCallback((data: any[]) => {
if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return;
const tableSectionKey = `_tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
if (Array.isArray(initialData) && initialData.length > 0) {
const conditionColumn = conditionalConfig?.conditionColumn; 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;
if (conditionColumn) {
// 조건별로 데이터 그룹핑
const grouped: ConditionalTableData = {}; const grouped: ConditionalTableData = {};
const conditions = new Set<string>(); const conditions = new Set<string>();
for (const row of initialData) { for (const row of data) {
const conditionValue = row[conditionColumn] || ""; const conditionValue = row[conditionColumn] || "";
if (conditionValue) { if (conditionValue) {
if (!grouped[conditionValue]) { if (!grouped[conditionValue]) {
@ -1926,15 +2013,119 @@ export function TableSectionRenderer({
setConditionalTableData(grouped); setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions)); setSelectedConditions(Array.from(conditions));
// 첫 번째 조건을 활성 탭으로 설정
if (conditions.size > 0) { if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]); setActiveConditionTab(Array.from(conditions)[0]);
} }
initialDataLoadedRef.current = true; initialDataLoadedRef.current = true;
}, [conditionalConfig?.conditionColumn]);
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
useEffect(() => {
if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return;
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) {
applyConditionalGrouping(initialData);
} }
}, [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;
} }
}, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]);
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(() => { const totalConditionalItems = useMemo(() => {

View File

@ -224,23 +224,38 @@ export function UniversalFormModalComponent({
// 설정 병합 // 설정 병합
const config: UniversalFormModalConfig = useMemo(() => { const config: UniversalFormModalConfig = useMemo(() => {
const componentConfig = component?.config || {}; 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 { return {
...defaultConfig, ...defaultConfig,
...propConfig, ...effectivePropConfig,
...componentConfig, ...effectiveCompConfig,
modal: { modal: {
...defaultConfig.modal, ...defaultConfig.modal,
...propConfig?.modal, ...effectivePropConfig?.modal,
...componentConfig.modal, ...effectiveCompConfig?.modal,
}, },
saveConfig: { saveConfig: {
...defaultConfig.saveConfig, ...defaultConfig.saveConfig,
...propConfig?.saveConfig, ...effectivePropConfig?.saveConfig,
...componentConfig.saveConfig, ...effectiveCompConfig?.saveConfig,
afterSave: { afterSave: {
...defaultConfig.saveConfig.afterSave, ...defaultConfig.saveConfig.afterSave,
...propConfig?.saveConfig?.afterSave, ...effectivePropConfig?.saveConfig?.afterSave,
...componentConfig.saveConfig?.afterSave, ...effectiveCompConfig?.saveConfig?.afterSave,
}, },
}, },
}; };
@ -295,6 +310,7 @@ export function UniversalFormModalComponent({
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요) // 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined); const lastInitializedId = useRef<string | undefined>(undefined);
const tableSectionLoadedRef = useRef(false);
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행 // 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => { useEffect(() => {
@ -316,7 +332,7 @@ export function UniversalFormModalComponent({
if (hasInitialized.current && lastInitializedId.current === currentIdString) { if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요 // 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) { if (!createModeDataHash || capturedInitialData.current) {
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨"); // console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨", { currentIdString });
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요 // 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
// (컴포넌트 remount로 인해 state가 초기화된 경우) // (컴포넌트 remount로 인해 state가 초기화된 경우)
return; return;
@ -350,21 +366,13 @@ export function UniversalFormModalComponent({
// console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current); // console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
} }
// console.log("[UniversalFormModal] initializeForm 호출 예정"); // console.log("[UniversalFormModal] initializeForm 호출 예정", { currentIdString });
hasInitialized.current = true; hasInitialized.current = true;
tableSectionLoadedRef.current = false;
initializeForm(); initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData]); // initialData 전체 변경 시 재초기화 }, [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 시 채번 플래그 초기화 // 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -728,9 +736,13 @@ export function UniversalFormModalComponent({
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조) // 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기 // 수정 모드일 때 디테일 테이블에서 데이터 가져오기
if (effectiveInitialData) { if (effectiveInitialData) {
console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { // console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { sectionsCount: config.sections.length });
sectionsCount: config.sections.length,
effectiveInitialDataKeys: Object.keys(effectiveInitialData), 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) { for (const section of config.sections) {
@ -739,16 +751,14 @@ export function UniversalFormModalComponent({
} }
const tableConfig = section.tableConfig; const tableConfig = section.tableConfig;
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
const editConfig = (tableConfig as any).editConfig; const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig; const saveConfig = tableConfig.saveConfig;
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, { console.warn(`[initializeForm] 테이블 섹션 ${section.id}:`, {
hasEditConfig: !!editConfig, editConfig,
loadOnEdit: editConfig?.loadOnEdit,
hasSaveConfig: !!saveConfig,
targetTable: saveConfig?.targetTable, targetTable: saveConfig?.targetTable,
linkColumn: editConfig?.linkColumn, masterField: editConfig?.linkColumn?.masterField,
masterValue: effectiveInitialData?.[editConfig?.linkColumn?.masterField],
}); });
// 수정 모드 로드 설정 확인 (기본값: true) // 수정 모드 로드 설정 확인 (기본값: true)
@ -1073,6 +1083,25 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) }, [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 createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
const item: RepeatSectionItem = { const item: RepeatSectionItem = {

View File

@ -47,14 +47,22 @@ export function UniversalFormModalConfigPanel({
onChange, onChange,
allComponents = [], allComponents = [],
}: UniversalFormModalConfigPanelProps) { }: 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 = { const config: UniversalFormModalConfig = {
...defaultConfig, ...defaultConfig,
...rawConfig, ...effectiveConfig,
modal: { ...defaultConfig.modal, ...rawConfig?.modal }, modal: { ...defaultConfig.modal, ...effectiveConfig?.modal },
sections: rawConfig?.sections ?? defaultConfig.sections, sections: effectiveConfig?.sections ?? defaultConfig.sections,
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig }, saveConfig: { ...defaultConfig.saveConfig, ...effectiveConfig?.saveConfig },
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode }, editMode: { ...defaultConfig.editMode, ...effectiveConfig?.editMode },
}; };
// 테이블 목록 // 테이블 목록

View File

@ -2721,9 +2721,12 @@ export function TableSectionSettingsModal({
}; };
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => { const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
updateTableConfig({ const newUiConfig = { ...tableConfig.uiConfig, ...updates };
uiConfig: { ...tableConfig.uiConfig, ...updates }, // 새 버튼 설정이 사용되면 레거시 addButtonType 제거
}); if ("showSearchButton" in updates || "showAddRowButton" in updates) {
delete (newUiConfig as any).addButtonType;
}
updateTableConfig({ uiConfig: newUiConfig });
}; };
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => { const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {

View File

@ -937,19 +937,38 @@ export function BomItemEditorComponent({
setItemSearchOpen(true); setItemSearchOpen(true);
}, []); }, []);
// 이미 추가된 품목 ID 목록 (중복 방지용) // 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용)
const existingItemIds = useMemo(() => { const existingItemIds = useMemo(() => {
const ids = new Set<string>(); const ids = new Set<string>();
const collect = (nodes: BomItemNode[]) => { const fkField = cfg.dataSource?.foreignKey || "child_item_id";
for (const n of nodes) {
const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; if (addTargetParentId === null) {
// 루트 레벨 추가: 루트 노드의 형제들만 체크
for (const n of treeData) {
const fk = n.data[fkField];
if (fk) ids.add(fk); if (fk) ids.add(fk);
collect(n.children);
} }
} 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;
}; };
collect(treeData); const parent = findParent(treeData);
if (parent) {
for (const child of parent.children) {
const fk = child.data[fkField];
if (fk) ids.add(fk);
}
}
}
return ids; return ids;
}, [treeData, cfg]); }, [treeData, cfg, addTargetParentId]);
// 루트 품목 추가 시작 // 루트 품목 추가 시작
const handleAddRoot = useCallback(() => { const handleAddRoot = useCallback(() => {

View File

@ -28,8 +28,6 @@ import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelC
import { applyMappingRules } from "@/lib/utils/dataMapping"; import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core"; import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core";
import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps { export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig; config?: ButtonPrimaryConfig;
// 추가 props // 추가 props
@ -1373,16 +1371,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 모달 내 저장 버튼: 필수 필드 미입력 시 비활성화
const isInModalContext = (props as any).isInModal === true;
const isSaveAction = processedConfig.action?.type === "save";
const isRequiredFieldsMissing = isSaveAction && isInModalContext && allComponents
? !checkAllRequiredFieldsFilled(allComponents, formData || {})
: false;
// 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수 + 필수 필드 미입력)
const finalDisabled = const finalDisabled =
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading || isRequiredFieldsMissing; componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일 // 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용) // 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
@ -1469,7 +1459,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
<button <button
type={componentConfig.actionType || "button"} type={componentConfig.actionType || "button"}
disabled={finalDisabled} disabled={finalDisabled}
title={isRequiredFieldsMissing ? "필수 입력 항목을 모두 채워주세요" : undefined}
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95" className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
style={buttonElementStyle} style={buttonElementStyle}
onClick={handleClick} onClick={handleClick}

View File

@ -114,7 +114,14 @@ export function DetailFormModal({
if (type === "input" && !formData.content?.trim()) return; if (type === "input" && !formData.content?.trim()) return;
if (type === "info" && !formData.lookup_target) return; if (type === "info" && !formData.lookup_target) return;
onSubmit(formData); const submitData = { ...formData };
if (type === "info" && !submitData.content?.trim()) {
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
submitData.content = `${targetLabel} 조회`;
}
onSubmit(submitData);
onClose(); onClose();
}; };

View File

@ -21,6 +21,7 @@ interface V2RepeaterRendererProps {
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
parentId?: string | number; parentId?: string | number;
formData?: Record<string, any>; formData?: Record<string, any>;
groupedData?: Record<string, any>[];
} }
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
@ -33,6 +34,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onButtonClick, onButtonClick,
parentId, parentId,
formData, formData,
groupedData,
}) => { }) => {
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출 // component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
const config: V2RepeaterConfig = React.useMemo(() => { const config: V2RepeaterConfig = React.useMemo(() => {
@ -105,6 +107,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onButtonClick={onButtonClick} onButtonClick={onButtonClick}
className={component?.className} className={component?.className}
formData={formData} formData={formData}
groupedData={groupedData}
/> />
); );
}; };

View File

@ -22,11 +22,76 @@ import {
Database, Database,
Table2, Table2,
Link2, Link2,
GripVertical,
X,
} from "lucide-react"; } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; 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 (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
)}
<Input
value={col.displayName || col.columnName}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="표시명"
className="h-6 min-w-0 flex-1 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
className="h-6 w-14 shrink-0 text-xs"
/>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
<X className="h-3 w-3" />
</Button>
</div>
);
}
export interface TableListConfigPanelProps { export interface TableListConfigPanelProps {
config: TableListConfig; config: TableListConfig;
@ -366,11 +431,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const existingColumn = config.columns?.find((col) => col.columnName === columnName); const existingColumn = config.columns?.find((col) => col.columnName === columnName);
if (existingColumn) return; if (existingColumn) return;
// tableColumns에서 해당 컬럼의 라벨 정보 찾기 // tableColumns → availableColumns 순서로 한국어 라벨 찾기
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); 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 || availableColumnInfo?.label || columnName;
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
const newColumn: ColumnConfig = { const newColumn: ColumnConfig = {
columnName, columnName,
@ -1458,6 +1523,63 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</> </>
)} )}
{/* 선택된 컬럼 순서 변경 (DnD) */}
{config.columns && config.columns.length > 0 && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> ({config.columns.length} )</h3>
<p className="text-muted-foreground text-[10px]">
/
</p>
</div>
<hr className="border-border" />
<DndContext
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
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);
}
}}
>
<SortableContext
items={(config.columns || []).map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{(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 (
<SortableColumnRow
key={column.columnName}
id={column.columnName}
col={colWithLabel}
index={idx}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
onRemove={() => removeColumn(column.columnName)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* 🆕 데이터 필터링 설정 */} {/* 🆕 데이터 필터링 설정 */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@ -1484,3 +1606,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
); );
}; };

View File

@ -3173,16 +3173,16 @@ export class ButtonActionExecutor {
return false; return false;
} }
// 1. 화면 설명 가져오기 // 1. 화면 정보 가져오기 (제목/설명이 미설정 시 화면명에서 가져옴)
let description = config.modalDescription || ""; let screenInfo: any = null;
if (!description) { if (!config.modalTitle || !config.modalDescription) {
try { try {
const screenInfo = await screenApi.getScreen(config.targetScreenId); screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
} catch (error) { } catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error); console.warn("화면 정보를 가져오지 못했습니다:", error);
} }
} }
let description = config.modalDescription || screenInfo?.description || "";
// 2. 데이터 소스 및 선택된 데이터 수집 // 2. 데이터 소스 및 선택된 데이터 수집
let selectedData: any[] = []; let selectedData: any[] = [];
@ -3288,7 +3288,7 @@ export class ButtonActionExecutor {
} }
// 3. 동적 모달 제목 생성 // 3. 동적 모달 제목 생성
let finalTitle = config.modalTitle || "화면"; let finalTitle = config.modalTitle || screenInfo?.screenName || "데이터 등록";
// 블록 기반 제목 처리 // 블록 기반 제목 처리
if (config.modalTitleBlocks?.length) { if (config.modalTitleBlocks?.length) {

View File

@ -662,75 +662,3 @@ const calculateStringSimilarity = (str1: string, str2: string): number => {
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen; 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<string, any>,
): 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;
};

View File

@ -235,6 +235,7 @@ export interface FlowStepDataList {
export interface MoveDataRequest { export interface MoveDataRequest {
flowId: number; flowId: number;
fromStepId: number;
recordId: string; recordId: string;
toStepId: number; toStepId: number;
note?: string; note?: string;

View File

@ -50,11 +50,13 @@ export interface RepeaterColumnConfig {
width: ColumnWidthOption; width: ColumnWidthOption;
visible: boolean; visible: boolean;
editable?: boolean; // 편집 가능 여부 (inline 모드) editable?: boolean; // 편집 가능 여부 (inline 모드)
hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨) hidden?: boolean; // 히든 처리 (화면에 안 보이지만 저장됨)
isJoinColumn?: boolean; isJoinColumn?: boolean;
sourceTable?: string; sourceTable?: string;
// 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시) // 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
isSourceDisplay?: boolean; isSourceDisplay?: boolean;
// 소스 데이터의 다른 컬럼명에서 값을 매핑 (예: qty ← order_qty)
sourceKey?: string;
// 입력 타입 (테이블 타입 관리의 inputType을 따름) // 입력 타입 (테이블 타입 관리의 inputType을 따름)
inputType?: string; // text, number, date, code, entity 등 inputType?: string; // text, number, date, code, entity 등
// 🆕 자동 입력 설정 // 🆕 자동 입력 설정
@ -140,6 +142,20 @@ export interface CalculationRule {
label?: string; 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<string, string>; // 리피터 컬럼 ← 조인 alias 매핑 (예: { "part_name": "part_code_item_name" })
additionalJoinColumns?: Array<{
sourceColumn: string;
sourceTable: string;
joinAlias: string;
}>;
}
// 메인 설정 타입 // 메인 설정 타입
export interface V2RepeaterConfig { export interface V2RepeaterConfig {
// 렌더링 모드 // 렌더링 모드
@ -151,6 +167,9 @@ export interface V2RepeaterConfig {
foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id) foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id)
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용 foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용
// 소스 디테일 자동 조회 설정 (선택된 마스터의 디테일 행을 리피터로 로드)
sourceDetailConfig?: SourceDetailConfig;
// 데이터 소스 설정 // 데이터 소스 설정
dataSource: RepeaterDataSource; dataSource: RepeaterDataSource;
@ -189,6 +208,7 @@ export interface V2RepeaterProps {
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
className?: string; className?: string;
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용 formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
groupedData?: Record<string, any>[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용)
} }
// 기본 설정값 // 기본 설정값