Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream

This commit is contained in:
dohyeons 2025-12-01 15:52:49 +09:00
commit b190e2ba08
34 changed files with 2650 additions and 909 deletions

View File

@ -4,6 +4,7 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { BatchService } from "../services/batchService"; import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService"; import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService";
import { import {
BatchConfigFilter, BatchConfigFilter,
CreateBatchConfigRequest, CreateBatchConfigRequest,
@ -63,7 +64,7 @@ export class BatchController {
res: Response res: Response
) { ) {
try { try {
const result = await BatchService.getAvailableConnections(); const result = await BatchExternalDbService.getAvailableConnections();
if (result.success) { if (result.success) {
res.json(result); res.json(result);
@ -99,8 +100,8 @@ export class BatchController {
} }
const connectionId = type === "external" ? Number(id) : undefined; const connectionId = type === "external" ? Number(id) : undefined;
const result = await BatchService.getTablesFromConnection( const result = await BatchService.getTables(
type, type as "internal" | "external",
connectionId connectionId
); );
@ -142,10 +143,10 @@ export class BatchController {
} }
const connectionId = type === "external" ? Number(id) : undefined; const connectionId = type === "external" ? Number(id) : undefined;
const result = await BatchService.getTableColumns( const result = await BatchService.getColumns(
type, tableName,
connectionId, type as "internal" | "external",
tableName connectionId
); );
if (result.success) { if (result.success) {

View File

@ -331,8 +331,11 @@ export class BatchManagementController {
const duration = endTime.getTime() - startTime.getTime(); const duration = endTime.getTime() - startTime.getTime();
// executionLog가 정의되어 있는지 확인 // executionLog가 정의되어 있는지 확인
if (typeof executionLog !== "undefined") { if (typeof executionLog !== "undefined" && executionLog) {
await BatchService.updateExecutionLog(executionLog.id, { const { BatchExecutionLogService } = await import(
"../services/batchExecutionLogService"
);
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED", execution_status: "FAILED",
end_time: endTime, end_time: endTime,
duration_ms: duration, duration_ms: duration,

View File

@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
}; };
const result = await dynamicFormService.updateFormDataPartial( const result = await dynamicFormService.updateFormDataPartial(
parseInt(id), id, // 🔧 parseInt 제거 - UUID 문자열도 지원
tableName, tableName,
originalData, originalData,
newDataWithMeta newDataWithMeta

View File

@ -5,6 +5,7 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
const pool = getPool(); const pool = getPool();
@ -16,7 +17,7 @@ const pool = getPool();
* *
* GET /api/screen-embedding?parentScreenId=1 * GET /api/screen-embedding?parentScreenId=1
*/ */
export async function getScreenEmbeddings(req: Request, res: Response) { export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) {
try { try {
const { parentScreenId } = req.query; const { parentScreenId } = req.query;
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
@ -67,7 +68,7 @@ export async function getScreenEmbeddings(req: Request, res: Response) {
* *
* GET /api/screen-embedding/:id * GET /api/screen-embedding/:id
*/ */
export async function getScreenEmbeddingById(req: Request, res: Response) { export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) {
try { try {
const { id } = req.params; const { id } = req.params;
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
@ -113,7 +114,7 @@ export async function getScreenEmbeddingById(req: Request, res: Response) {
* *
* POST /api/screen-embedding * POST /api/screen-embedding
*/ */
export async function createScreenEmbedding(req: Request, res: Response) { export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) {
try { try {
const { const {
parentScreenId, parentScreenId,
@ -184,7 +185,7 @@ export async function createScreenEmbedding(req: Request, res: Response) {
* *
* PUT /api/screen-embedding/:id * PUT /api/screen-embedding/:id
*/ */
export async function updateScreenEmbedding(req: Request, res: Response) { export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) {
try { try {
const { id } = req.params; const { id } = req.params;
const { position, mode, config } = req.body; const { position, mode, config } = req.body;
@ -257,7 +258,7 @@ export async function updateScreenEmbedding(req: Request, res: Response) {
* *
* DELETE /api/screen-embedding/:id * DELETE /api/screen-embedding/:id
*/ */
export async function deleteScreenEmbedding(req: Request, res: Response) { export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) {
try { try {
const { id } = req.params; const { id } = req.params;
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
@ -301,7 +302,7 @@ export async function deleteScreenEmbedding(req: Request, res: Response) {
* *
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2 * GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
*/ */
export async function getScreenDataTransfer(req: Request, res: Response) { export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try { try {
const { sourceScreenId, targetScreenId } = req.query; const { sourceScreenId, targetScreenId } = req.query;
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
@ -363,7 +364,7 @@ export async function getScreenDataTransfer(req: Request, res: Response) {
* *
* POST /api/screen-data-transfer * POST /api/screen-data-transfer
*/ */
export async function createScreenDataTransfer(req: Request, res: Response) { export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try { try {
const { const {
sourceScreenId, sourceScreenId,
@ -436,7 +437,7 @@ export async function createScreenDataTransfer(req: Request, res: Response) {
* *
* PUT /api/screen-data-transfer/:id * PUT /api/screen-data-transfer/:id
*/ */
export async function updateScreenDataTransfer(req: Request, res: Response) { export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try { try {
const { id } = req.params; const { id } = req.params;
const { dataReceivers, buttonConfig } = req.body; const { dataReceivers, buttonConfig } = req.body;
@ -504,7 +505,7 @@ export async function updateScreenDataTransfer(req: Request, res: Response) {
* *
* DELETE /api/screen-data-transfer/:id * DELETE /api/screen-data-transfer/:id
*/ */
export async function deleteScreenDataTransfer(req: Request, res: Response) { export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try { try {
const { id } = req.params; const { id } = req.params;
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
@ -548,7 +549,7 @@ export async function deleteScreenDataTransfer(req: Request, res: Response) {
* *
* GET /api/screen-split-panel/:screenId * GET /api/screen-split-panel/:screenId
*/ */
export async function getScreenSplitPanel(req: Request, res: Response) { export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
try { try {
const { screenId } = req.params; const { screenId } = req.params;
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
@ -655,7 +656,7 @@ export async function getScreenSplitPanel(req: Request, res: Response) {
* *
* POST /api/screen-split-panel * POST /api/screen-split-panel
*/ */
export async function createScreenSplitPanel(req: Request, res: Response) { export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
const client = await pool.connect(); const client = await pool.connect();
try { try {
@ -792,7 +793,7 @@ export async function createScreenSplitPanel(req: Request, res: Response) {
* *
* PUT /api/screen-split-panel/:id * PUT /api/screen-split-panel/:id
*/ */
export async function updateScreenSplitPanel(req: Request, res: Response) { export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
try { try {
const { id } = req.params; const { id } = req.params;
const { layoutConfig } = req.body; const { layoutConfig } = req.body;
@ -845,7 +846,7 @@ export async function updateScreenSplitPanel(req: Request, res: Response) {
* *
* DELETE /api/screen-split-panel/:id * DELETE /api/screen-split-panel/:id
*/ */
export async function deleteScreenSplitPanel(req: Request, res: Response) { export async function deleteScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
const client = await pool.connect(); const client = await pool.connect();
try { try {

View File

@ -22,9 +22,9 @@ router.use(authenticateToken);
// 폼 데이터 CRUD // 폼 데이터 CRUD
router.post("/save", saveFormData); // 기존 버전 (레거시 지원) router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
router.put("/:id", updateFormData); router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
router.delete("/:id", deleteFormData); router.delete("/:id", deleteFormData);
router.get("/:id", getFormData); router.get("/:id", getFormData);

View File

@ -203,8 +203,7 @@ export class BatchExternalDbService {
// 비밀번호 복호화 // 비밀번호 복호화
if (connection.password) { if (connection.password) {
try { try {
const passwordEncryption = new PasswordEncryption(); connection.password = PasswordEncryption.decrypt(connection.password);
connection.password = passwordEncryption.decrypt(connection.password);
} catch (error) { } catch (error) {
console.error("비밀번호 복호화 실패:", error); console.error("비밀번호 복호화 실패:", error);
// 복호화 실패 시 원본 사용 (또는 에러 처리) // 복호화 실패 시 원본 사용 (또는 에러 처리)

View File

@ -1,10 +1,10 @@
import cron from "node-cron"; import cron, { ScheduledTask } from "node-cron";
import { BatchService } from "./batchService"; import { BatchService } from "./batchService";
import { BatchExecutionLogService } from "./batchExecutionLogService"; import { BatchExecutionLogService } from "./batchExecutionLogService";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
export class BatchSchedulerService { export class BatchSchedulerService {
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map(); private static scheduledTasks: Map<number, ScheduledTask> = new Map();
/** /**
* *
@ -183,7 +183,7 @@ export class BatchSchedulerService {
// 실행 로그 업데이트 (실패) // 실행 로그 업데이트 (실패)
if (executionLog) { if (executionLog) {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, { await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILURE", execution_status: "FAILED",
end_time: new Date(), end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(), duration_ms: Date.now() - startTime.getTime(),
error_message: error_message:
@ -404,4 +404,11 @@ export class BatchSchedulerService {
return { totalRecords, successRecords, failedRecords }; return { totalRecords, successRecords, failedRecords };
} }
/**
* (scheduleBatch의 )
*/
static async scheduleBatchConfig(config: any) {
return this.scheduleBatch(config);
}
} }

View File

@ -16,7 +16,6 @@ import {
UpdateBatchConfigRequest, UpdateBatchConfigRequest,
} from "../types/batchTypes"; } from "../types/batchTypes";
import { BatchExternalDbService } from "./batchExternalDbService"; import { BatchExternalDbService } from "./batchExternalDbService";
import { DbConnectionManager } from "./dbConnectionManager";
export class BatchService { export class BatchService {
/** /**
@ -475,7 +474,13 @@ export class BatchService {
try { try {
if (connectionType === "internal") { if (connectionType === "internal") {
// 내부 DB 테이블 조회 // 내부 DB 테이블 조회
const tables = await DbConnectionManager.getInternalTables(); const tables = await query<TableInfo>(
`SELECT table_name, table_type, table_schema
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name`
);
return { return {
success: true, success: true,
data: tables, data: tables,
@ -509,7 +514,13 @@ export class BatchService {
try { try {
if (connectionType === "internal") { if (connectionType === "internal") {
// 내부 DB 컬럼 조회 // 내부 DB 컬럼 조회
const columns = await DbConnectionManager.getInternalColumns(tableName); const columns = await query<ColumnInfo>(
`SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position`,
[tableName]
);
return { return {
success: true, success: true,
data: columns, data: columns,
@ -543,7 +554,9 @@ export class BatchService {
try { try {
if (connectionType === "internal") { if (connectionType === "internal") {
// 내부 DB 데이터 조회 // 내부 DB 데이터 조회
const data = await DbConnectionManager.getInternalData(tableName, 10); const data = await query<any>(
`SELECT * FROM ${tableName} LIMIT 10`
);
return { return {
success: true, success: true,
data, data,

View File

@ -746,7 +746,7 @@ export class DynamicFormService {
* ( ) * ( )
*/ */
async updateFormDataPartial( async updateFormDataPartial(
id: number, id: string | number, // 🔧 UUID 문자열도 지원
tableName: string, tableName: string,
originalData: Record<string, any>, originalData: Record<string, any>,
newData: Record<string, any> newData: Record<string, any>
@ -1662,12 +1662,47 @@ export class DynamicFormService {
companyCode, companyCode,
}); });
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외) // 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인)
let whereClause = `"${keyField}" = $1`; const columnQuery = `
const params: any[] = [keyValue, updateValue, userId]; SELECT column_name
let paramIndex = 4; FROM information_schema.columns
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
`;
const columnResult = await client.query(columnQuery, [tableName]);
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
const hasUpdatedBy = existingColumns.includes('updated_by');
const hasUpdatedAt = existingColumns.includes('updated_at');
const hasCompanyCode = existingColumns.includes('company_code');
if (companyCode && companyCode !== "*") { console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
hasUpdatedBy,
hasUpdatedAt,
hasCompanyCode,
});
// 동적 SET 절 구성
let setClause = `"${updateField}" = $1`;
const params: any[] = [updateValue];
let paramIndex = 2;
if (hasUpdatedBy) {
setClause += `, updated_by = $${paramIndex}`;
params.push(userId);
paramIndex++;
}
if (hasUpdatedAt) {
setClause += `, updated_at = NOW()`;
}
// WHERE 절 구성
let whereClause = `"${keyField}" = $${paramIndex}`;
params.push(keyValue);
paramIndex++;
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만)
if (hasCompanyCode && companyCode && companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`; whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode); params.push(companyCode);
paramIndex++; paramIndex++;
@ -1675,9 +1710,7 @@ export class DynamicFormService {
const sqlQuery = ` const sqlQuery = `
UPDATE "${tableName}" UPDATE "${tableName}"
SET "${updateField}" = $2, SET ${setClause}
updated_by = $3,
updated_at = NOW()
WHERE ${whereClause} WHERE ${whereClause}
`; `;

View File

@ -1165,12 +1165,26 @@ export class TableManagementService {
paramCount: number; paramCount: number;
} | null> { } | null> {
try { try {
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!) // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) { if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
// 날짜 타입이면 날짜 범위로 처리
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
return this.buildDateRangeCondition(columnName, value, paramIndex); return this.buildDateRangeCondition(columnName, value, paramIndex);
} }
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = value.split("|").filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", ");
logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
} }
// 🔧 날짜 범위 객체 {from, to} 체크 // 🔧 날짜 범위 객체 {from, to} 체크

View File

@ -1,4 +1,98 @@
import { ApiResponse, ColumnInfo } from './batchTypes'; // 배치관리 타입 정의
// 작성일: 2024-12-24
// 공통 API 응답 타입
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// 컬럼 정보 타입
export interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable?: string;
column_default?: string | null;
}
// 테이블 정보 타입
export interface TableInfo {
table_name: string;
table_type?: string;
table_schema?: string;
}
// 연결 정보 타입
export interface ConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
// 배치 설정 필터 타입
export interface BatchConfigFilter {
page?: number;
limit?: number;
search?: string;
is_active?: string;
company_code?: string;
}
// 배치 매핑 타입
export interface BatchMapping {
id?: number;
batch_config_id?: number;
company_code?: string;
from_connection_type: 'internal' | 'external' | 'restapi';
from_connection_id?: number;
from_table_name: string;
from_column_name: string;
from_column_type?: string;
from_api_url?: string;
from_api_key?: string;
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
from_api_param_type?: 'url' | 'query';
from_api_param_name?: string;
from_api_param_value?: string;
from_api_param_source?: 'static' | 'dynamic';
from_api_body?: string;
to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number;
to_table_name: string;
to_column_name: string;
to_column_type?: string;
to_api_url?: string;
to_api_key?: string;
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
to_api_body?: string;
mapping_order?: number;
created_by?: string;
created_date?: Date;
}
// 배치 설정 타입
export interface BatchConfig {
id?: number;
batch_name: string;
description?: string;
cron_schedule: string;
is_active: 'Y' | 'N';
company_code?: string;
created_by?: string;
created_date?: Date;
updated_by?: string;
updated_date?: Date;
batch_mappings?: BatchMapping[];
}
export interface BatchConnectionInfo { export interface BatchConnectionInfo {
type: 'internal' | 'external'; type: 'internal' | 'external';
@ -27,7 +121,7 @@ export interface BatchMappingRequest {
from_api_param_name?: string; // API 파라미터명 from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿 from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) // REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
from_api_body?: string; from_api_body?: string;
to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number; to_connection_id?: number;

View File

@ -57,6 +57,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 폼 데이터 상태 추가 // 폼 데이터 상태 추가
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false); const [continuousMode, setContinuousMode] = useState(false);
@ -143,10 +146,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
console.log("✅ URL 파라미터 추가:", urlParams); console.log("✅ URL 파라미터 추가:", urlParams);
} }
// 🆕 editData가 있으면 formData로 설정 (수정 모드) // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
if (editData) { if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
setFormData(editData); setFormData(editData);
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else {
setOriginalData(null); // 신규 등록 모드
} }
setModalState({ setModalState({
@ -177,7 +183,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}); });
setScreenData(null); setScreenData(null);
setFormData({}); setFormData({});
setSelectedData([]); // 🆕 선택된 데이터 초기화 setOriginalData(null); // 🆕 원본 데이터 초기화
setContinuousMode(false); setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
console.log("🔄 연속 모드 초기화: false"); console.log("🔄 연속 모드 초기화: false");
@ -365,12 +371,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.", "⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
); );
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용 setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
} else { } else {
setFormData(normalizedData); setFormData(normalizedData);
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} }
// setFormData 직후 확인 // setFormData 직후 확인
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
} else { } else {
console.error("❌ 수정 데이터 로드 실패:", response.error); console.error("❌ 수정 데이터 로드 실패:", response.error);
toast.error("데이터를 불러올 수 없습니다."); toast.error("데이터를 불러올 수 없습니다.");
@ -619,11 +628,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
component={adjustedComponent} component={adjustedComponent}
allComponents={screenData.components} allComponents={screenData.components}
formData={formData} formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
...prev, setFormData((prev) => {
[fieldName]: value, const newFormData = {
})); ...prev,
[fieldName]: value,
};
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
return newFormData;
});
}} }}
onRefresh={() => { onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송 // 부모 화면의 테이블 새로고침 이벤트 발송
@ -637,8 +652,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
userId={userId} userId={userId}
userName={userName} userName={userName}
companyCode={user?.companyCode} companyCode={user?.companyCode}
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
groupedData={selectedData.length > 0 ? selectedData : undefined}
/> />
); );
})} })}

View File

@ -53,6 +53,8 @@ interface InteractiveScreenViewerProps {
disabledFields?: string[]; disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean; isInModal?: boolean;
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null;
} }
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -72,6 +74,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
groupedData, groupedData,
disabledFields = [], disabledFields = [],
isInModal = false, isInModal = false,
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth(); const { userName: authUserName, user: authUser } = useAuth();
@ -331,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
component={comp} component={comp}
isInteractive={true} isInteractive={true}
formData={formData} formData={formData}
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={handleFormDataChange} onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id} screenId={screenInfo?.id}
tableName={screenInfo?.tableName} tableName={screenInfo?.tableName}

View File

@ -1774,6 +1774,255 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/> />
</div> </div>
{/* 첫 번째 추가 테이블 설정 (위치정보와 함께 상태 변경) */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-update-field"> ( 1)</Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="geolocation-update-field"
checked={config.action?.geolocationUpdateField === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationUpdateField", checked)}
/>
</div>
{config.action?.geolocationUpdateField && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div>
<Label> </Label>
<Select
value={config.action?.geolocationExtraTableName || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Input
placeholder="예: status"
value={config.action?.geolocationExtraField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="예: active"
value={config.action?.geolocationExtraValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ( )</Label>
<Input
placeholder="예: id"
value={config.action?.geolocationExtraKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraKeyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationExtraKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
🔑 ID
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
🏢
</SelectItem>
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
👤
</SelectItem>
{tableColumns.length > 0 && (
<>
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
{/* 두 번째 추가 테이블 설정 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-second-table"> ( 2)</Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="geolocation-second-table"
checked={config.action?.geolocationSecondTableEnabled === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationSecondTableEnabled", checked)}
/>
</div>
{config.action?.geolocationSecondTableEnabled && (
<div className="mt-3 space-y-3 rounded-md bg-green-50 p-3 dark:bg-green-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Select
value={config.action?.geolocationSecondTableName || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationSecondMode || "update"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="update" className="text-xs">UPDATE ( )</SelectItem>
<SelectItem value="insert" className="text-xs">INSERT ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{config.action?.geolocationSecondMode === "insert" ? "저장할 필드" : "변경할 필드"}</Label>
<Input
placeholder="예: status"
value={config.action?.geolocationSecondField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label>{config.action?.geolocationSecondMode === "insert" ? "저장할 값" : "변경할 값"}</Label>
<Input
placeholder="예: inactive"
value={config.action?.geolocationSecondValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondValue", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>
{config.action?.geolocationSecondMode === "insert"
? "연결 필드 (대상 테이블)"
: "키 필드 (대상 테이블)"}
</Label>
<Input
placeholder={config.action?.geolocationSecondMode === "insert" ? "예: vehicle_id" : "예: id"}
value={config.action?.geolocationSecondKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondKeyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationSecondKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
🔑 ID
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
🏢
</SelectItem>
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
👤
</SelectItem>
{tableColumns.length > 0 && (
<>
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{config.action?.geolocationSecondMode === "insert" && (
<div className="flex items-center justify-between rounded bg-green-100 p-2 dark:bg-green-900">
<div className="space-y-0.5">
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">/ </p>
</div>
<Switch
checked={config.action?.geolocationSecondInsertFields?.includeLocation === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationSecondInsertFields", {
...config.action?.geolocationSecondInsertFields,
includeLocation: checked
})}
/>
</div>
)}
<p className="text-[10px] text-green-700 dark:text-green-300">
{config.action?.geolocationSecondMode === "insert"
? "새 레코드를 생성합니다. 연결 필드로 현재 폼 데이터와 연결됩니다."
: "기존 레코드를 수정합니다. 키 필드로 레코드를 찾아 값을 변경합니다."}
</p>
</div>
)}
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950"> <div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100"> <p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong> <strong> :</strong>
@ -1784,6 +2033,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<br /> <br />
3. / 3. /
<br /> <br />
4.
<br />
<br />
<strong>:</strong> + vehicles.status를 inactive로
<br />
<br /> <br />
<strong>:</strong> HTTPS . <strong>:</strong> HTTPS .
</p> </p>
@ -1852,6 +2106,62 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 🆕 키 필드 설정 (레코드 식별용) */}
<div className="mt-4 border-t pt-4">
<h5 className="mb-3 text-xs font-medium text-muted-foreground"> </h5>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-key-field">
(DB ) <span className="text-destructive">*</span>
</Label>
<Input
id="update-key-field"
placeholder="예: user_id"
value={config.action?.updateKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> DB </p>
</div>
<div>
<Label htmlFor="update-key-source">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="키 값 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span> ID
</span>
</SelectItem>
<SelectItem value="__userName__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
{tableColumns.map((column) => (
<SelectItem key={column} value={column} className="text-xs">
{column}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
</div>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="update-auto-save"> </Label> <Label htmlFor="update-auto-save"> </Label>
@ -1899,15 +2209,78 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 위치정보 수집 옵션 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-with-geolocation"> </Label>
<p className="text-xs text-muted-foreground"> GPS </p>
</div>
<Switch
id="update-with-geolocation"
checked={config.action?.updateWithGeolocation === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)}
/>
</div>
{config.action?.updateWithGeolocation && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: latitude"
value={config.action?.updateGeolocationLatField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: longitude"
value={config.action?.updateGeolocationLngField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="예: accuracy"
value={config.action?.updateGeolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> ()</Label>
<Input
placeholder="예: location_time"
value={config.action?.updateGeolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
GPS .
</p>
</div>
)}
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950"> <div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100"> <p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong> <strong> :</strong>
<br /> <br />
- 버튼: status &quot;active&quot; - 버튼: status&quot;active&quot; +
<br /> <br />
- 버튼: approval_status &quot;approved&quot; - 버튼: status를 &quot;inactive&quot; +
<br /> <br />
- 버튼: is_completed &quot;Y&quot; - 버튼: is_completed&quot;Y&quot;
</p> </p>
</div> </div>
</div> </div>

View File

@ -360,6 +360,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<ConfigPanelComponent <ConfigPanelComponent
config={config} config={config}
onChange={handlePanelConfigChange} onChange={handlePanelConfigChange}
onConfigChange={handlePanelConfigChange} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
tables={tables} // 테이블 정보 전달 tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용) allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달 screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달

View File

@ -48,6 +48,12 @@ interface SplitPanelContextValue {
// screenId로 위치 찾기 // screenId로 위치 찾기
getPositionByScreenId: (screenId: number) => SplitPanelPosition | null; getPositionByScreenId: (screenId: number) => SplitPanelPosition | null;
// 🆕 우측에 추가된 항목 ID 관리 (좌측 테이블에서 필터링용)
addedItemIds: Set<string>;
addItemIds: (ids: string[]) => void;
removeItemIds: (ids: string[]) => void;
clearItemIds: () => void;
} }
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null); const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
@ -74,6 +80,9 @@ export function SplitPanelProvider({
// 강제 리렌더링용 상태 // 강제 리렌더링용 상태
const [, forceUpdate] = useState(0); const [, forceUpdate] = useState(0);
// 🆕 우측에 추가된 항목 ID 상태
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
/** /**
* *
@ -191,6 +200,38 @@ export function SplitPanelProvider({
[leftScreenId, rightScreenId] [leftScreenId, rightScreenId]
); );
/**
* 🆕 ID
*/
const addItemIds = useCallback((ids: string[]) => {
setAddedItemIds((prev) => {
const newSet = new Set(prev);
ids.forEach((id) => newSet.add(id));
logger.debug(`[SplitPanelContext] 항목 ID 추가: ${ids.length}`, { ids });
return newSet;
});
}, []);
/**
* 🆕 ID
*/
const removeItemIds = useCallback((ids: string[]) => {
setAddedItemIds((prev) => {
const newSet = new Set(prev);
ids.forEach((id) => newSet.delete(id));
logger.debug(`[SplitPanelContext] 항목 ID 제거: ${ids.length}`, { ids });
return newSet;
});
}, []);
/**
* 🆕 ID
*/
const clearItemIds = useCallback(() => {
setAddedItemIds(new Set());
logger.debug(`[SplitPanelContext] 항목 ID 초기화`);
}, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<SplitPanelContextValue>(() => ({ const value = React.useMemo<SplitPanelContextValue>(() => ({
splitPanelId, splitPanelId,
@ -202,6 +243,10 @@ export function SplitPanelProvider({
getOtherSideReceivers, getOtherSideReceivers,
isInSplitPanel: true, isInSplitPanel: true,
getPositionByScreenId, getPositionByScreenId,
addedItemIds,
addItemIds,
removeItemIds,
clearItemIds,
}), [ }), [
splitPanelId, splitPanelId,
leftScreenId, leftScreenId,
@ -211,6 +256,10 @@ export function SplitPanelProvider({
transferToOtherSide, transferToOtherSide,
getOtherSideReceivers, getOtherSideReceivers,
getPositionByScreenId, getPositionByScreenId,
addedItemIds,
addItemIds,
removeItemIds,
clearItemIds,
]); ]);
return ( return (

View File

@ -124,7 +124,7 @@ export class DynamicFormApi {
* @returns * @returns
*/ */
static async updateFormDataPartial( static async updateFormDataPartial(
id: number, id: string | number, // 🔧 UUID 문자열도 지원
originalData: Record<string, any>, originalData: Record<string, any>,
newData: Record<string, any>, newData: Record<string, any>,
tableName: string, tableName: string,

View File

@ -337,6 +337,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => { const handleChange = (value: any) => {
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
return;
}
// React 이벤트 객체인 경우 값 추출 // React 이벤트 객체인 경우 값 추출
let actualValue = value; let actualValue = value;
if (value && typeof value === "object" && value.nativeEvent && value.target) { if (value && typeof value === "object" && value.nativeEvent && value.target) {

View File

@ -57,20 +57,42 @@ export function AutocompleteSearchInputComponent({
filterCondition, filterCondition,
}); });
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
const selectedDataRef = useRef<EntitySearchResult | null>(null);
const inputValueRef = useRef<string>("");
// formData에서 현재 값 가져오기 (isInteractive 모드) // formData에서 현재 값 가져오기 (isInteractive 모드)
const currentValue = isInteractive && formData && component?.columnName const currentValue = isInteractive && formData && component?.columnName
? formData[component.columnName] ? formData[component.columnName]
: value; : value;
// value가 변경되면 표시값 업데이트 // selectedData 변경 시 ref도 업데이트
useEffect(() => { useEffect(() => {
if (currentValue && selectedData) { if (selectedData) {
setInputValue(selectedData[displayField] || ""); selectedDataRef.current = selectedData;
} else if (!currentValue) { inputValueRef.current = inputValue;
setInputValue("");
setSelectedData(null);
} }
}, [currentValue, displayField, selectedData]); }, [selectedData, inputValue]);
// 리렌더링 시 ref에서 값 복원
useEffect(() => {
if (!selectedData && selectedDataRef.current) {
setSelectedData(selectedDataRef.current);
setInputValue(inputValueRef.current);
}
}, []);
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
useEffect(() => {
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
if (selectedData || selectedDataRef.current) {
return;
}
if (!currentValue) {
setInputValue("");
}
}, [currentValue, selectedData]);
// 외부 클릭 감지 // 외부 클릭 감지
useEffect(() => { useEffect(() => {

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -21,7 +21,9 @@ export function AutocompleteSearchInputConfigPanel({
config, config,
onConfigChange, onConfigChange,
}: AutocompleteSearchInputConfigPanelProps) { }: AutocompleteSearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config); // 초기화 여부 추적 (첫 마운트 시에만 config로 초기화)
const isInitialized = useRef(false);
const [localConfig, setLocalConfig] = useState<AutocompleteSearchInputConfig>(config);
const [allTables, setAllTables] = useState<any[]>([]); const [allTables, setAllTables] = useState<any[]>([]);
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]); const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]); const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
@ -32,12 +34,21 @@ export function AutocompleteSearchInputConfigPanel({
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false); const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false); const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
// 첫 마운트 시에만 config로 초기화 (이후에는 localConfig 유지)
useEffect(() => { useEffect(() => {
setLocalConfig(config); if (!isInitialized.current && config) {
setLocalConfig(config);
isInitialized.current = true;
}
}, [config]); }, [config]);
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => { const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
const newConfig = { ...localConfig, ...updates }; const newConfig = { ...localConfig, ...updates };
console.log("🔧 [AutocompleteConfigPanel] updateConfig:", {
updates,
localConfig,
newConfig,
});
setLocalConfig(newConfig); setLocalConfig(newConfig);
onConfigChange(newConfig); onConfigChange(newConfig);
}; };
@ -325,10 +336,11 @@ export function AutocompleteSearchInputConfigPanel({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs"> *</Label> <Label className="text-xs"> *</Label>
<Select <Select
value={mapping.sourceField} value={mapping.sourceField || undefined}
onValueChange={(value) => onValueChange={(value) => {
updateFieldMapping(index, { sourceField: value }) console.log("🔧 [Select] sourceField 변경:", value);
} updateFieldMapping(index, { sourceField: value });
}}
disabled={!localConfig.tableName || isLoadingSourceColumns} disabled={!localConfig.tableName || isLoadingSourceColumns}
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
@ -347,10 +359,11 @@ export function AutocompleteSearchInputConfigPanel({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs"> *</Label> <Label className="text-xs"> *</Label>
<Select <Select
value={mapping.targetField} value={mapping.targetField || undefined}
onValueChange={(value) => onValueChange={(value) => {
updateFieldMapping(index, { targetField: value }) console.log("🔧 [Select] targetField 변경:", value);
} updateFieldMapping(index, { targetField: value });
}}
disabled={!localConfig.targetTable || isLoadingTargetColumns} disabled={!localConfig.targetTable || isLoadingTargetColumns}
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">

View File

@ -694,7 +694,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const context: ButtonActionContext = { const context: ButtonActionContext = {
formData: formData || {}, formData: formData || {},
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용 screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용 tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
userId, // 🆕 사용자 ID userId, // 🆕 사용자 ID

View File

@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
}; };
// DOM에 전달하면 안 되는 React-specific props 필터링 // DOM에 전달하면 안 되는 React-specific props 필터링
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { const {
selectedScreen, selectedScreen,
onZoneComponentDrop, onZoneComponentDrop,
@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
tableName: _tableName, tableName: _tableName,
onRefresh: _onRefresh, onRefresh: _onRefresh,
onClose: _onClose, onClose: _onClose,
// 추가된 props 필터링
webType: _webType,
autoGeneration: _autoGeneration,
isInteractive: _isInteractive,
formData: _formData,
onFormDataChange: _onFormDataChange,
menuId: _menuId,
menuObjid: _menuObjid,
onSave: _onSave,
userId: _userId,
userName: _userName,
companyCode: _companyCode,
isInModal: _isInModal,
readonly: _readonly,
originalData: _originalData,
allComponents: _allComponents,
onUpdateLayout: _onUpdateLayout,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,
onSelectedRowsChange: _onSelectedRowsChange,
sortBy: _sortBy,
sortOrder: _sortOrder,
tableDisplayData: _tableDisplayData,
flowSelectedData: _flowSelectedData,
flowSelectedStepId: _flowSelectedStepId,
onFlowSelectedDataChange: _onFlowSelectedDataChange,
onConfigChange: _onConfigChange,
refreshKey: _refreshKey,
flowRefreshKey: _flowRefreshKey,
onFlowRefresh: _onFlowRefresh,
isPreview: _isPreview,
groupedData: _groupedData,
...domProps ...domProps
} = props; } = props as any;
return ( return (
<div style={componentStyle} className={className} {...domProps}> <div style={componentStyle} className={className} {...domProps}>

View File

@ -94,20 +94,51 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false; const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
const variant = config.variant || props.variant || "card"; const variant = config.variant || props.variant || "card";
// 기본 옵션 (포항/광양)
const DEFAULT_OPTIONS: LocationOption[] = [
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
];
// 상태 // 상태
const [options, setOptions] = useState<LocationOption[]>([]); const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isSwapping, setIsSwapping] = useState(false); const [isSwapping, setIsSwapping] = useState(false);
// 현재 선택된 값 // 로컬 선택 상태 (Select 컴포넌트용)
const departureValue = formData[departureField] || ""; const [localDeparture, setLocalDeparture] = useState<string>("");
const destinationValue = formData[destinationField] || ""; const [localDestination, setLocalDestination] = useState<string>("");
// 옵션 로드 // 옵션 로드
useEffect(() => { useEffect(() => {
const loadOptions = async () => { const loadOptions = async () => {
if (dataSource.type === "static") { console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
setOptions(dataSource.staticOptions || []);
// 정적 옵션 처리 (기본값)
// type이 없거나 static이거나, table인데 tableName이 없는 경우
const shouldUseStatic =
!dataSource.type ||
dataSource.type === "static" ||
(dataSource.type === "table" && !dataSource.tableName) ||
(dataSource.type === "code" && !dataSource.codeCategory);
if (shouldUseStatic) {
const staticOpts = dataSource.staticOptions || [];
// 정적 옵션이 설정되어 있고, value가 유효한 경우 사용
// (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용)
const isValidOptions = staticOpts.length > 0 &&
staticOpts[0]?.value &&
staticOpts[0].value !== departureField &&
staticOpts[0].value !== destinationField;
if (isValidOptions) {
console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts);
setOptions(staticOpts);
} else {
// 기본값 (포항/광양)
console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS });
setOptions(DEFAULT_OPTIONS);
}
return; return;
} }
@ -115,11 +146,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
// 코드 관리에서 가져오기 // 코드 관리에서 가져오기
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`); const response = await apiClient.get(`/code-management/codes`, {
params: { categoryCode: dataSource.codeCategory },
});
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const codeOptions = response.data.data.map((code: any) => ({ const codeOptions = response.data.data.map((code: any) => ({
value: code.code_value || code.codeValue, value: code.code_value || code.codeValue || code.code,
label: code.code_name || code.codeName, label: code.code_name || code.codeName || code.name,
})); }));
setOptions(codeOptions); setOptions(codeOptions);
} }
@ -135,13 +168,17 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
// 테이블에서 가져오기 // 테이블에서 가져오기
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, { const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
params: { pageSize: 1000 }, params: { page: 1, pageSize: 1000 },
}); });
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const tableOptions = response.data.data.map((row: any) => ({ // data가 배열인지 또는 data.rows인지 확인
value: row[dataSource.valueField || "id"], const rows = Array.isArray(response.data.data)
label: row[dataSource.labelField || "name"], ? response.data.data
: response.data.data.rows || [];
const tableOptions = rows.map((row: any) => ({
value: String(row[dataSource.valueField || "id"] || ""),
label: String(row[dataSource.labelField || "name"] || ""),
})); }));
setOptions(tableOptions); setOptions(tableOptions);
} }
@ -153,82 +190,130 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
} }
}; };
if (!isDesignMode) { loadOptions();
loadOptions();
} else {
// 디자인 모드에서는 샘플 데이터
setOptions([
{ value: "seoul", label: "서울" },
{ value: "busan", label: "부산" },
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
]);
}
}, [dataSource, isDesignMode]); }, [dataSource, isDesignMode]);
// formData에서 초기값 동기화
useEffect(() => {
const depVal = formData[departureField];
const destVal = formData[destinationField];
if (depVal && options.some(o => o.value === depVal)) {
setLocalDeparture(depVal);
}
if (destVal && options.some(o => o.value === destVal)) {
setLocalDestination(destVal);
}
}, [formData, departureField, destinationField, options]);
// 출발지 변경 // 출발지 변경
const handleDepartureChange = (value: string) => { const handleDepartureChange = (selectedValue: string) => {
console.log("[LocationSwapSelector] 출발지 변경:", {
selectedValue,
departureField,
hasOnFormDataChange: !!onFormDataChange,
options
});
// 로컬 상태 업데이트
setLocalDeparture(selectedValue);
// 부모에게 전달
if (onFormDataChange) { if (onFormDataChange) {
onFormDataChange(departureField, value); console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`);
onFormDataChange(departureField, selectedValue);
// 라벨 필드도 업데이트 // 라벨 필드도 업데이트
if (departureLabelField) { if (departureLabelField) {
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === selectedValue);
onFormDataChange(departureLabelField, selectedOption?.label || ""); if (selectedOption) {
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`);
onFormDataChange(departureLabelField, selectedOption.label);
}
} }
} else {
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
} }
}; };
// 도착지 변경 // 도착지 변경
const handleDestinationChange = (value: string) => { const handleDestinationChange = (selectedValue: string) => {
console.log("[LocationSwapSelector] 도착지 변경:", {
selectedValue,
destinationField,
hasOnFormDataChange: !!onFormDataChange,
options
});
// 로컬 상태 업데이트
setLocalDestination(selectedValue);
// 부모에게 전달
if (onFormDataChange) { if (onFormDataChange) {
onFormDataChange(destinationField, value); console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`);
onFormDataChange(destinationField, selectedValue);
// 라벨 필드도 업데이트 // 라벨 필드도 업데이트
if (destinationLabelField) { if (destinationLabelField) {
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === selectedValue);
onFormDataChange(destinationLabelField, selectedOption?.label || ""); if (selectedOption) {
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`);
onFormDataChange(destinationLabelField, selectedOption.label);
}
} }
} else {
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
} }
}; };
// 출발지/도착지 교환 // 출발지/도착지 교환
const handleSwap = () => { const handleSwap = () => {
if (!onFormDataChange) return;
setIsSwapping(true); setIsSwapping(true);
// 값 교환 // 로컬 상태 교환
const tempDeparture = departureValue; const tempDeparture = localDeparture;
const tempDestination = destinationValue; const tempDestination = localDestination;
setLocalDeparture(tempDestination);
setLocalDestination(tempDeparture);
onFormDataChange(departureField, tempDestination); // 부모에게 전달
onFormDataChange(destinationField, tempDeparture); if (onFormDataChange) {
onFormDataChange(departureField, tempDestination);
onFormDataChange(destinationField, tempDeparture);
// 라벨도 교환 // 라벨도 교환
if (departureLabelField && destinationLabelField) { if (departureLabelField && destinationLabelField) {
const tempDepartureLabel = formData[departureLabelField]; const depOption = options.find(o => o.value === tempDestination);
const tempDestinationLabel = formData[destinationLabelField]; const destOption = options.find(o => o.value === tempDeparture);
onFormDataChange(departureLabelField, tempDestinationLabel); onFormDataChange(departureLabelField, depOption?.label || "");
onFormDataChange(destinationLabelField, tempDepartureLabel); onFormDataChange(destinationLabelField, destOption?.label || "");
}
} }
// 애니메이션 효과 // 애니메이션 효과
setTimeout(() => setIsSwapping(false), 300); setTimeout(() => setIsSwapping(false), 300);
}; };
// 선택된 라벨 가져오기
const getDepartureLabel = () => {
const option = options.find((opt) => opt.value === departureValue);
return option?.label || "선택";
};
const getDestinationLabel = () => {
const option = options.find((opt) => opt.value === destinationValue);
return option?.label || "선택";
};
// 스타일에서 width, height 추출 // 스타일에서 width, height 추출
const { width, height, ...restStyle } = style || {}; const { width, height, ...restStyle } = style || {};
// 선택된 라벨 가져오기
const getDepartureLabel = () => {
const opt = options.find(o => o.value === localDeparture);
return opt?.label || "";
};
const getDestinationLabel = () => {
const opt = options.find(o => o.value === localDestination);
return opt?.label || "";
};
// 디버그 로그
console.log("[LocationSwapSelector] 렌더:", {
localDeparture,
localDestination,
options: options.map(o => `${o.value}:${o.label}`),
});
// Card 스타일 (이미지 참고) // Card 스타일 (이미지 참고)
if (variant === "card") { if (variant === "card") {
return ( return (
@ -242,18 +327,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex flex-1 flex-col items-center"> <div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span> <span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
<Select <Select
value={departureValue} value={localDeparture || undefined}
onValueChange={handleDepartureChange} onValueChange={handleDepartureChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0"> <SelectTrigger className={cn(
<SelectValue placeholder="선택"> "h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
<span className={cn(isSwapping && "animate-pulse")}> isSwapping && "animate-pulse"
{getDepartureLabel()} )}>
</span> {localDeparture ? (
</SelectValue> <span>{getDepartureLabel()}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -270,7 +358,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleSwap} onClick={handleSwap}
disabled={isDesignMode || !departureValue || !destinationValue}
className={cn( className={cn(
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted", "mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
isSwapping && "rotate-180" isSwapping && "rotate-180"
@ -284,18 +371,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex flex-1 flex-col items-center"> <div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span> <span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
<Select <Select
value={destinationValue} value={localDestination || undefined}
onValueChange={handleDestinationChange} onValueChange={handleDestinationChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0"> <SelectTrigger className={cn(
<SelectValue placeholder="선택"> "h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
<span className={cn(isSwapping && "animate-pulse")}> isSwapping && "animate-pulse"
{getDestinationLabel()} )}>
</span> {localDestination ? (
</SelectValue> <span>{getDestinationLabel()}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -320,14 +410,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex-1"> <div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label> <label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
<Select <Select
value={departureValue} value={localDeparture || undefined}
onValueChange={handleDepartureChange} onValueChange={handleDepartureChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-10"> <SelectTrigger className="h-10">
<SelectValue placeholder="선택" /> {localDeparture ? getDepartureLabel() : <span className="text-muted-foreground"></span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -343,7 +433,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleSwap} onClick={handleSwap}
disabled={isDesignMode}
className="mt-5 h-10 w-10" className="mt-5 h-10 w-10"
> >
<ArrowLeftRight className="h-4 w-4" /> <ArrowLeftRight className="h-4 w-4" />
@ -353,14 +442,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex-1"> <div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label> <label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
<Select <Select
value={destinationValue} value={localDestination || undefined}
onValueChange={handleDestinationChange} onValueChange={handleDestinationChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-10"> <SelectTrigger className="h-10">
<SelectValue placeholder="선택" /> {localDestination ? getDestinationLabel() : <span className="text-muted-foreground"></span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -381,14 +470,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
style={restStyle} style={restStyle}
> >
<Select <Select
value={departureValue} value={localDeparture || undefined}
onValueChange={handleDepartureChange} onValueChange={handleDepartureChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-8 flex-1 text-sm"> <SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={departureLabel} /> {localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">{departureLabel}</span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -403,7 +492,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleSwap} onClick={handleSwap}
disabled={isDesignMode}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<ArrowLeftRight className="h-4 w-4" /> <ArrowLeftRight className="h-4 w-4" />
@ -411,14 +499,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
)} )}
<Select <Select
value={destinationValue} value={localDestination || undefined}
onValueChange={handleDestinationChange} onValueChange={handleDestinationChange}
disabled={loading || isDesignMode} disabled={loading}
> >
<SelectTrigger className="h-8 flex-1 text-sm"> <SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={destinationLabel} /> {localDestination ? getDestinationLabel() : <span className="text-muted-foreground">{destinationLabel}</span>}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" sideOffset={4}>
{options.map((option) => ( {options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}

View File

@ -90,7 +90,7 @@ export function LocationSwapSelectorConfigPanel({
} }
}, [config?.dataSource?.tableName, config?.dataSource?.type]); }, [config?.dataSource?.tableName, config?.dataSource?.type]);
// 코드 카테고리 로드 // 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시)
useEffect(() => { useEffect(() => {
const loadCodeCategories = async () => { const loadCodeCategories = async () => {
try { try {
@ -103,8 +103,11 @@ export function LocationSwapSelectorConfigPanel({
})) }))
); );
} }
} catch (error) { } catch (error: any) {
console.error("코드 카테고리 로드 실패:", error); // 404는 API가 없는 것이므로 무시
if (error?.response?.status !== 404) {
console.error("코드 카테고리 로드 실패:", error);
}
} }
}; };
loadCodeCategories(); loadCodeCategories();
@ -139,13 +142,83 @@ export function LocationSwapSelectorConfigPanel({
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="static"> ()</SelectItem> <SelectItem value="static"> (/ )</SelectItem>
<SelectItem value="table"></SelectItem> <SelectItem value="table"> </SelectItem>
<SelectItem value="code"> </SelectItem> <SelectItem value="code"> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* 고정 옵션 설정 (type이 static일 때) */}
{(!config?.dataSource?.type || config?.dataSource?.type === "static") && (
<div className="space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> 1 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: pohang"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label> 1 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: 포항"
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> 2 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: gwangyang"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label> 2 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: 광양"
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-xs text-amber-700 dark:text-amber-300">
2 . (: 포항 )
</p>
</div>
)}
{/* 테이블 선택 (type이 table일 때) */} {/* 테이블 선택 (type이 table일 때) */}
{config?.dataSource?.type === "table" && ( {config?.dataSource?.type === "table" && (
<> <>
@ -298,14 +371,14 @@ export function LocationSwapSelectorConfigPanel({
<Label> ()</Label> <Label> ()</Label>
{tableColumns.length > 0 ? ( {tableColumns.length > 0 ? (
<Select <Select
value={config?.departureLabelField || ""} value={config?.departureLabelField || "__none__"}
onValueChange={(value) => handleChange("departureLabelField", value)} onValueChange={(value) => handleChange("departureLabelField", value === "__none__" ? "" : value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" /> <SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=""></SelectItem> <SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => ( {tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}
@ -325,14 +398,14 @@ export function LocationSwapSelectorConfigPanel({
<Label> ()</Label> <Label> ()</Label>
{tableColumns.length > 0 ? ( {tableColumns.length > 0 ? (
<Select <Select
value={config?.destinationLabelField || ""} value={config?.destinationLabelField || "__none__"}
onValueChange={(value) => handleChange("destinationLabelField", value)} onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" /> <SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=""></SelectItem> <SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => ( {tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}

View File

@ -12,7 +12,28 @@ export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRender
static componentDefinition = LocationSwapSelectorDefinition; static componentDefinition = LocationSwapSelectorDefinition;
render(): React.ReactElement { render(): React.ReactElement {
return <LocationSwapSelectorComponent {...this.props} />; const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props;
// component.componentConfig에서 설정 가져오기
const componentConfig = component?.componentConfig || {};
console.log("[LocationSwapSelectorRenderer] render:", {
componentConfig,
formData,
isDesignMode
});
return (
<LocationSwapSelectorComponent
id={component?.id}
style={style}
isDesignMode={isDesignMode}
formData={formData}
onFormDataChange={onFormDataChange}
componentConfig={componentConfig}
{...restProps}
/>
);
} }
} }

View File

@ -20,12 +20,15 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
defaultConfig: { defaultConfig: {
// 데이터 소스 설정 // 데이터 소스 설정
dataSource: { dataSource: {
type: "table", // "table" | "code" | "static" type: "static", // "table" | "code" | "static"
tableName: "", // 장소 테이블명 tableName: "", // 장소 테이블명
valueField: "location_code", // 값 필드 valueField: "location_code", // 값 필드
labelField: "location_name", // 표시 필드 labelField: "location_name", // 표시 필드
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때) codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
staticOptions: [], // 정적 옵션 (type이 "static"일 때) staticOptions: [
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
], // 정적 옵션 (type이 "static"일 때)
}, },
// 필드 매핑 // 필드 매핑
departureField: "departure", // 출발지 저장 필드 departureField: "departure", // 출발지 저장 필드

View File

@ -120,10 +120,15 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
setGroupedData(items); setGroupedData(items);
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용) // 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
const itemIds = items.map((item: any) => item.id).filter(Boolean); const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
setOriginalItemIds(itemIds); setOriginalItemIds(itemIds);
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds); console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
splitPanelContext.addItemIds(itemIds);
}
// onChange 호출하여 부모에게 알림 // onChange 호출하여 부모에게 알림
if (onChange && items.length > 0) { if (onChange && items.length > 0) {
const dataWithMeta = items.map((item: any) => ({ const dataWithMeta = items.map((item: any) => ({
@ -244,11 +249,54 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
const currentValue = parsedValueRef.current; const currentValue = parsedValueRef.current;
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가 // mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
// 🆕 필터링된 데이터 사용
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append"; const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData];
let newItems: any[];
let addedCount = 0;
let duplicateCount = 0;
if (mode === "replace") {
newItems = filteredData;
addedCount = filteredData.length;
} else {
// 🆕 중복 체크: id 또는 고유 식별자를 기준으로 이미 존재하는 항목 제외
const existingIds = new Set(
currentValue
.map((item: any) => item.id || item.po_item_id || item.item_id)
.filter(Boolean)
);
const uniqueNewItems = filteredData.filter((item: any) => {
const itemId = item.id || item.po_item_id || item.item_id;
if (itemId && existingIds.has(itemId)) {
duplicateCount++;
return false; // 중복 항목 제외
}
return true;
});
newItems = [...currentValue, ...uniqueNewItems];
addedCount = uniqueNewItems.length;
}
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode }); console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
currentValue,
newItems,
mode,
addedCount,
duplicateCount,
});
// 🆕 groupedData 상태도 직접 업데이트 (UI 즉시 반영)
setGroupedData(newItems);
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
if (splitPanelContext?.addItemIds && addedCount > 0) {
const newItemIds = newItems
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
splitPanelContext.addItemIds(newItemIds);
}
// JSON 문자열로 변환하여 저장 // JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newItems); const jsonValue = JSON.stringify(newItems);
@ -268,7 +316,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
onChangeRef.current(jsonValue); onChangeRef.current(jsonValue);
} }
toast.success(`${filteredData.length}개 항목이 추가되었습니다`); // 결과 메시지 표시
if (addedCount > 0) {
if (duplicateCount > 0) {
toast.success(`${addedCount}개 항목이 추가되었습니다 (${duplicateCount}개 중복 제외)`);
} else {
toast.success(`${addedCount}개 항목이 추가되었습니다`);
}
} else if (duplicateCount > 0) {
toast.warning(`${duplicateCount}개 항목이 이미 추가되어 있습니다`);
}
}, []); }, []);
// DataReceivable 인터페이스 구현 // DataReceivable 인터페이스 구현
@ -311,14 +368,69 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
} }
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]); }, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
// 🆕 전역 이벤트 리스너 (splitPanelDataTransfer)
useEffect(() => {
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
const { data, mode, mappingRules } = event.detail;
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
dataCount: data?.length,
mode,
componentId: component.id,
});
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
const splitPanelPosition = screenContext?.splitPanelPosition;
if (splitPanelPosition === "right" && data && data.length > 0) {
handleReceiveData(data, mappingRules || mode || "append");
}
};
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
const handleRepeaterChange = useCallback((newValue: any[]) => {
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newValue);
onChange?.(jsonValue);
// 🆕 groupedData 상태도 업데이트
setGroupedData(newValue);
// 🆕 SplitPanelContext의 addedItemIds 동기화
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
// 현재 항목들의 ID 목록
const currentIds = newValue
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
const addedIds = splitPanelContext.addedItemIds;
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
if (removedIds.length > 0) {
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
splitPanelContext.removeItemIds(removedIds);
}
// 새로 추가된 ID가 있으면 등록
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
if (newIds.length > 0) {
console.log(" [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
splitPanelContext.addItemIds(newIds);
}
}
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
return ( return (
<RepeaterInput <RepeaterInput
value={parsedValue} value={parsedValue}
onChange={(newValue) => { onChange={handleRepeaterChange}
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newValue);
onChange?.(jsonValue);
}}
config={config} config={config}
disabled={disabled} disabled={disabled}
readonly={readonly} readonly={readonly}

View File

@ -330,6 +330,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [data, setData] = useState<Record<string, any>[]>([]); const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
const filteredData = useMemo(() => {
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우에만 필터링
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
const addedIds = splitPanelContext.addedItemIds;
const filtered = data.filter((row) => {
const rowId = String(row.id || row.po_item_id || row.item_id || "");
return !addedIds.has(rowId);
});
console.log("🔍 [TableList] 우측 추가 항목 필터링:", {
originalCount: data.length,
filteredCount: filtered.length,
addedIdsCount: addedIds.size,
});
return filtered;
}
return data;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
@ -438,8 +457,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
componentType: "table-list", componentType: "table-list",
getSelectedData: () => { getSelectedData: () => {
// 선택된 행의 실제 데이터 반환 // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
const selectedData = data.filter((row) => { const selectedData = filteredData.filter((row) => {
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || ""); const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
return selectedRows.has(rowId); return selectedRows.has(rowId);
}); });
@ -447,7 +466,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}, },
getAllData: () => { getAllData: () => {
return data; // 🆕 필터링된 데이터 반환
return filteredData;
}, },
clearSelection: () => { clearSelection: () => {
@ -1375,31 +1395,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
} }
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index))); const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0); setIsAllSelected(allRowsSelected && filteredData.length > 0);
}; };
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index)); const allKeys = filteredData.map((row, index) => getRowKey(row, index));
const newSelectedRows = new Set(allKeys); const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows); setSelectedRows(newSelectedRows);
setIsAllSelected(true); setIsAllSelected(true);
if (onSelectedRowsChange) { if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection); onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
} }
if (onFormDataChange) { if (onFormDataChange) {
onFormDataChange({ onFormDataChange({
selectedRows: Array.from(newSelectedRows), selectedRows: Array.from(newSelectedRows),
selectedRowsData: data, selectedRowsData: filteredData,
}); });
} }
// 🆕 modalDataStore에 전체 데이터 저장 // 🆕 modalDataStore에 전체 데이터 저장
if (tableConfig.selectedTable && data.length > 0) { if (tableConfig.selectedTable && filteredData.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => { import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = data.map((row, idx) => ({ const modalItems = filteredData.map((row, idx) => ({
id: getRowKey(row, idx), id: getRowKey(row, idx),
originalData: row, originalData: row,
additionalData: {}, additionalData: {},
@ -2003,11 +2023,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 데이터 그룹화 // 데이터 그룹화
const groupedData = useMemo((): GroupedData[] => { const groupedData = useMemo((): GroupedData[] => {
if (groupByColumns.length === 0 || data.length === 0) return []; if (groupByColumns.length === 0 || filteredData.length === 0) return [];
const grouped = new Map<string, any[]>(); const grouped = new Map<string, any[]>();
data.forEach((item) => { filteredData.forEach((item) => {
// 그룹 키 생성: "통화:KRW > 단위:EA" // 그룹 키 생성: "통화:KRW > 단위:EA"
const keyParts = groupByColumns.map((col) => { const keyParts = groupByColumns.map((col) => {
// 카테고리/엔티티 타입인 경우 _name 필드 사용 // 카테고리/엔티티 타입인 경우 _name 필드 사용
@ -2334,7 +2354,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div> </div>
)} )}
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}> <div style={{ flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky <SingleTableWithSticky
data={data} data={data}
columns={visibleColumns} columns={visibleColumns}
@ -2401,7 +2421,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div <div
className="flex flex-1 flex-col" className="flex flex-1 flex-col"
style={{ style={{
marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`,
width: "100%", width: "100%",
height: "100%", height: "100%",
overflow: "hidden", overflow: "hidden",
@ -2431,7 +2450,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className="sticky z-50" className="sticky z-50"
style={{ style={{
position: "sticky", position: "sticky",
top: "-2px", top: 0,
zIndex: 50, zIndex: 50,
backgroundColor: "hsl(var(--background))", backgroundColor: "hsl(var(--background))",
}} }}
@ -2706,7 +2725,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}) })
) : ( ) : (
// 일반 렌더링 (그룹 없음) // 일반 렌더링 (그룹 없음)
data.map((row, index) => ( filteredData.map((row, index) => (
<tr <tr
key={index} key={index}
className={cn( className={cn(

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X } from "lucide-react"; import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel"; import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
@ -13,6 +13,9 @@ import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker"; import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
interface PresetFilter { interface PresetFilter {
id: string; id: string;
@ -20,6 +23,7 @@ interface PresetFilter {
columnLabel: string; columnLabel: string;
filterType: "text" | "number" | "date" | "select"; filterType: "text" | "number" | "date" | "select";
width?: number; width?: number;
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
} }
interface TableSearchWidgetProps { interface TableSearchWidgetProps {
@ -280,6 +284,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
} }
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
if (filter.filterType === "select" && Array.isArray(filterValue)) {
filterValue = filterValue.join("|");
}
return { return {
...filter, ...filter,
value: filterValue || "", value: filterValue || "",
@ -289,6 +298,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 빈 값 체크 // 빈 값 체크
if (!f.value) return false; if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false; if (typeof f.value === "string" && f.value === "") return false;
if (Array.isArray(f.value) && f.value.length === 0) return false;
return true; return true;
}); });
@ -343,12 +353,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
case "select": { case "select": {
let options = selectOptions[filter.columnName] || []; let options = selectOptions[filter.columnName] || [];
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
if (value && !options.find((opt) => opt.value === value)) {
const savedLabel = selectedLabels[filter.columnName] || value;
options = [{ value, label: savedLabel }, ...options];
}
// 중복 제거 (value 기준) // 중복 제거 (value 기준)
const uniqueOptions = options.reduce( const uniqueOptions = options.reduce(
(acc, option) => { (acc, option) => {
@ -360,39 +364,86 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
[] as Array<{ value: string; label: string }>, [] as Array<{ value: string; label: string }>,
); );
// 항상 다중선택 모드
const selectedValues: string[] = Array.isArray(value) ? value : (value ? [value] : []);
// 선택된 값들의 라벨 표시
const getDisplayText = () => {
if (selectedValues.length === 0) return column?.columnLabel || "선택";
if (selectedValues.length === 1) {
const opt = uniqueOptions.find(o => o.value === selectedValues[0]);
return opt?.label || selectedValues[0];
}
return `${selectedValues.length}개 선택됨`;
};
const handleMultiSelectChange = (optionValue: string, checked: boolean) => {
let newValues: string[];
if (checked) {
newValues = [...selectedValues, optionValue];
} else {
newValues = selectedValues.filter(v => v !== optionValue);
}
handleFilterChange(filter.columnName, newValues.length > 0 ? newValues : "");
};
return ( return (
<Select <Popover>
value={value} <PopoverTrigger asChild>
onValueChange={(val) => { <Button
// 선택한 값의 라벨 저장 variant="outline"
const selectedOption = uniqueOptions.find((opt) => opt.value === val); role="combobox"
if (selectedOption) { className={cn(
setSelectedLabels((prev) => ({ "h-9 min-h-9 justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm",
...prev, selectedValues.length === 0 && "text-muted-foreground"
[filter.columnName]: selectedOption.label, )}
})); style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
} >
handleFilterChange(filter.columnName, val); <span className="truncate">{getDisplayText()}</span>
}} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
> </Button>
<SelectTrigger </PopoverTrigger>
className="h-9 min-h-9 text-xs focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm" <PopoverContent
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }} className="p-0"
style={{ width: `${width}px` }}
align="start"
> >
<SelectValue placeholder={column?.columnLabel || "선택"} /> <div className="max-h-60 overflow-auto">
</SelectTrigger> {uniqueOptions.length === 0 ? (
<SelectContent> <div className="text-muted-foreground px-3 py-2 text-xs"> </div>
{uniqueOptions.length === 0 ? ( ) : (
<div className="text-muted-foreground px-2 py-1.5 text-xs"> </div> <div className="p-1">
) : ( {uniqueOptions.map((option, index) => (
uniqueOptions.map((option, index) => ( <div
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}> key={`${filter.columnName}-multi-${option.value}-${index}`}
{option.label} className="flex items-center space-x-2 rounded-sm px-2 py-1.5 hover:bg-accent cursor-pointer"
</SelectItem> onClick={() => handleMultiSelectChange(option.value, !selectedValues.includes(option.value))}
)) >
<Checkbox
checked={selectedValues.includes(option.value)}
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs sm:text-sm">{option.label}</span>
</div>
))}
</div>
)}
</div>
{selectedValues.length > 0 && (
<div className="border-t p-1">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleFilterChange(filter.columnName, "")}
>
</Button>
</div>
)} )}
</SelectContent> </PopoverContent>
</Select> </Popover>
); );
} }

View File

@ -29,6 +29,7 @@ interface PresetFilter {
columnLabel: string; columnLabel: string;
filterType: "text" | "number" | "date" | "select"; filterType: "text" | "number" | "date" | "select";
width?: number; width?: number;
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
} }
export function TableSearchWidgetConfigPanel({ export function TableSearchWidgetConfigPanel({

File diff suppressed because it is too large Load Diff

View File

@ -21,12 +21,14 @@
**생성된 테이블**: **생성된 테이블**:
1. **screen_embedding** (화면 임베딩 설정) 1. **screen_embedding** (화면 임베딩 설정)
- 한 화면을 다른 화면 안에 임베드 - 한 화면을 다른 화면 안에 임베드
- 위치 (left, right, top, bottom, center) - 위치 (left, right, top, bottom, center)
- 모드 (view, select, form, edit) - 모드 (view, select, form, edit)
- 설정 (width, height, multiSelect 등) - 설정 (width, height, multiSelect 등)
2. **screen_data_transfer** (데이터 전달 설정) 2. **screen_data_transfer** (데이터 전달 설정)
- 소스 화면 → 타겟 화면 데이터 전달 - 소스 화면 → 타겟 화면 데이터 전달
- 데이터 수신자 배열 (JSONB) - 데이터 수신자 배열 (JSONB)
- 매핑 규칙, 조건, 검증 - 매핑 규칙, 조건, 검증
@ -38,6 +40,7 @@
- 레이아웃 설정 (splitRatio, resizable 등) - 레이아웃 설정 (splitRatio, resizable 등)
**샘플 데이터**: **샘플 데이터**:
- 입고 등록 시나리오 샘플 데이터 포함 - 입고 등록 시나리오 샘플 데이터 포함
- 발주 목록 → 입고 처리 품목 매핑 예시 - 발주 목록 → 입고 처리 품목 매핑 예시
@ -46,6 +49,7 @@
**파일**: `frontend/types/screen-embedding.ts` **파일**: `frontend/types/screen-embedding.ts`
**주요 타입**: **주요 타입**:
```typescript ```typescript
// 화면 임베딩 // 화면 임베딩
- EmbeddingMode: "view" | "select" | "form" | "edit" - EmbeddingMode: "view" | "select" | "form" | "edit"
@ -67,13 +71,15 @@
#### 1.3 백엔드 API #### 1.3 백엔드 API
**파일**: **파일**:
- `backend-node/src/controllers/screenEmbeddingController.ts` - `backend-node/src/controllers/screenEmbeddingController.ts`
- `backend-node/src/routes/screenEmbeddingRoutes.ts` - `backend-node/src/routes/screenEmbeddingRoutes.ts`
**API 엔드포인트**: **API 엔드포인트**:
**화면 임베딩**: **화면 임베딩**:
- `GET /api/screen-embedding?parentScreenId=1` - 목록 조회 - `GET /api/screen-embedding?parentScreenId=1` - 목록 조회
- `GET /api/screen-embedding/:id` - 상세 조회 - `GET /api/screen-embedding/:id` - 상세 조회
- `POST /api/screen-embedding` - 생성 - `POST /api/screen-embedding` - 생성
@ -81,18 +87,21 @@
- `DELETE /api/screen-embedding/:id` - 삭제 - `DELETE /api/screen-embedding/:id` - 삭제
**데이터 전달**: **데이터 전달**:
- `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회 - `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회
- `POST /api/screen-data-transfer` - 생성 - `POST /api/screen-data-transfer` - 생성
- `PUT /api/screen-data-transfer/:id` - 수정 - `PUT /api/screen-data-transfer/:id` - 수정
- `DELETE /api/screen-data-transfer/:id` - 삭제 - `DELETE /api/screen-data-transfer/:id` - 삭제
**분할 패널**: **분할 패널**:
- `GET /api/screen-split-panel/:screenId` - 조회 - `GET /api/screen-split-panel/:screenId` - 조회
- `POST /api/screen-split-panel` - 생성 (트랜잭션) - `POST /api/screen-split-panel` - 생성 (트랜잭션)
- `PUT /api/screen-split-panel/:id` - 수정 - `PUT /api/screen-split-panel/:id` - 수정
- `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE) - `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE)
**특징**: **특징**:
- ✅ 멀티테넌시 지원 (company_code 필터링) - ✅ 멀티테넌시 지원 (company_code 필터링)
- ✅ 트랜잭션 처리 (분할 패널 생성/삭제) - ✅ 트랜잭션 처리 (분할 패널 생성/삭제)
- ✅ 외래키 CASCADE 처리 - ✅ 외래키 CASCADE 처리
@ -103,25 +112,24 @@
**파일**: `frontend/lib/api/screenEmbedding.ts` **파일**: `frontend/lib/api/screenEmbedding.ts`
**함수**: **함수**:
```typescript ```typescript
// 화면 임베딩 // 화면 임베딩
- getScreenEmbeddings(parentScreenId) -getScreenEmbeddings(parentScreenId) -
- getScreenEmbeddingById(id) getScreenEmbeddingById(id) -
- createScreenEmbedding(data) createScreenEmbedding(data) -
- updateScreenEmbedding(id, data) updateScreenEmbedding(id, data) -
- deleteScreenEmbedding(id) deleteScreenEmbedding(id) -
// 데이터 전달
// 데이터 전달 getScreenDataTransfer(sourceScreenId, targetScreenId) -
- getScreenDataTransfer(sourceScreenId, targetScreenId) createScreenDataTransfer(data) -
- createScreenDataTransfer(data) updateScreenDataTransfer(id, data) -
- updateScreenDataTransfer(id, data) deleteScreenDataTransfer(id) -
- deleteScreenDataTransfer(id) // 분할 패널
getScreenSplitPanel(screenId) -
// 분할 패널 createScreenSplitPanel(data) -
- getScreenSplitPanel(screenId) updateScreenSplitPanel(id, layoutConfig) -
- createScreenSplitPanel(data) deleteScreenSplitPanel(id);
- updateScreenSplitPanel(id, layoutConfig)
- deleteScreenSplitPanel(id)
``` ```
--- ---
@ -133,6 +141,7 @@
**파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx` **파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx`
**주요 기능**: **주요 기능**:
- ✅ 화면 데이터 로드 - ✅ 화면 데이터 로드
- ✅ 모드별 렌더링 (view, select, form, edit) - ✅ 모드별 렌더링 (view, select, form, edit)
- ✅ 선택 모드 지원 (체크박스) - ✅ 선택 모드 지원 (체크박스)
@ -141,6 +150,7 @@
- ✅ 로딩/에러 상태 UI - ✅ 로딩/에러 상태 UI
**외부 인터페이스** (useImperativeHandle): **외부 인터페이스** (useImperativeHandle):
```typescript ```typescript
- getSelectedRows(): any[] - getSelectedRows(): any[]
- clearSelection(): void - clearSelection(): void
@ -149,6 +159,7 @@
``` ```
**데이터 수신 프로세스**: **데이터 수신 프로세스**:
1. 조건 필터링 (condition) 1. 조건 필터링 (condition)
2. 매핑 규칙 적용 (mappingRules) 2. 매핑 규칙 적용 (mappingRules)
3. 검증 (validation) 3. 검증 (validation)
@ -165,10 +176,12 @@
**주요 함수**: **주요 함수**:
1. **applyMappingRules(data, rules)** 1. **applyMappingRules(data, rules)**
- 일반 매핑: 각 행에 대해 필드 매핑 - 일반 매핑: 각 행에 대해 필드 매핑
- 변환 매핑: 집계 함수 적용 - 변환 매핑: 집계 함수 적용
2. **변환 함수 지원**: 2. **변환 함수 지원**:
- `sum`: 합계 - `sum`: 합계
- `average`: 평균 - `average`: 평균
- `count`: 개수 - `count`: 개수
@ -177,15 +190,18 @@
- `concat`, `join`: 문자열 결합 - `concat`, `join`: 문자열 결합
3. **filterDataByCondition(data, condition)** 3. **filterDataByCondition(data, condition)**
- 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn - 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn
4. **validateMappingResult(data, rules)** 4. **validateMappingResult(data, rules)**
- 필수 필드 검증 - 필수 필드 검증
5. **previewMapping(sampleData, rules)** 5. **previewMapping(sampleData, rules)**
- 매핑 결과 미리보기 - 매핑 결과 미리보기
**특징**: **특징**:
- ✅ 중첩 객체 지원 (`user.address.city`) - ✅ 중첩 객체 지원 (`user.address.city`)
- ✅ 타입 안전성 - ✅ 타입 안전성
- ✅ 에러 처리 - ✅ 에러 처리
@ -195,6 +211,7 @@
**파일**: `frontend/lib/utils/logger.ts` **파일**: `frontend/lib/utils/logger.ts`
**기능**: **기능**:
- debug, info, warn, error 레벨 - debug, info, warn, error 레벨
- 개발 환경에서만 debug 출력 - 개발 환경에서만 debug 출력
- 타임스탬프 포함 - 타임스탬프 포함
@ -208,6 +225,7 @@
**파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx` **파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx`
**주요 기능**: **주요 기능**:
- ✅ 좌우 화면 임베딩 - ✅ 좌우 화면 임베딩
- ✅ 리사이저 (드래그로 비율 조정) - ✅ 리사이저 (드래그로 비율 조정)
- ✅ 데이터 전달 버튼 - ✅ 데이터 전달 버튼
@ -218,6 +236,7 @@
- ✅ 전달 후 선택 초기화 (옵션) - ✅ 전달 후 선택 초기화 (옵션)
**UI 구조**: **UI 구조**:
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │ │ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │
@ -230,6 +249,7 @@
``` ```
**이벤트 흐름**: **이벤트 흐름**:
1. 좌측에서 행 선택 → 선택 카운트 업데이트 1. 좌측에서 행 선택 → 선택 카운트 업데이트
2. 전달 버튼 클릭 → 검증 2. 전달 버튼 클릭 → 검증
3. 우측 화면의 컴포넌트들에 데이터 전달 3. 우측 화면의 컴포넌트들에 데이터 전달
@ -281,7 +301,7 @@ ERP-node/
const inboundConfig: ScreenSplitPanel = { const inboundConfig: ScreenSplitPanel = {
screenId: 100, screenId: 100,
leftEmbedding: { leftEmbedding: {
childScreenId: 10, // 발주 목록 조회 childScreenId: 10, // 발주 목록 조회
position: "left", position: "left",
mode: "select", mode: "select",
config: { config: {
@ -290,7 +310,7 @@ const inboundConfig: ScreenSplitPanel = {
}, },
}, },
rightEmbedding: { rightEmbedding: {
childScreenId: 20, // 입고 등록 폼 childScreenId: 20, // 입고 등록 폼
position: "right", position: "right",
mode: "form", mode: "form",
config: { config: {
@ -352,7 +372,7 @@ const inboundConfig: ScreenSplitPanel = {
onDataTransferred={(data) => { onDataTransferred={(data) => {
console.log("전달된 데이터:", data); console.log("전달된 데이터:", data);
}} }}
/> />;
``` ```
--- ---
@ -395,6 +415,7 @@ const inboundConfig: ScreenSplitPanel = {
### Phase 5: 고급 기능 (예정) ### Phase 5: 고급 기능 (예정)
1. **DataReceivable 인터페이스 구현** 1. **DataReceivable 인터페이스 구현**
- TableComponent - TableComponent
- InputComponent - InputComponent
- SelectComponent - SelectComponent
@ -402,6 +423,7 @@ const inboundConfig: ScreenSplitPanel = {
- 기타 컴포넌트들 - 기타 컴포넌트들
2. **양방향 동기화** 2. **양방향 동기화**
- 우측 → 좌측 데이터 반영 - 우측 → 좌측 데이터 반영
- 실시간 업데이트 - 실시간 업데이트
@ -412,6 +434,7 @@ const inboundConfig: ScreenSplitPanel = {
### Phase 6: 설정 UI (예정) ### Phase 6: 설정 UI (예정)
1. **시각적 매핑 설정 UI** 1. **시각적 매핑 설정 UI**
- 드래그앤드롭으로 필드 매핑 - 드래그앤드롭으로 필드 매핑
- 변환 함수 선택 - 변환 함수 선택
- 조건 설정 - 조건 설정
@ -463,7 +486,7 @@ import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
const { data: config } = await getScreenSplitPanel(screenId); const { data: config } = await getScreenSplitPanel(screenId);
// 렌더링 // 렌더링
<ScreenSplitPanel config={config} /> <ScreenSplitPanel config={config} />;
``` ```
--- ---
@ -471,6 +494,7 @@ const { data: config } = await getScreenSplitPanel(screenId);
## ✅ 체크리스트 ## ✅ 체크리스트
### 구현 완료 ### 구현 완료
- [x] 데이터베이스 스키마 (3개 테이블) - [x] 데이터베이스 스키마 (3개 테이블)
- [x] TypeScript 타입 정의 - [x] TypeScript 타입 정의
- [x] 백엔드 API (15개 엔드포인트) - [x] 백엔드 API (15개 엔드포인트)
@ -481,6 +505,7 @@ const { data: config } = await getScreenSplitPanel(screenId);
- [x] 로거 유틸리티 - [x] 로거 유틸리티
### 다음 단계 ### 다음 단계
- [ ] DataReceivable 구현 (각 컴포넌트 타입별) - [ ] DataReceivable 구현 (각 컴포넌트 타입별)
- [ ] 설정 UI (드래그앤드롭 매핑) - [ ] 설정 UI (드래그앤드롭 매핑)
- [ ] 미리보기 기능 - [ ] 미리보기 기능
@ -500,4 +525,3 @@ const { data: config } = await getScreenSplitPanel(screenId);
- ✅ 매핑 엔진 완성 - ✅ 매핑 엔진 완성
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. 이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.

View File

@ -11,6 +11,7 @@
### 1. 데이터베이스 스키마 ### 1. 데이터베이스 스키마
#### 새로운 테이블 (독립적) #### 새로운 테이블 (독립적)
```sql ```sql
- screen_embedding (신규) - screen_embedding (신규)
- screen_data_transfer (신규) - screen_data_transfer (신규)
@ -18,11 +19,13 @@
``` ```
**충돌 없는 이유**: **충돌 없는 이유**:
- ✅ 완전히 새로운 테이블명 - ✅ 완전히 새로운 테이블명
- ✅ 기존 테이블과 이름 중복 없음 - ✅ 기존 테이블과 이름 중복 없음
- ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용) - ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용)
#### 기존 테이블 (영향 없음) #### 기존 테이블 (영향 없음)
```sql ```sql
- screen_definitions (변경 없음) - screen_definitions (변경 없음)
- screen_layouts (변경 없음) - screen_layouts (변경 없음)
@ -32,6 +35,7 @@
``` ```
**확인 사항**: **확인 사항**:
- ✅ 기존 테이블 구조 변경 없음 - ✅ 기존 테이블 구조 변경 없음
- ✅ 기존 데이터 마이그레이션 불필요 - ✅ 기존 데이터 마이그레이션 불필요
- ✅ 기존 쿼리 영향 없음 - ✅ 기존 쿼리 영향 없음
@ -41,6 +45,7 @@
### 2. API 엔드포인트 ### 2. API 엔드포인트
#### 새로운 엔드포인트 (독립적) #### 새로운 엔드포인트 (독립적)
``` ```
POST /api/screen-embedding POST /api/screen-embedding
GET /api/screen-embedding GET /api/screen-embedding
@ -59,11 +64,13 @@ DELETE /api/screen-split-panel/:id
``` ```
**충돌 없는 이유**: **충돌 없는 이유**:
- ✅ 기존 `/api/screen-management/*` 와 다른 경로 - ✅ 기존 `/api/screen-management/*` 와 다른 경로
- ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음) - ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음)
- ✅ 독립적인 컨트롤러 파일 - ✅ 독립적인 컨트롤러 파일
#### 기존 엔드포인트 (영향 없음) #### 기존 엔드포인트 (영향 없음)
``` ```
/api/screen-management/* (변경 없음) /api/screen-management/* (변경 없음)
/api/screen/* (변경 없음) /api/screen/* (변경 없음)
@ -75,16 +82,19 @@ DELETE /api/screen-split-panel/:id
### 3. TypeScript 타입 ### 3. TypeScript 타입
#### 새로운 타입 파일 (독립적) #### 새로운 타입 파일 (독립적)
```typescript ```typescript
frontend/types/screen-embedding.ts (신규) frontend / types / screen - embedding.ts(신규);
``` ```
**충돌 없는 이유**: **충돌 없는 이유**:
- ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일 - ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일
- ✅ 타입명 중복 없음 - ✅ 타입명 중복 없음
- ✅ 독립적인 네임스페이스 - ✅ 독립적인 네임스페이스
#### 기존 타입 (영향 없음) #### 기존 타입 (영향 없음)
```typescript ```typescript
frontend/types/screen.ts (변경 없음) frontend/types/screen.ts (변경 없음)
frontend/types/screen-management.ts (변경 없음) frontend/types/screen-management.ts (변경 없음)
@ -96,6 +106,7 @@ backend-node/src/types/screen.ts (변경 없음)
### 4. 프론트엔드 컴포넌트 ### 4. 프론트엔드 컴포넌트
#### 새로운 컴포넌트 (독립적) #### 새로운 컴포넌트 (독립적)
``` ```
frontend/components/screen-embedding/ frontend/components/screen-embedding/
├── EmbeddedScreen.tsx (신규) ├── EmbeddedScreen.tsx (신규)
@ -104,11 +115,13 @@ frontend/components/screen-embedding/
``` ```
**충돌 없는 이유**: **충돌 없는 이유**:
- ✅ 별도 디렉토리 (`screen-embedding/`) - ✅ 별도 디렉토리 (`screen-embedding/`)
- ✅ 기존 컴포넌트 수정 없음 - ✅ 기존 컴포넌트 수정 없음
- ✅ 독립적으로 import 가능 - ✅ 독립적으로 import 가능
#### 기존 컴포넌트 (영향 없음) #### 기존 컴포넌트 (영향 없음)
``` ```
frontend/components/screen/ (변경 없음) frontend/components/screen/ (변경 없음)
frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음) frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
@ -121,17 +134,20 @@ frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
### 1. screen_definitions 테이블 참조 ### 1. screen_definitions 테이블 참조
**현재 구조**: **현재 구조**:
```sql ```sql
-- 새 테이블들이 screen_definitions를 참조 -- 새 테이블들이 screen_definitions를 참조
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
REFERENCES screen_definitions(screen_id) ON DELETE CASCADE REFERENCES screen_definitions(screen_id) ON DELETE CASCADE
``` ```
**잠재적 문제**: **잠재적 문제**:
- ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE) - ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE)
- ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음 - ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음
**해결 방법**: **해결 방법**:
```sql ```sql
-- 이미 구현됨: ON DELETE CASCADE -- 이미 구현됨: ON DELETE CASCADE
-- 화면 삭제 시 자동으로 관련 임베딩도 삭제 -- 화면 삭제 시 자동으로 관련 임베딩도 삭제
@ -139,6 +155,7 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
``` ```
**권장 사항**: **권장 사항**:
- ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6) - ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6)
- ✅ 삭제 시 경고 메시지 표시 - ✅ 삭제 시 경고 메시지 표시
@ -147,21 +164,23 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
### 2. 화면 렌더링 로직 ### 2. 화면 렌더링 로직
**현재 화면 렌더링**: **현재 화면 렌더링**:
```typescript ```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx // frontend/app/(main)/screens/[screenId]/page.tsx
function ScreenViewPage() { function ScreenViewPage() {
// 기존: 단일 화면 렌더링 // 기존: 단일 화면 렌더링
const screenId = parseInt(params.screenId as string); const screenId = parseInt(params.screenId as string);
// 레이아웃 로드 // 레이아웃 로드
const layout = await screenApi.getScreenLayout(screenId); const layout = await screenApi.getScreenLayout(screenId);
// 컴포넌트 렌더링 // 컴포넌트 렌더링
<DynamicComponentRenderer components={layout.components} /> <DynamicComponentRenderer components={layout.components} />;
} }
``` ```
**새로운 렌더링 (분할 패널)**: **새로운 렌더링 (분할 패널)**:
```typescript ```typescript
// 분할 패널 화면인 경우 // 분할 패널 화면인 경우
if (isSplitPanelScreen) { if (isSplitPanelScreen) {
@ -174,10 +193,12 @@ return <DynamicComponentRenderer components={layout.components} />;
``` ```
**잠재적 문제**: **잠재적 문제**:
- ⚠️ 화면 타입 구분 로직 필요 - ⚠️ 화면 타입 구분 로직 필요
- ⚠️ 기존 화면 렌더링 로직 수정 필요 - ⚠️ 기존 화면 렌더링 로직 수정 필요
**해결 방법**: **해결 방법**:
```typescript ```typescript
// 1. screen_definitions에 screen_type 컬럼 추가 (선택사항) // 1. screen_definitions에 screen_type 컬럼 추가 (선택사항)
ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal'; ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal';
@ -191,40 +212,45 @@ if (splitPanelConfig.success && splitPanelConfig.data) {
``` ```
**권장 구현**: **권장 구현**:
```typescript ```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx 수정 // frontend/app/(main)/screens/[screenId]/page.tsx 수정
useEffect(() => { useEffect(() => {
const loadScreen = async () => { const loadScreen = async () => {
// 1. 분할 패널 확인 // 1. 분할 패널 확인
const splitPanelResult = await getScreenSplitPanel(screenId); const splitPanelResult = await getScreenSplitPanel(screenId);
if (splitPanelResult.success && splitPanelResult.data) { if (splitPanelResult.success && splitPanelResult.data) {
// 분할 패널 화면 // 분할 패널 화면
setScreenType('split_panel'); setScreenType("split_panel");
setSplitPanelConfig(splitPanelResult.data); setSplitPanelConfig(splitPanelResult.data);
return; return;
} }
// 2. 일반 화면 // 2. 일반 화면
const screenResult = await screenApi.getScreen(screenId); const screenResult = await screenApi.getScreen(screenId);
const layoutResult = await screenApi.getScreenLayout(screenId); const layoutResult = await screenApi.getScreenLayout(screenId);
setScreenType('normal'); setScreenType("normal");
setScreen(screenResult.data); setScreen(screenResult.data);
setLayout(layoutResult.data); setLayout(layoutResult.data);
}; };
loadScreen(); loadScreen();
}, [screenId]); }, [screenId]);
// 렌더링 // 렌더링
{screenType === 'split_panel' && splitPanelConfig && ( {
<ScreenSplitPanel config={splitPanelConfig} /> screenType === "split_panel" && splitPanelConfig && (
)} <ScreenSplitPanel config={splitPanelConfig} />
);
}
{screenType === 'normal' && layout && ( {
<DynamicComponentRenderer components={layout.components} /> screenType === "normal" && layout && (
)} <DynamicComponentRenderer components={layout.components} />
);
}
``` ```
--- ---
@ -232,6 +258,7 @@ useEffect(() => {
### 3. 컴포넌트 등록 시스템 ### 3. 컴포넌트 등록 시스템
**현재 시스템**: **현재 시스템**:
```typescript ```typescript
// frontend/lib/registry/components.ts // frontend/lib/registry/components.ts
const componentRegistry = new Map<string, ComponentDefinition>(); const componentRegistry = new Map<string, ComponentDefinition>();
@ -242,6 +269,7 @@ export function registerComponent(id: string, component: any) {
``` ```
**새로운 요구사항**: **새로운 요구사항**:
```typescript ```typescript
// DataReceivable 인터페이스 구현 필요 // DataReceivable 인터페이스 구현 필요
interface DataReceivable { interface DataReceivable {
@ -254,29 +282,31 @@ interface DataReceivable {
``` ```
**잠재적 문제**: **잠재적 문제**:
- ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현 - ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현
- ⚠️ 데이터 수신 기능 없음 - ⚠️ 데이터 수신 기능 없음
**해결 방법**: **해결 방법**:
```typescript ```typescript
// Phase 5에서 구현 예정 // Phase 5에서 구현 예정
// 기존 컴포넌트를 래핑하는 어댑터 패턴 사용 // 기존 컴포넌트를 래핑하는 어댑터 패턴 사용
class TableComponentAdapter implements DataReceivable { class TableComponentAdapter implements DataReceivable {
constructor(private tableComponent: any) {} constructor(private tableComponent: any) {}
async receiveData(data: any[], mode: DataReceiveMode) { async receiveData(data: any[], mode: DataReceiveMode) {
if (mode === 'append') { if (mode === "append") {
this.tableComponent.addRows(data); this.tableComponent.addRows(data);
} else if (mode === 'replace') { } else if (mode === "replace") {
this.tableComponent.setRows(data); this.tableComponent.setRows(data);
} }
} }
getData() { getData() {
return this.tableComponent.getRows(); return this.tableComponent.getRows();
} }
clearData() { clearData() {
this.tableComponent.clearRows(); this.tableComponent.clearRows();
} }
@ -284,6 +314,7 @@ class TableComponentAdapter implements DataReceivable {
``` ```
**권장 사항**: **권장 사항**:
- ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑 - ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑
- ✅ 점진적으로 DataReceivable 구현 - ✅ 점진적으로 DataReceivable 구현
- ✅ 하위 호환성 유지 - ✅ 하위 호환성 유지
@ -297,38 +328,41 @@ class TableComponentAdapter implements DataReceivable {
**파일**: `frontend/app/(main)/screens/[screenId]/page.tsx` **파일**: `frontend/app/(main)/screens/[screenId]/page.tsx`
**수정 내용**: **수정 내용**:
```typescript ```typescript
import { getScreenSplitPanel } from "@/lib/api/screenEmbedding"; import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
import { ScreenSplitPanel } from "@/components/screen-embedding"; import { ScreenSplitPanel } from "@/components/screen-embedding";
function ScreenViewPage() { function ScreenViewPage() {
const [screenType, setScreenType] = useState<'normal' | 'split_panel'>('normal'); const [screenType, setScreenType] = useState<"normal" | "split_panel">(
"normal"
);
const [splitPanelConfig, setSplitPanelConfig] = useState<any>(null); const [splitPanelConfig, setSplitPanelConfig] = useState<any>(null);
useEffect(() => { useEffect(() => {
const loadScreen = async () => { const loadScreen = async () => {
// 분할 패널 확인 // 분할 패널 확인
const splitResult = await getScreenSplitPanel(screenId); const splitResult = await getScreenSplitPanel(screenId);
if (splitResult.success && splitResult.data) { if (splitResult.success && splitResult.data) {
setScreenType('split_panel'); setScreenType("split_panel");
setSplitPanelConfig(splitResult.data); setSplitPanelConfig(splitResult.data);
setLoading(false); setLoading(false);
return; return;
} }
// 일반 화면 로드 (기존 로직) // 일반 화면 로드 (기존 로직)
// ... // ...
}; };
loadScreen(); loadScreen();
}, [screenId]); }, [screenId]);
// 렌더링 // 렌더링
if (screenType === 'split_panel' && splitPanelConfig) { if (screenType === "split_panel" && splitPanelConfig) {
return <ScreenSplitPanel config={splitPanelConfig} />; return <ScreenSplitPanel config={splitPanelConfig} />;
} }
// 기존 렌더링 로직 // 기존 렌더링 로직
// ... // ...
} }
@ -343,6 +377,7 @@ function ScreenViewPage() {
**파일**: 화면 관리 페이지 **파일**: 화면 관리 페이지
**추가 기능**: **추가 기능**:
- 화면 생성 시 "분할 패널" 타입 선택 - 화면 생성 시 "분할 패널" 타입 선택
- 분할 패널 설정 UI - 분할 패널 설정 UI
- 임베딩 설정 UI - 임베딩 설정 UI
@ -354,15 +389,15 @@ function ScreenViewPage() {
## 📊 충돌 위험도 평가 ## 📊 충돌 위험도 평가
| 항목 | 위험도 | 설명 | 조치 필요 | | 항목 | 위험도 | 설명 | 조치 필요 |
|------|--------|------|-----------| | -------------------- | ------- | ------------------- | ----------------- |
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 | | 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 | | API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 | | TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 | | 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 | | 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) | | 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 | | 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
**전체 위험도**: 🟢 **낮음** (대부분 독립적) **전체 위험도**: 🟢 **낮음** (대부분 독립적)
@ -371,24 +406,28 @@ function ScreenViewPage() {
## ✅ 안전성 체크리스트 ## ✅ 안전성 체크리스트
### 데이터베이스 ### 데이터베이스
- [x] 새 테이블명이 기존과 중복되지 않음 - [x] 새 테이블명이 기존과 중복되지 않음
- [x] 기존 테이블 구조 변경 없음 - [x] 기존 테이블 구조 변경 없음
- [x] 외래키 CASCADE 설정 완료 - [x] 외래키 CASCADE 설정 완료
- [x] 멀티테넌시 (company_code) 지원 - [x] 멀티테넌시 (company_code) 지원
### 백엔드 ### 백엔드
- [x] 새 라우트가 기존과 충돌하지 않음 - [x] 새 라우트가 기존과 충돌하지 않음
- [x] 독립적인 컨트롤러 파일 - [x] 독립적인 컨트롤러 파일
- [x] 기존 API 수정 없음 - [x] 기존 API 수정 없음
- [x] 에러 핸들링 완료 - [x] 에러 핸들링 완료
### 프론트엔드 ### 프론트엔드
- [x] 새 컴포넌트가 별도 디렉토리 - [x] 새 컴포넌트가 별도 디렉토리
- [x] 기존 컴포넌트 수정 없음 - [x] 기존 컴포넌트 수정 없음
- [x] 독립적인 타입 정의 - [x] 독립적인 타입 정의
- [ ] 화면 페이지 수정 필요 (조건 분기) - [ ] 화면 페이지 수정 필요 (조건 분기)
### 호환성 ### 호환성
- [x] 기존 화면 동작 영향 없음 - [x] 기존 화면 동작 영향 없음
- [x] 하위 호환성 유지 - [x] 하위 호환성 유지
- [ ] 컴포넌트 어댑터 구현 (Phase 5) - [ ] 컴포넌트 어댑터 구현 (Phase 5)
@ -400,6 +439,7 @@ function ScreenViewPage() {
### 즉시 조치 (필수) ### 즉시 조치 (필수)
1. **화면 페이지 수정** 1. **화면 페이지 수정**
```typescript ```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx // frontend/app/(main)/screens/[screenId]/page.tsx
// 분할 패널 확인 로직 추가 // 분할 패널 확인 로직 추가
@ -421,11 +461,13 @@ function ScreenViewPage() {
### 단계적 조치 (Phase 5-6) ### 단계적 조치 (Phase 5-6)
1. **컴포넌트 어댑터 구현** 1. **컴포넌트 어댑터 구현**
- TableComponent → DataReceivable - TableComponent → DataReceivable
- InputComponent → DataReceivable - InputComponent → DataReceivable
- 기타 컴포넌트들 - 기타 컴포넌트들
2. **설정 UI 개발** 2. **설정 UI 개발**
- 분할 패널 생성 UI - 분할 패널 생성 UI
- 매핑 규칙 설정 UI - 매핑 규칙 설정 UI
- 미리보기 기능 - 미리보기 기능
@ -442,6 +484,7 @@ function ScreenViewPage() {
### ✅ 안전성 평가: 높음 ### ✅ 안전성 평가: 높음
**이유**: **이유**:
1. ✅ 대부분의 코드가 독립적으로 추가됨 1. ✅ 대부분의 코드가 독립적으로 추가됨
2. ✅ 기존 시스템 수정 최소화 2. ✅ 기존 시스템 수정 최소화
3. ✅ 하위 호환성 유지 3. ✅ 하위 호환성 유지
@ -450,10 +493,12 @@ function ScreenViewPage() {
### ⚠️ 주의 사항 ### ⚠️ 주의 사항
1. **화면 페이지 수정 필요** 1. **화면 페이지 수정 필요**
- 분할 패널 확인 로직 추가 - 분할 패널 확인 로직 추가
- 조건부 렌더링 구현 - 조건부 렌더링 구현
2. **점진적 구현 권장** 2. **점진적 구현 권장**
- Phase 5: 컴포넌트 어댑터 - Phase 5: 컴포넌트 어댑터
- Phase 6: 설정 UI - Phase 6: 설정 UI
- 단계별 테스트 - 단계별 테스트
@ -467,4 +512,3 @@ function ScreenViewPage() {
**충돌 위험도: 낮음 (🟢)** **충돌 위험도: 낮음 (🟢)**
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. 새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.