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
This commit is contained in:
syc0123 2026-03-03 13:12:48 +09:00
commit dca89a698f
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);
setLayout(layoutData); if (layoutData?.components?.length > 0) {
setLayout(layoutData);
} else {
console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId);
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
if (baseLayerData && isValidV2Layout(baseLayerData)) {
const converted = convertV2ToLegacy(baseLayerData);
if (converted) {
setLayout({
...converted,
screenResolution: baseLayerData.screenResolution || converted.screenResolution,
} as LayoutData);
} else {
setLayout(layoutData);
}
} else {
setLayout(layoutData);
}
}
} }
} catch (layoutError) { } 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) {
throw new Error("플로우 연결 정보를 가져올 수 없습니다");
}
const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId);
if (!nextConn) {
throw new Error("다음 단계가 연결되어 있지 않습니다");
}
// 데이터 이동 API 호출 // 선택된 행의 ID 추출
for (const dataId of selectedDataIds) { const selectedDataIds = Array.from(selectedRows).map((index) => String(data[index].id));
const response = await moveDataToNextStep(flowId, stepId, dataId);
if (!response.success) { // 배치 이동 API 호출
throw new Error(`데이터 이동 실패: ${response.message}`); 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 ? (
<div isHorizLabel ? (
style={{ <div style={{ position: "relative", width: "100%", height: "100%" }}>
display: "flex", <label
flexDirection: isHorizLabel ? (labelPos === "left" ? "row" : "row-reverse") : "column-reverse", className="text-sm font-medium leading-none"
alignItems: isHorizLabel ? "center" : undefined, style={{
gap: isHorizLabel ? labelGapValue : undefined, position: "absolute",
width: "100%", top: "50%",
height: "100%", transform: "translateY(-50%)",
}} ...(labelPos === "left"
> ? { right: "100%", marginRight: labelGapValue }
{externalLabelComponent} : { left: "100%", marginLeft: labelGapValue }),
<div style={{ flex: 1, minWidth: 0, height: isHorizLabel ? "100%" : undefined }}> fontSize: style?.labelFontSize || "14px",
{renderInteractiveWidget(componentToRender)} 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> ) : (
<div
style={{
display: "flex",
flexDirection: "column-reverse",
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0 }}>
{renderInteractiveWidget(componentToRender)}
</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,65 +420,113 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
fkValue, fkValue,
}); });
const response = await apiClient.post( let rows: any[] = [];
`/table-management/tables/${config.mainTableName}/data`, 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, page: 1,
size: 1000, size: 1000,
dataFilter: { search: searchParam,
enabled: true, enableEntityJoin: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], autoFilter: JSON.stringify({ enabled: true }),
}, };
autoFilter: 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(
`/table-management/tables/${config.mainTableName}/data`,
{
page: 1,
size: 1000,
dataFilter: {
enabled: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
},
autoFilter: true,
}
);
rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
}
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
if (Array.isArray(rows) && rows.length > 0) { if (Array.isArray(rows) && rows.length > 0) {
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : "");
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 // 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); const columnMapping = config.sourceDetailConfig?.columnMapping;
const sourceTable = config.dataSource?.sourceTable; if (useEntityJoinForLoad && columnMapping) {
const fkColumn = config.dataSource?.foreignKey; const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const refKey = config.dataSource?.referenceKey || "id"; rows.forEach((row: any) => {
sourceDisplayColumns.forEach((col) => {
const mappedKey = columnMapping[col.key];
const value = mappedKey ? row[mappedKey] : row[col.key];
row[`_display_${col.key}`] = value ?? "";
});
});
console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료");
}
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시)
try { if (!useEntityJoinForLoad) {
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const uniqueValues = [...new Set(fkValues)]; const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey;
const refKey = config.dataSource?.referenceKey || "id";
if (uniqueValues.length > 0) { if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 try {
const sourcePromises = uniqueValues.map((val) => const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
apiClient.post(`/table-management/tables/${sourceTable}/data`, { const uniqueValues = [...new Set(fkValues)];
page: 1, size: 1,
search: { [refKey]: val },
autoFilter: true,
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
.catch(() => [])
);
const sourceResults = await Promise.all(sourcePromises);
const sourceMap = new Map<string, any>();
sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
// 각 행에 소스 테이블의 표시 데이터 병합 if (uniqueValues.length > 0) {
rows.forEach((row: any) => { const sourcePromises = uniqueValues.map((val) =>
const sourceRecord = sourceMap.get(String(row[fkColumn])); apiClient.post(`/table-management/tables/${sourceTable}/data`, {
if (sourceRecord) { page: 1, size: 1,
sourceDisplayColumns.forEach((col) => { search: { [refKey]: val },
const displayValue = sourceRecord[col.key] ?? null; autoFilter: true,
row[col.key] = displayValue; }).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
row[`_display_${col.key}`] = displayValue; .catch(() => [])
}); );
} const sourceResults = await Promise.all(sourcePromises);
}); const sourceMap = new Map<string, any>();
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
sourceDisplayColumns.forEach((col) => {
const displayValue = sourceRecord[col.key] ?? null;
row[col.key] = displayValue;
row[`_display_${col.key}`] = displayValue;
});
}
});
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
}
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
} }
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
} }
} }
@ -964,8 +1013,113 @@ export const V2Repeater: React.FC<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({
} }
} }
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); if (Object.keys(newOptionsMap).length > 0) {
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,42 +641,81 @@ export function TableSectionRenderer({
const loadDynamicOptions = async () => { const loadDynamicOptions = async () => {
setDynamicOptionsLoading(true); setDynamicOptionsLoading(true);
try { try {
// DISTINCT 값을 가져오기 위한 API 호출 // 카테고리 값이 있는 컬럼인지 확인 (category_values 테이블에서 라벨 해결)
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { let categoryLabelMap: Record<string, string> = {};
search: filterCondition ? { _raw: filterCondition } : {}, try {
size: 1000, const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
page: 1, const catResult = await getCategoryValues(tableName, valueColumn, false);
}); if (catResult?.success && Array.isArray(catResult.data)) {
for (const item of catResult.data) {
if (response.data.success && response.data.data?.data) { const code = item.valueCode || item.value_code || item.value || "";
const rows = response.data.data.data; const label = item.valueLabel || item.displayLabel || item.display_label || item.label || code;
if (code) categoryLabelMap[code] = label;
// 중복 제거하여 고유 값 추출
const uniqueValues = new Map<string, string>();
for (const row of rows) {
const value = row[valueColumn];
if (value && !uniqueValues.has(value)) {
const label = labelColumn ? row[labelColumn] || value : value;
uniqueValues.set(value, label);
} }
} }
} catch {
// 카테고리 값이 없으면 무시
}
// 옵션 배열로 변환 const hasCategoryValues = Object.keys(categoryLabelMap).length > 0;
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
if (hasCategoryValues) {
// 카테고리 값이 정의되어 있으면 그대로 옵션으로 사용
const options = Object.entries(categoryLabelMap).map(([code, label], index) => ({
id: `dynamic_${index}`, id: `dynamic_${index}`,
value, value: code,
label, label,
})); }));
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { console.log("[TableSectionRenderer] 카테고리 기반 옵션 로드 완료:", {
tableName, tableName,
valueColumn, valueColumn,
optionCount: options.length, optionCount: options.length,
options,
}); });
setDynamicOptions(options); setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true; dynamicOptionsLoadedRef.current = true;
} else {
// 카테고리 값이 없으면 기존 방식: DISTINCT 값에서 추출 (쉼표 다중값 분리)
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: filterCondition ? { _raw: filterCondition } : {},
size: 1000,
page: 1,
});
if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data;
const uniqueValues = new Map<string, string>();
for (const row of rows) {
const rawValue = row[valueColumn];
if (!rawValue) continue;
// 쉼표 구분 다중값을 개별로 분리
const values = String(rawValue).split(",").map((v: string) => v.trim()).filter(Boolean);
for (const v of values) {
if (!uniqueValues.has(v)) {
const label = labelColumn ? row[labelColumn] || v : v;
uniqueValues.set(v, label);
}
}
}
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
id: `dynamic_${index}`,
value,
label,
}));
console.log("[TableSectionRenderer] DISTINCT 기반 옵션 로드 완료:", {
tableName,
valueColumn,
optionCount: options.length,
});
setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true;
}
} }
} catch (error) { } catch (error) {
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error); console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
@ -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,45 +1986,146 @@ export function TableSectionRenderer({
[addEmptyRowToCondition], [addEmptyRowToCondition],
); );
// 조건부 테이블: 초기 데이터를 그룹핑하여 표시하는 헬퍼
const applyConditionalGrouping = useCallback((data: any[]) => {
const conditionColumn = conditionalConfig?.conditionColumn;
console.warn(`[applyConditionalGrouping] 호출됨:`, {
conditionColumn,
dataLength: data.length,
sampleConditions: data.slice(0, 3).map(r => r[conditionColumn || ""]),
});
if (!conditionColumn || data.length === 0) return;
const grouped: ConditionalTableData = {};
const conditions = new Set<string>();
for (const row of data) {
const conditionValue = row[conditionColumn] || "";
if (conditionValue) {
if (!grouped[conditionValue]) {
grouped[conditionValue] = [];
}
grouped[conditionValue].push(row);
conditions.add(conditionValue);
}
}
setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions));
if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]);
}
initialDataLoadedRef.current = true;
}, [conditionalConfig?.conditionColumn]);
// 조건부 테이블: 초기 데이터 로드 (수정 모드) // 조건부 테이블: 초기 데이터 로드 (수정 모드)
useEffect(() => { useEffect(() => {
if (!isConditionalMode) return; if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return; if (initialDataLoadedRef.current) return;
const tableSectionKey = `_tableSection_${sectionId}`; const initialData =
const initialData = formData[tableSectionKey]; 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) { if (Array.isArray(initialData) && initialData.length > 0) {
const conditionColumn = conditionalConfig?.conditionColumn; applyConditionalGrouping(initialData);
if (conditionColumn) {
// 조건별로 데이터 그룹핑
const grouped: ConditionalTableData = {};
const conditions = new Set<string>();
for (const row of initialData) {
const conditionValue = row[conditionColumn] || "";
if (conditionValue) {
if (!grouped[conditionValue]) {
grouped[conditionValue] = [];
}
grouped[conditionValue].push(row);
conditions.add(conditionValue);
}
}
setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions));
// 첫 번째 조건을 활성 탭으로 설정
if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]);
}
initialDataLoadedRef.current = true;
}
} }
}, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]); }, [isConditionalMode, sectionId, formData, applyConditionalGrouping]);
// 조건부 테이블: formData에 데이터가 없으면 editConfig 기반으로 직접 API 로드
const selfLoadAttemptedRef = React.useRef(false);
useEffect(() => {
if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return;
if (selfLoadAttemptedRef.current) return;
const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig;
const linkColumn = editConfig?.linkColumn;
const targetTable = saveConfig?.targetTable;
console.warn(`[TableSectionRenderer] 자체 로드 체크:`, {
sectionId,
hasEditConfig: !!editConfig,
linkColumn,
targetTable,
masterField: linkColumn?.masterField,
masterValue: linkColumn?.masterField ? formData[linkColumn.masterField] : "N/A",
formDataKeys: Object.keys(formData).slice(0, 15),
initialDataLoaded: initialDataLoadedRef.current,
selfLoadAttempted: selfLoadAttemptedRef.current,
existingTableData_: !!formData[`_tableSection_${sectionId}`],
existingTableData__: !!formData[`__tableSection_${sectionId}`],
});
if (!linkColumn?.masterField || !linkColumn?.detailField || !targetTable) {
console.warn(`[TableSectionRenderer] 자체 로드 스킵: linkColumn/targetTable 미설정`);
return;
}
const masterValue = formData[linkColumn.masterField];
if (!masterValue) {
console.warn(`[TableSectionRenderer] 자체 로드 대기: masterField=${linkColumn.masterField} 값 없음`);
return;
}
// formData에 테이블 섹션 데이터가 이미 있으면 해당 데이터 사용
const existingData =
formData[`_tableSection_${sectionId}`] ||
formData[`__tableSection_${sectionId}`];
if (Array.isArray(existingData) && existingData.length > 0) {
console.warn(`[TableSectionRenderer] 기존 데이터 발견, applyConditionalGrouping 호출: ${existingData.length}`);
applyConditionalGrouping(existingData);
return;
}
selfLoadAttemptedRef.current = true;
console.warn(`[TableSectionRenderer] 자체 API 로드 시작: ${targetTable}, ${linkColumn.detailField}=${masterValue}`);
const loadDetailData = async () => {
try {
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
search: {
[linkColumn.detailField]: { value: masterValue, operator: "equals" },
},
page: 1,
size: 1000,
autoFilter: { enabled: true },
});
if (response.data?.success) {
let items: any[] = [];
const data = response.data.data;
if (Array.isArray(data)) items = data;
else if (data?.items && Array.isArray(data.items)) items = data.items;
else if (data?.rows && Array.isArray(data.rows)) items = data.rows;
else if (data?.data && Array.isArray(data.data)) items = data.data;
console.warn(`[TableSectionRenderer] 자체 데이터 로드 완료: ${items.length}`);
if (items.length > 0) {
applyConditionalGrouping(items);
}
} else {
console.warn(`[TableSectionRenderer] API 응답 실패:`, response.data);
}
} catch (error) {
console.error(`[TableSectionRenderer] 자체 데이터 로드 실패:`, error);
}
};
loadDetailData();
}, [isConditionalMode, sectionId, formData, tableConfig, applyConditionalGrouping]);
// 조건부 테이블: 전체 항목 수 계산 // 조건부 테이블: 전체 항목 수 계산
const totalConditionalItems = useMemo(() => { 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 {
collect(treeData); // 하위 추가: 해당 부모의 직속 자식들만 체크
const findParent = (nodes: BomItemNode[]): BomItemNode | null => {
for (const n of nodes) {
if (n.tempId === addTargetParentId) return n;
const found = findParent(n.children);
if (found) return found;
}
return null;
};
const parent = findParent(treeData);
if (parent) {
for (const child of parent.children) {
const fk = child.data[fkField];
if (fk) ids.add(fk);
}
}
}
return ids; 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>[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용)
} }
// 기본 설정값 // 기본 설정값