Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream
This commit is contained in:
commit
b190e2ba08
|
|
@ -4,6 +4,7 @@
|
|||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import {
|
||||
BatchConfigFilter,
|
||||
CreateBatchConfigRequest,
|
||||
|
|
@ -63,7 +64,7 @@ export class BatchController {
|
|||
res: Response
|
||||
) {
|
||||
try {
|
||||
const result = await BatchService.getAvailableConnections();
|
||||
const result = await BatchExternalDbService.getAvailableConnections();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
|
|
@ -99,8 +100,8 @@ export class BatchController {
|
|||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTablesFromConnection(
|
||||
type,
|
||||
const result = await BatchService.getTables(
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
|
|
@ -142,10 +143,10 @@ export class BatchController {
|
|||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTableColumns(
|
||||
type,
|
||||
connectionId,
|
||||
tableName
|
||||
const result = await BatchService.getColumns(
|
||||
tableName,
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -331,8 +331,11 @@ export class BatchManagementController {
|
|||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// executionLog가 정의되어 있는지 확인
|
||||
if (typeof executionLog !== "undefined") {
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
if (typeof executionLog !== "undefined" && executionLog) {
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
|
|||
};
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
parseInt(id),
|
||||
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||
tableName,
|
||||
originalData,
|
||||
newDataWithMeta
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ const pool = getPool();
|
|||
* 화면 임베딩 목록 조회
|
||||
* GET /api/screen-embedding?parentScreenId=1
|
||||
*/
|
||||
export async function getScreenEmbeddings(req: Request, res: Response) {
|
||||
export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { parentScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -67,7 +68,7 @@ export async function getScreenEmbeddings(req: Request, res: Response) {
|
|||
* 화면 임베딩 상세 조회
|
||||
* GET /api/screen-embedding/:id
|
||||
*/
|
||||
export async function getScreenEmbeddingById(req: Request, res: Response) {
|
||||
export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -113,7 +114,7 @@ export async function getScreenEmbeddingById(req: Request, res: Response) {
|
|||
* 화면 임베딩 생성
|
||||
* POST /api/screen-embedding
|
||||
*/
|
||||
export async function createScreenEmbedding(req: Request, res: Response) {
|
||||
export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
parentScreenId,
|
||||
|
|
@ -184,7 +185,7 @@ export async function createScreenEmbedding(req: Request, res: Response) {
|
|||
* 화면 임베딩 수정
|
||||
* PUT /api/screen-embedding/:id
|
||||
*/
|
||||
export async function updateScreenEmbedding(req: Request, res: Response) {
|
||||
export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { position, mode, config } = req.body;
|
||||
|
|
@ -257,7 +258,7 @@ export async function updateScreenEmbedding(req: Request, res: Response) {
|
|||
* 화면 임베딩 삭제
|
||||
* DELETE /api/screen-embedding/:id
|
||||
*/
|
||||
export async function deleteScreenEmbedding(req: Request, res: Response) {
|
||||
export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
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
|
||||
*/
|
||||
export async function getScreenDataTransfer(req: Request, res: Response) {
|
||||
export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { sourceScreenId, targetScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -363,7 +364,7 @@ export async function getScreenDataTransfer(req: Request, res: Response) {
|
|||
* 데이터 전달 설정 생성
|
||||
* POST /api/screen-data-transfer
|
||||
*/
|
||||
export async function createScreenDataTransfer(req: Request, res: Response) {
|
||||
export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
sourceScreenId,
|
||||
|
|
@ -436,7 +437,7 @@ export async function createScreenDataTransfer(req: Request, res: Response) {
|
|||
* 데이터 전달 설정 수정
|
||||
* PUT /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function updateScreenDataTransfer(req: Request, res: Response) {
|
||||
export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { dataReceivers, buttonConfig } = req.body;
|
||||
|
|
@ -504,7 +505,7 @@ export async function updateScreenDataTransfer(req: Request, res: Response) {
|
|||
* 데이터 전달 설정 삭제
|
||||
* DELETE /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function deleteScreenDataTransfer(req: Request, res: Response) {
|
||||
export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -548,7 +549,7 @@ export async function deleteScreenDataTransfer(req: Request, res: Response) {
|
|||
* 분할 패널 설정 조회
|
||||
* GET /api/screen-split-panel/:screenId
|
||||
*/
|
||||
export async function getScreenSplitPanel(req: Request, res: Response) {
|
||||
export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -655,7 +656,7 @@ export async function getScreenSplitPanel(req: Request, res: Response) {
|
|||
* 분할 패널 설정 생성
|
||||
* 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();
|
||||
|
||||
try {
|
||||
|
|
@ -792,7 +793,7 @@ export async function createScreenSplitPanel(req: Request, res: Response) {
|
|||
* 분할 패널 설정 수정
|
||||
* PUT /api/screen-split-panel/:id
|
||||
*/
|
||||
export async function updateScreenSplitPanel(req: Request, res: Response) {
|
||||
export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { layoutConfig } = req.body;
|
||||
|
|
@ -845,7 +846,7 @@ export async function updateScreenSplitPanel(req: Request, res: Response) {
|
|||
* 분할 패널 설정 삭제
|
||||
* 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();
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ router.use(authenticateToken);
|
|||
// 폼 데이터 CRUD
|
||||
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
||||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
|
||||
router.put("/:id", updateFormData);
|
||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
router.delete("/:id", deleteFormData);
|
||||
router.get("/:id", getFormData);
|
||||
|
||||
|
|
|
|||
|
|
@ -203,8 +203,7 @@ export class BatchExternalDbService {
|
|||
// 비밀번호 복호화
|
||||
if (connection.password) {
|
||||
try {
|
||||
const passwordEncryption = new PasswordEncryption();
|
||||
connection.password = passwordEncryption.decrypt(connection.password);
|
||||
connection.password = PasswordEncryption.decrypt(connection.password);
|
||||
} catch (error) {
|
||||
console.error("비밀번호 복호화 실패:", error);
|
||||
// 복호화 실패 시 원본 사용 (또는 에러 처리)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import cron from "node-cron";
|
||||
import cron, { ScheduledTask } from "node-cron";
|
||||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
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) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILURE",
|
||||
execution_status: "FAILED",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message:
|
||||
|
|
@ -404,4 +404,11 @@ export class BatchSchedulerService {
|
|||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 배치 작업 스케줄링 (scheduleBatch의 별칭)
|
||||
*/
|
||||
static async scheduleBatchConfig(config: any) {
|
||||
return this.scheduleBatch(config);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
UpdateBatchConfigRequest,
|
||||
} from "../types/batchTypes";
|
||||
import { BatchExternalDbService } from "./batchExternalDbService";
|
||||
import { DbConnectionManager } from "./dbConnectionManager";
|
||||
|
||||
export class BatchService {
|
||||
/**
|
||||
|
|
@ -475,7 +474,13 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 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 {
|
||||
success: true,
|
||||
data: tables,
|
||||
|
|
@ -509,7 +514,13 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 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 {
|
||||
success: true,
|
||||
data: columns,
|
||||
|
|
@ -543,7 +554,9 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 데이터 조회
|
||||
const data = await DbConnectionManager.getInternalData(tableName, 10);
|
||||
const data = await query<any>(
|
||||
`SELECT * FROM ${tableName} LIMIT 10`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
|
|
|
|||
|
|
@ -746,7 +746,7 @@ export class DynamicFormService {
|
|||
* 폼 데이터 부분 업데이트 (변경된 필드만 업데이트)
|
||||
*/
|
||||
async updateFormDataPartial(
|
||||
id: number,
|
||||
id: string | number, // 🔧 UUID 문자열도 지원
|
||||
tableName: string,
|
||||
originalData: Record<string, any>,
|
||||
newData: Record<string, any>
|
||||
|
|
@ -1662,12 +1662,47 @@ export class DynamicFormService {
|
|||
companyCode,
|
||||
});
|
||||
|
||||
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외)
|
||||
let whereClause = `"${keyField}" = $1`;
|
||||
const params: any[] = [keyValue, updateValue, userId];
|
||||
let paramIndex = 4;
|
||||
// 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인)
|
||||
const columnQuery = `
|
||||
SELECT column_name
|
||||
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);
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
const hasUpdatedBy = existingColumns.includes('updated_by');
|
||||
const hasUpdatedAt = existingColumns.includes('updated_at');
|
||||
const hasCompanyCode = existingColumns.includes('company_code');
|
||||
|
||||
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}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
|
|
@ -1675,9 +1710,7 @@ export class DynamicFormService {
|
|||
|
||||
const sqlQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET "${updateField}" = $2,
|
||||
updated_by = $3,
|
||||
updated_at = NOW()
|
||||
SET ${setClause}
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1165,12 +1165,26 @@ export class TableManagementService {
|
|||
paramCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!)
|
||||
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||
if (typeof value === "string" && value.includes("|")) {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
|
||||
// 날짜 타입이면 날짜 범위로 처리
|
||||
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
||||
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} 체크
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
type: 'internal' | 'external';
|
||||
|
|
@ -27,7 +121,7 @@ export interface BatchMappingRequest {
|
|||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
// REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 추가
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
|
||||
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
|
||||
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
|
|
@ -143,10 +146,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
console.log("✅ URL 파라미터 추가:", urlParams);
|
||||
}
|
||||
|
||||
// 🆕 editData가 있으면 formData로 설정 (수정 모드)
|
||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||
if (editData) {
|
||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||
setFormData(editData);
|
||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
} else {
|
||||
setOriginalData(null); // 신규 등록 모드
|
||||
}
|
||||
|
||||
setModalState({
|
||||
|
|
@ -177,7 +183,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setOriginalData(null); // 🆕 원본 데이터 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
|
|
@ -365,12 +371,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
|
||||
);
|
||||
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
||||
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
|
||||
} else {
|
||||
setFormData(normalizedData);
|
||||
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
}
|
||||
|
||||
// setFormData 직후 확인
|
||||
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
||||
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
|
||||
} else {
|
||||
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||
toast.error("데이터를 불러올 수 없습니다.");
|
||||
|
|
@ -619,11 +628,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
|
|
@ -637,8 +652,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
|
||||
groupedData={selectedData.length > 0 ? selectedData : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ interface InteractiveScreenViewerProps {
|
|||
disabledFields?: string[];
|
||||
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||
isInModal?: boolean;
|
||||
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
originalData?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -72,6 +74,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
groupedData,
|
||||
disabledFields = [],
|
||||
isInModal = false,
|
||||
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
|
|
@ -331,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
component={comp}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
|
|
|
|||
|
|
@ -1774,6 +1774,255 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
/>
|
||||
</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">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
|
|
@ -1784,6 +2033,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<br />
|
||||
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다
|
||||
<br />
|
||||
4. 추가 테이블 설정이 있으면 해당 테이블의 필드도 함께 변경됩니다
|
||||
<br />
|
||||
<br />
|
||||
<strong>예시:</strong> 위치정보 저장 + vehicles.status를 inactive로 변경
|
||||
<br />
|
||||
<br />
|
||||
<strong>참고:</strong> HTTPS 환경에서만 위치정보가 작동합니다.
|
||||
</p>
|
||||
|
|
@ -1852,6 +2106,62 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</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="space-y-0.5">
|
||||
<Label htmlFor="update-auto-save">변경 후 자동 저장</Label>
|
||||
|
|
@ -1899,15 +2209,78 @@ 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="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">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 예시:</strong>
|
||||
<br />
|
||||
- 운행알림 버튼: status 필드를 "active"로 변경
|
||||
- 운행알림 버튼: status를 "active"로 + 위치정보 수집
|
||||
<br />
|
||||
- 승인 버튼: approval_status 필드를 "approved"로 변경
|
||||
- 출발 버튼: status를 "inactive"로 + 위치정보 수집
|
||||
<br />
|
||||
- 완료 버튼: is_completed 필드를 "Y"로 변경
|
||||
- 완료 버튼: is_completed를 "Y"로 변경
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -360,6 +360,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handlePanelConfigChange}
|
||||
onConfigChange={handlePanelConfigChange} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ interface SplitPanelContextValue {
|
|||
|
||||
// screenId로 위치 찾기
|
||||
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);
|
||||
|
|
@ -75,6 +81,9 @@ export function SplitPanelProvider({
|
|||
// 강제 리렌더링용 상태
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// 🆕 우측에 추가된 항목 ID 상태
|
||||
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* 데이터 수신자 등록
|
||||
*/
|
||||
|
|
@ -191,6 +200,38 @@ export function SplitPanelProvider({
|
|||
[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 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
||||
splitPanelId,
|
||||
|
|
@ -202,6 +243,10 @@ export function SplitPanelProvider({
|
|||
getOtherSideReceivers,
|
||||
isInSplitPanel: true,
|
||||
getPositionByScreenId,
|
||||
addedItemIds,
|
||||
addItemIds,
|
||||
removeItemIds,
|
||||
clearItemIds,
|
||||
}), [
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
|
|
@ -211,6 +256,10 @@ export function SplitPanelProvider({
|
|||
transferToOtherSide,
|
||||
getOtherSideReceivers,
|
||||
getPositionByScreenId,
|
||||
addedItemIds,
|
||||
addItemIds,
|
||||
removeItemIds,
|
||||
clearItemIds,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export class DynamicFormApi {
|
|||
* @returns 업데이트 결과
|
||||
*/
|
||||
static async updateFormDataPartial(
|
||||
id: number,
|
||||
id: string | number, // 🔧 UUID 문자열도 지원
|
||||
originalData: Record<string, any>,
|
||||
newData: Record<string, any>,
|
||||
tableName: string,
|
||||
|
|
|
|||
|
|
@ -337,6 +337,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
const handleChange = (value: any) => {
|
||||
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
||||
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
|
||||
return;
|
||||
}
|
||||
|
||||
// React 이벤트 객체인 경우 값 추출
|
||||
let actualValue = value;
|
||||
if (value && typeof value === "object" && value.nativeEvent && value.target) {
|
||||
|
|
|
|||
|
|
@ -57,20 +57,42 @@ export function AutocompleteSearchInputComponent({
|
|||
filterCondition,
|
||||
});
|
||||
|
||||
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
|
||||
const selectedDataRef = useRef<EntitySearchResult | null>(null);
|
||||
const inputValueRef = useRef<string>("");
|
||||
|
||||
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
||||
const currentValue = isInteractive && formData && component?.columnName
|
||||
? formData[component.columnName]
|
||||
: value;
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
// selectedData 변경 시 ref도 업데이트
|
||||
useEffect(() => {
|
||||
if (currentValue && selectedData) {
|
||||
setInputValue(selectedData[displayField] || "");
|
||||
} else if (!currentValue) {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
if (selectedData) {
|
||||
selectedDataRef.current = selectedData;
|
||||
inputValueRef.current = inputValue;
|
||||
}
|
||||
}, [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(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -21,7 +21,9 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
config,
|
||||
onConfigChange,
|
||||
}: AutocompleteSearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
// 초기화 여부 추적 (첫 마운트 시에만 config로 초기화)
|
||||
const isInitialized = useRef(false);
|
||||
const [localConfig, setLocalConfig] = useState<AutocompleteSearchInputConfig>(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
|
||||
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
|
||||
|
|
@ -32,12 +34,21 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||
|
||||
// 첫 마운트 시에만 config로 초기화 (이후에는 localConfig 유지)
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
if (!isInitialized.current && config) {
|
||||
setLocalConfig(config);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
console.log("🔧 [AutocompleteConfigPanel] updateConfig:", {
|
||||
updates,
|
||||
localConfig,
|
||||
newConfig,
|
||||
});
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
|
@ -325,10 +336,11 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">외부 테이블 컬럼 *</Label>
|
||||
<Select
|
||||
value={mapping.sourceField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { sourceField: value })
|
||||
}
|
||||
value={mapping.sourceField || undefined}
|
||||
onValueChange={(value) => {
|
||||
console.log("🔧 [Select] sourceField 변경:", value);
|
||||
updateFieldMapping(index, { sourceField: value });
|
||||
}}
|
||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||
>
|
||||
<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">
|
||||
<Label className="text-xs">저장 테이블 컬럼 *</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { targetField: value })
|
||||
}
|
||||
value={mapping.targetField || undefined}
|
||||
onValueChange={(value) => {
|
||||
console.log("🔧 [Select] targetField 변경:", value);
|
||||
updateFieldMapping(index, { targetField: value });
|
||||
}}
|
||||
disabled={!localConfig.targetTable || isLoadingTargetColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -694,7 +694,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
userId, // 🆕 사용자 ID
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
|||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
|
|
@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
|||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
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
|
||||
} = props;
|
||||
} = props as any;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
|
|
|
|||
|
|
@ -94,20 +94,51 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||
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 [isSwapping, setIsSwapping] = useState(false);
|
||||
|
||||
// 현재 선택된 값
|
||||
const departureValue = formData[departureField] || "";
|
||||
const destinationValue = formData[destinationField] || "";
|
||||
// 로컬 선택 상태 (Select 컴포넌트용)
|
||||
const [localDeparture, setLocalDeparture] = useState<string>("");
|
||||
const [localDestination, setLocalDestination] = useState<string>("");
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
if (dataSource.type === "static") {
|
||||
setOptions(dataSource.staticOptions || []);
|
||||
console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
|
||||
|
||||
// 정적 옵션 처리 (기본값)
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -115,11 +146,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
// 코드 관리에서 가져오기
|
||||
setLoading(true);
|
||||
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) {
|
||||
const codeOptions = response.data.data.map((code: any) => ({
|
||||
value: code.code_value || code.codeValue,
|
||||
label: code.code_name || code.codeName,
|
||||
value: code.code_value || code.codeValue || code.code,
|
||||
label: code.code_name || code.codeName || code.name,
|
||||
}));
|
||||
setOptions(codeOptions);
|
||||
}
|
||||
|
|
@ -135,13 +168,17 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
// 테이블에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, {
|
||||
params: { pageSize: 1000 },
|
||||
const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
|
||||
params: { page: 1, pageSize: 1000 },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
const tableOptions = response.data.data.map((row: any) => ({
|
||||
value: row[dataSource.valueField || "id"],
|
||||
label: row[dataSource.labelField || "name"],
|
||||
// data가 배열인지 또는 data.rows인지 확인
|
||||
const rows = Array.isArray(response.data.data)
|
||||
? 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);
|
||||
}
|
||||
|
|
@ -153,81 +190,129 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
}
|
||||
};
|
||||
|
||||
if (!isDesignMode) {
|
||||
loadOptions();
|
||||
} else {
|
||||
// 디자인 모드에서는 샘플 데이터
|
||||
setOptions([
|
||||
{ value: "seoul", label: "서울" },
|
||||
{ value: "busan", label: "부산" },
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
]);
|
||||
}
|
||||
loadOptions();
|
||||
}, [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) {
|
||||
onFormDataChange(departureField, value);
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`);
|
||||
onFormDataChange(departureField, selectedValue);
|
||||
// 라벨 필드도 업데이트
|
||||
if (departureLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
onFormDataChange(departureLabelField, selectedOption?.label || "");
|
||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||
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) {
|
||||
onFormDataChange(destinationField, value);
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`);
|
||||
onFormDataChange(destinationField, selectedValue);
|
||||
// 라벨 필드도 업데이트
|
||||
if (destinationLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
onFormDataChange(destinationLabelField, selectedOption?.label || "");
|
||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||
if (selectedOption) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`);
|
||||
onFormDataChange(destinationLabelField, selectedOption.label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
// 출발지/도착지 교환
|
||||
const handleSwap = () => {
|
||||
if (!onFormDataChange) return;
|
||||
|
||||
setIsSwapping(true);
|
||||
|
||||
// 값 교환
|
||||
const tempDeparture = departureValue;
|
||||
const tempDestination = destinationValue;
|
||||
// 로컬 상태 교환
|
||||
const tempDeparture = localDeparture;
|
||||
const tempDestination = localDestination;
|
||||
|
||||
onFormDataChange(departureField, tempDestination);
|
||||
onFormDataChange(destinationField, tempDeparture);
|
||||
setLocalDeparture(tempDestination);
|
||||
setLocalDestination(tempDeparture);
|
||||
|
||||
// 라벨도 교환
|
||||
if (departureLabelField && destinationLabelField) {
|
||||
const tempDepartureLabel = formData[departureLabelField];
|
||||
const tempDestinationLabel = formData[destinationLabelField];
|
||||
onFormDataChange(departureLabelField, tempDestinationLabel);
|
||||
onFormDataChange(destinationLabelField, tempDepartureLabel);
|
||||
// 부모에게 전달
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(departureField, tempDestination);
|
||||
onFormDataChange(destinationField, tempDeparture);
|
||||
|
||||
// 라벨도 교환
|
||||
if (departureLabelField && destinationLabelField) {
|
||||
const depOption = options.find(o => o.value === tempDestination);
|
||||
const destOption = options.find(o => o.value === tempDeparture);
|
||||
onFormDataChange(departureLabelField, depOption?.label || "");
|
||||
onFormDataChange(destinationLabelField, destOption?.label || "");
|
||||
}
|
||||
}
|
||||
|
||||
// 애니메이션 효과
|
||||
setTimeout(() => setIsSwapping(false), 300);
|
||||
};
|
||||
|
||||
// 스타일에서 width, height 추출
|
||||
const { width, height, ...restStyle } = style || {};
|
||||
|
||||
// 선택된 라벨 가져오기
|
||||
const getDepartureLabel = () => {
|
||||
const option = options.find((opt) => opt.value === departureValue);
|
||||
return option?.label || "선택";
|
||||
const opt = options.find(o => o.value === localDeparture);
|
||||
return opt?.label || "";
|
||||
};
|
||||
|
||||
const getDestinationLabel = () => {
|
||||
const option = options.find((opt) => opt.value === destinationValue);
|
||||
return option?.label || "선택";
|
||||
const opt = options.find(o => o.value === localDestination);
|
||||
return opt?.label || "";
|
||||
};
|
||||
|
||||
// 스타일에서 width, height 추출
|
||||
const { width, height, ...restStyle } = style || {};
|
||||
// 디버그 로그
|
||||
console.log("[LocationSwapSelector] 렌더:", {
|
||||
localDeparture,
|
||||
localDestination,
|
||||
options: options.map(o => `${o.value}:${o.label}`),
|
||||
});
|
||||
|
||||
// Card 스타일 (이미지 참고)
|
||||
if (variant === "card") {
|
||||
|
|
@ -242,18 +327,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
|
||||
<Select
|
||||
value={departureValue}
|
||||
value={localDeparture || undefined}
|
||||
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">
|
||||
<SelectValue placeholder="선택">
|
||||
<span className={cn(isSwapping && "animate-pulse")}>
|
||||
{getDepartureLabel()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
<SelectTrigger className={cn(
|
||||
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
||||
isSwapping && "animate-pulse"
|
||||
)}>
|
||||
{localDeparture ? (
|
||||
<span>{getDepartureLabel()}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">선택</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -270,7 +358,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode || !departureValue || !destinationValue}
|
||||
className={cn(
|
||||
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
||||
isSwapping && "rotate-180"
|
||||
|
|
@ -284,18 +371,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
|
||||
<Select
|
||||
value={destinationValue}
|
||||
value={localDestination || undefined}
|
||||
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">
|
||||
<SelectValue placeholder="선택">
|
||||
<span className={cn(isSwapping && "animate-pulse")}>
|
||||
{getDestinationLabel()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
<SelectTrigger className={cn(
|
||||
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
||||
isSwapping && "animate-pulse"
|
||||
)}>
|
||||
{localDestination ? (
|
||||
<span>{getDestinationLabel()}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">선택</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -320,14 +410,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
|
||||
<Select
|
||||
value={departureValue}
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="선택" />
|
||||
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">선택</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -343,7 +433,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode}
|
||||
className="mt-5 h-10 w-10"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
|
|
@ -353,14 +442,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
|
||||
<Select
|
||||
value={destinationValue}
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="선택" />
|
||||
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">선택</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -381,14 +470,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
style={restStyle}
|
||||
>
|
||||
<Select
|
||||
value={departureValue}
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
<SelectValue placeholder={departureLabel} />
|
||||
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">{departureLabel}</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -403,7 +492,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
|
|
@ -411,14 +499,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
)}
|
||||
|
||||
<Select
|
||||
value={destinationValue}
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
<SelectValue placeholder={destinationLabel} />
|
||||
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">{destinationLabel}</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export function LocationSwapSelectorConfigPanel({
|
|||
}
|
||||
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
|
||||
|
||||
// 코드 카테고리 로드
|
||||
// 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시)
|
||||
useEffect(() => {
|
||||
const loadCodeCategories = async () => {
|
||||
try {
|
||||
|
|
@ -103,8 +103,11 @@ export function LocationSwapSelectorConfigPanel({
|
|||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 카테고리 로드 실패:", error);
|
||||
} catch (error: any) {
|
||||
// 404는 API가 없는 것이므로 무시
|
||||
if (error?.response?.status !== 404) {
|
||||
console.error("코드 카테고리 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCodeCategories();
|
||||
|
|
@ -139,13 +142,83 @@ export function LocationSwapSelectorConfigPanel({
|
|||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">정적 옵션 (하드코딩)</SelectItem>
|
||||
<SelectItem value="table">테이블</SelectItem>
|
||||
<SelectItem value="code">코드 관리</SelectItem>
|
||||
<SelectItem value="static">고정 옵션 (포항/광양 등)</SelectItem>
|
||||
<SelectItem value="table">테이블에서 가져오기</SelectItem>
|
||||
<SelectItem value="code">코드 관리에서 가져오기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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일 때) */}
|
||||
{config?.dataSource?.type === "table" && (
|
||||
<>
|
||||
|
|
@ -298,14 +371,14 @@ export function LocationSwapSelectorConfigPanel({
|
|||
<Label>출발지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureLabelField || ""}
|
||||
onValueChange={(value) => handleChange("departureLabelField", value)}
|
||||
value={config?.departureLabelField || "__none__"}
|
||||
onValueChange={(value) => handleChange("departureLabelField", value === "__none__" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</SelectItem>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
|
|
@ -325,14 +398,14 @@ export function LocationSwapSelectorConfigPanel({
|
|||
<Label>도착지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationLabelField || ""}
|
||||
onValueChange={(value) => handleChange("destinationLabelField", value)}
|
||||
value={config?.destinationLabelField || "__none__"}
|
||||
onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</SelectItem>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,28 @@ export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRender
|
|||
static componentDefinition = LocationSwapSelectorDefinition;
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
type: "table", // "table" | "code" | "static"
|
||||
type: "static", // "table" | "code" | "static"
|
||||
tableName: "", // 장소 테이블명
|
||||
valueField: "location_code", // 값 필드
|
||||
labelField: "location_name", // 표시 필드
|
||||
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
||||
staticOptions: [], // 정적 옵션 (type이 "static"일 때)
|
||||
staticOptions: [
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
], // 정적 옵션 (type이 "static"일 때)
|
||||
},
|
||||
// 필드 매핑
|
||||
departureField: "departure", // 출발지 저장 필드
|
||||
|
|
|
|||
|
|
@ -120,10 +120,15 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
setGroupedData(items);
|
||||
|
||||
// 🆕 원본 데이터 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);
|
||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||
|
||||
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
||||
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
||||
splitPanelContext.addItemIds(itemIds);
|
||||
}
|
||||
|
||||
// onChange 호출하여 부모에게 알림
|
||||
if (onChange && items.length > 0) {
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
|
|
@ -244,11 +249,54 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
const currentValue = parsedValueRef.current;
|
||||
|
||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||
// 🆕 필터링된 데이터 사용
|
||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||
const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData];
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode });
|
||||
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,
|
||||
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 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newItems);
|
||||
|
|
@ -268,7 +316,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
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 인터페이스 구현
|
||||
|
|
@ -311,14 +368,69 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
}
|
||||
}, [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 (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
onChange={(newValue) => {
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newValue);
|
||||
onChange?.(jsonValue);
|
||||
}}
|
||||
onChange={handleRepeaterChange}
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
|
|
|
|||
|
|
@ -330,6 +330,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
|
@ -438,8 +457,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
componentType: "table-list",
|
||||
|
||||
getSelectedData: () => {
|
||||
// 선택된 행의 실제 데이터 반환
|
||||
const selectedData = data.filter((row) => {
|
||||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||
const selectedData = filteredData.filter((row) => {
|
||||
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
|
||||
return selectedRows.has(rowId);
|
||||
});
|
||||
|
|
@ -447,7 +466,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
},
|
||||
|
||||
getAllData: () => {
|
||||
return data;
|
||||
// 🆕 필터링된 데이터 반환
|
||||
return filteredData;
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
|
|
@ -1375,31 +1395,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
setIsAllSelected(allRowsSelected && data.length > 0);
|
||||
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
setIsAllSelected(allRowsSelected && filteredData.length > 0);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
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);
|
||||
setSelectedRows(newSelectedRows);
|
||||
setIsAllSelected(true);
|
||||
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
selectedRowsData: data,
|
||||
selectedRowsData: filteredData,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 전체 데이터 저장
|
||||
if (tableConfig.selectedTable && data.length > 0) {
|
||||
if (tableConfig.selectedTable && filteredData.length > 0) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
const modalItems = data.map((row, idx) => ({
|
||||
const modalItems = filteredData.map((row, idx) => ({
|
||||
id: getRowKey(row, idx),
|
||||
originalData: row,
|
||||
additionalData: {},
|
||||
|
|
@ -2003,11 +2023,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 데이터 그룹화
|
||||
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[]>();
|
||||
|
||||
data.forEach((item) => {
|
||||
filteredData.forEach((item) => {
|
||||
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||
const keyParts = groupByColumns.map((col) => {
|
||||
// 카테고리/엔티티 타입인 경우 _name 필드 사용
|
||||
|
|
@ -2334,7 +2354,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<SingleTableWithSticky
|
||||
data={data}
|
||||
columns={visibleColumns}
|
||||
|
|
@ -2401,7 +2421,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<div
|
||||
className="flex flex-1 flex-col"
|
||||
style={{
|
||||
marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
|
|
@ -2431,7 +2450,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="sticky z-50"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "-2px",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}}
|
||||
|
|
@ -2706,7 +2725,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
})
|
||||
) : (
|
||||
// 일반 렌더링 (그룹 없음)
|
||||
data.map((row, index) => (
|
||||
filteredData.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
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 { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
|
||||
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 {
|
||||
id: string;
|
||||
|
|
@ -20,6 +23,7 @@ interface PresetFilter {
|
|||
columnLabel: string;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
|
||||
}
|
||||
|
||||
interface TableSearchWidgetProps {
|
||||
|
|
@ -280,6 +284,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
}
|
||||
}
|
||||
|
||||
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
|
||||
if (filter.filterType === "select" && Array.isArray(filterValue)) {
|
||||
filterValue = filterValue.join("|");
|
||||
}
|
||||
|
||||
return {
|
||||
...filter,
|
||||
value: filterValue || "",
|
||||
|
|
@ -289,6 +298,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
// 빈 값 체크
|
||||
if (!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;
|
||||
});
|
||||
|
||||
|
|
@ -343,12 +353,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
case "select": {
|
||||
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 기준)
|
||||
const uniqueOptions = options.reduce(
|
||||
(acc, option) => {
|
||||
|
|
@ -360,39 +364,86 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
[] 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 (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => {
|
||||
// 선택한 값의 라벨 저장
|
||||
const selectedOption = uniqueOptions.find((opt) => opt.value === val);
|
||||
if (selectedOption) {
|
||||
setSelectedLabels((prev) => ({
|
||||
...prev,
|
||||
[filter.columnName]: selectedOption.label,
|
||||
}));
|
||||
}
|
||||
handleFilterChange(filter.columnName, val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
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"
|
||||
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"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",
|
||||
selectedValues.length === 0 && "text-muted-foreground"
|
||||
)}
|
||||
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
>
|
||||
<span className="truncate">{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: `${width}px` }}
|
||||
align="start"
|
||||
>
|
||||
<SelectValue placeholder={column?.columnLabel || "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">옵션 없음</div>
|
||||
) : (
|
||||
uniqueOptions.map((option, index) => (
|
||||
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{uniqueOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${filter.columnName}-multi-${option.value}-${index}`}
|
||||
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 hover:bg-accent cursor-pointer"
|
||||
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>
|
||||
</Select>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface PresetFilter {
|
|||
columnLabel: string;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
|
||||
}
|
||||
|
||||
export function TableSearchWidgetConfigPanel({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,7 @@
|
|||
# 화면 임베딩 및 데이터 전달 시스템 구현 계획서
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [현재 문제점](#현재-문제점)
|
||||
3. [목표](#목표)
|
||||
|
|
@ -17,9 +18,11 @@
|
|||
## 개요
|
||||
|
||||
### 배경
|
||||
|
||||
현재 화면관리 시스템은 단일 화면 단위로만 동작하며, 화면 간 데이터 전달이나 화면 임베딩이 불가능합니다. 실무에서는 "입고 등록"과 같이 **좌측에서 데이터를 선택하고 우측으로 전달하여 처리하는** 복잡한 워크플로우가 필요합니다.
|
||||
|
||||
### 핵심 요구사항
|
||||
|
||||
- **화면 임베딩**: 기존 화면을 다른 화면 안에 재사용
|
||||
- **데이터 전달**: 한 화면에서 선택한 데이터를 다른 화면의 컴포넌트로 전달
|
||||
- **유연한 매핑**: 테이블뿐만 아니라 입력 필드, 셀렉트 박스, 리피터 등 모든 컴포넌트에 데이터 주입 가능
|
||||
|
|
@ -30,18 +33,22 @@
|
|||
## 현재 문제점
|
||||
|
||||
### 1. 화면 재사용 불가
|
||||
|
||||
- 각 화면은 독립적으로만 동작
|
||||
- 동일한 기능을 여러 화면에서 중복 구현
|
||||
|
||||
### 2. 화면 간 데이터 전달 불가
|
||||
|
||||
- 한 화면에서 선택한 데이터를 다른 화면으로 전달할 수 없음
|
||||
- 사용자가 수동으로 복사/붙여넣기 해야 함
|
||||
|
||||
### 3. 복잡한 워크플로우 구현 불가
|
||||
|
||||
- "발주 목록 조회 → 품목 선택 → 입고 등록"과 같은 프로세스를 단일 화면에서 처리 불가
|
||||
- 여러 화면을 오가며 작업해야 하는 불편함
|
||||
|
||||
### 4. 컴포넌트별 데이터 주입 불가
|
||||
|
||||
- 테이블에만 데이터를 추가할 수 있음
|
||||
- 입력 필드, 셀렉트 박스 등에 자동으로 값을 설정할 수 없음
|
||||
|
||||
|
|
@ -50,12 +57,14 @@
|
|||
## 목표
|
||||
|
||||
### 주요 목표
|
||||
|
||||
1. **화면 임베딩 시스템 구축**: 기존 화면을 컨테이너로 사용
|
||||
2. **범용 데이터 전달 시스템**: 모든 컴포넌트 타입 지원
|
||||
3. **시각적 매핑 설정 UI**: 드래그앤드롭으로 매핑 규칙 설정
|
||||
4. **실시간 미리보기**: 데이터 전달 결과를 즉시 확인
|
||||
|
||||
### 부가 목표
|
||||
|
||||
- 조건부 데이터 전달 (필터링)
|
||||
- 데이터 변환 함수 (합계, 평균, 개수 등)
|
||||
- 양방향 데이터 동기화
|
||||
|
|
@ -299,18 +308,13 @@ CREATE INDEX idx_screen_split_panel_screen ON screen_split_panel(screen_id, comp
|
|||
```typescript
|
||||
// 임베딩 모드
|
||||
type EmbeddingMode =
|
||||
| "view" // 읽기 전용
|
||||
| "select" // 선택 모드 (체크박스)
|
||||
| "form" // 폼 입력 모드
|
||||
| "edit"; // 편집 모드
|
||||
| "view" // 읽기 전용
|
||||
| "select" // 선택 모드 (체크박스)
|
||||
| "form" // 폼 입력 모드
|
||||
| "edit"; // 편집 모드
|
||||
|
||||
// 임베딩 위치
|
||||
type EmbeddingPosition =
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "center";
|
||||
type EmbeddingPosition = "left" | "right" | "top" | "bottom" | "center";
|
||||
|
||||
// 화면 임베딩 설정
|
||||
interface ScreenEmbedding {
|
||||
|
|
@ -320,8 +324,8 @@ interface ScreenEmbedding {
|
|||
position: EmbeddingPosition;
|
||||
mode: EmbeddingMode;
|
||||
config: {
|
||||
width?: string; // "50%", "400px"
|
||||
height?: string; // "100%", "600px"
|
||||
width?: string; // "50%", "400px"
|
||||
height?: string; // "100%", "600px"
|
||||
resizable?: boolean;
|
||||
multiSelect?: boolean;
|
||||
showToolbar?: boolean;
|
||||
|
|
@ -337,36 +341,36 @@ interface ScreenEmbedding {
|
|||
```typescript
|
||||
// 컴포넌트 타입
|
||||
type ComponentType =
|
||||
| "table" // 테이블
|
||||
| "input" // 입력 필드
|
||||
| "select" // 셀렉트 박스
|
||||
| "textarea" // 텍스트 영역
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오 버튼
|
||||
| "date" // 날짜 선택
|
||||
| "repeater" // 리피터 (반복 그룹)
|
||||
| "form-group" // 폼 그룹
|
||||
| "hidden"; // 히든 필드
|
||||
| "table" // 테이블
|
||||
| "input" // 입력 필드
|
||||
| "select" // 셀렉트 박스
|
||||
| "textarea" // 텍스트 영역
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오 버튼
|
||||
| "date" // 날짜 선택
|
||||
| "repeater" // 리피터 (반복 그룹)
|
||||
| "form-group" // 폼 그룹
|
||||
| "hidden"; // 히든 필드
|
||||
|
||||
// 데이터 수신 모드
|
||||
type DataReceiveMode =
|
||||
| "append" // 기존 데이터에 추가
|
||||
| "replace" // 기존 데이터 덮어쓰기
|
||||
| "merge"; // 기존 데이터와 병합 (키 기준)
|
||||
| "append" // 기존 데이터에 추가
|
||||
| "replace" // 기존 데이터 덮어쓰기
|
||||
| "merge"; // 기존 데이터와 병합 (키 기준)
|
||||
|
||||
// 변환 함수
|
||||
type TransformFunction =
|
||||
| "none" // 변환 없음
|
||||
| "sum" // 합계
|
||||
| "average" // 평균
|
||||
| "count" // 개수
|
||||
| "min" // 최소값
|
||||
| "max" // 최대값
|
||||
| "first" // 첫 번째 값
|
||||
| "last" // 마지막 값
|
||||
| "concat" // 문자열 결합
|
||||
| "join" // 배열 결합
|
||||
| "custom"; // 커스텀 함수
|
||||
| "none" // 변환 없음
|
||||
| "sum" // 합계
|
||||
| "average" // 평균
|
||||
| "count" // 개수
|
||||
| "min" // 최소값
|
||||
| "max" // 최대값
|
||||
| "first" // 첫 번째 값
|
||||
| "last" // 마지막 값
|
||||
| "concat" // 문자열 결합
|
||||
| "join" // 배열 결합
|
||||
| "custom"; // 커스텀 함수
|
||||
|
||||
// 조건 연산자
|
||||
type ConditionOperator =
|
||||
|
|
@ -383,12 +387,12 @@ type ConditionOperator =
|
|||
|
||||
// 매핑 규칙
|
||||
interface MappingRule {
|
||||
sourceField: string; // 소스 필드명
|
||||
targetField: string; // 타겟 필드명
|
||||
sourceField: string; // 소스 필드명
|
||||
targetField: string; // 타겟 필드명
|
||||
transform?: TransformFunction; // 변환 함수
|
||||
transformConfig?: any; // 변환 함수 설정
|
||||
defaultValue?: any; // 기본값
|
||||
required?: boolean; // 필수 여부
|
||||
transformConfig?: any; // 변환 함수 설정
|
||||
defaultValue?: any; // 기본값
|
||||
required?: boolean; // 필수 여부
|
||||
}
|
||||
|
||||
// 조건
|
||||
|
|
@ -400,16 +404,16 @@ interface Condition {
|
|||
|
||||
// 데이터 수신자
|
||||
interface DataReceiver {
|
||||
targetComponentId: string; // 타겟 컴포넌트 ID
|
||||
targetComponentId: string; // 타겟 컴포넌트 ID
|
||||
targetComponentType: ComponentType;
|
||||
mode: DataReceiveMode;
|
||||
mappingRules: MappingRule[];
|
||||
condition?: Condition; // 조건부 전달
|
||||
condition?: Condition; // 조건부 전달
|
||||
validation?: {
|
||||
required?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
customValidation?: string; // JavaScript 함수 문자열
|
||||
customValidation?: string; // JavaScript 함수 문자열
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -447,10 +451,10 @@ interface ScreenDataTransfer {
|
|||
```typescript
|
||||
// 레이아웃 설정
|
||||
interface LayoutConfig {
|
||||
splitRatio: number; // 0-100 (좌측 비율)
|
||||
splitRatio: number; // 0-100 (좌측 비율)
|
||||
resizable: boolean;
|
||||
minLeftWidth?: number; // 최소 좌측 너비 (px)
|
||||
minRightWidth?: number; // 최소 우측 너비 (px)
|
||||
minLeftWidth?: number; // 최소 좌측 너비 (px)
|
||||
minRightWidth?: number; // 최소 우측 너비 (px)
|
||||
orientation: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
|
|
@ -522,7 +526,10 @@ interface ScreenSplitPanelProps {
|
|||
onDataTransferred?: (data: any[]) => void;
|
||||
}
|
||||
|
||||
export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanelProps) {
|
||||
export function ScreenSplitPanel({
|
||||
config,
|
||||
onDataTransferred,
|
||||
}: ScreenSplitPanelProps) {
|
||||
const leftScreenRef = useRef<EmbeddedScreenHandle>(null);
|
||||
const rightScreenRef = useRef<EmbeddedScreenHandle>(null);
|
||||
const [splitRatio, setSplitRatio] = useState(config.layoutConfig.splitRatio);
|
||||
|
|
@ -541,13 +548,21 @@ export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanel
|
|||
if (config.dataTransfer.buttonConfig.validation) {
|
||||
const validation = config.dataTransfer.buttonConfig.validation;
|
||||
|
||||
if (validation.minSelection && selectedRows.length < validation.minSelection) {
|
||||
if (
|
||||
validation.minSelection &&
|
||||
selectedRows.length < validation.minSelection
|
||||
) {
|
||||
toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.maxSelection && selectedRows.length > validation.maxSelection) {
|
||||
toast.error(`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`);
|
||||
if (
|
||||
validation.maxSelection &&
|
||||
selectedRows.length > validation.maxSelection
|
||||
) {
|
||||
toast.error(
|
||||
`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -581,17 +596,12 @@ export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanel
|
|||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
<div style={{ width: `${splitRatio}%` }}>
|
||||
<EmbeddedScreen
|
||||
ref={leftScreenRef}
|
||||
embedding={config.leftEmbedding}
|
||||
/>
|
||||
<EmbeddedScreen ref={leftScreenRef} embedding={config.leftEmbedding} />
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{config.layoutConfig.resizable && (
|
||||
<Resizer
|
||||
onResize={(newRatio) => setSplitRatio(newRatio)}
|
||||
/>
|
||||
<Resizer onResize={(newRatio) => setSplitRatio(newRatio)} />
|
||||
)}
|
||||
|
||||
{/* 전달 버튼 */}
|
||||
|
|
@ -602,7 +612,10 @@ export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanel
|
|||
size={config.dataTransfer.buttonConfig.size || "default"}
|
||||
>
|
||||
{config.dataTransfer.buttonConfig.icon && (
|
||||
<Icon name={config.dataTransfer.buttonConfig.icon} className="mr-2" />
|
||||
<Icon
|
||||
name={config.dataTransfer.buttonConfig.icon}
|
||||
className="mr-2"
|
||||
/>
|
||||
)}
|
||||
{config.dataTransfer.buttonConfig.label}
|
||||
</Button>
|
||||
|
|
@ -634,77 +647,83 @@ export interface EmbeddedScreenHandle {
|
|||
getData(): any;
|
||||
}
|
||||
|
||||
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
||||
({ embedding }, ref) => {
|
||||
const [screenData, setScreenData] = useState<any>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||
export const EmbeddedScreen = forwardRef<
|
||||
EmbeddedScreenHandle,
|
||||
EmbeddedScreenProps
|
||||
>(({ embedding }, ref) => {
|
||||
const [screenData, setScreenData] = useState<any>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
// 화면 데이터 로드
|
||||
useEffect(() => {
|
||||
loadScreenData(embedding.childScreenId);
|
||||
}, [embedding.childScreenId]);
|
||||
// 화면 데이터 로드
|
||||
useEffect(() => {
|
||||
loadScreenData(embedding.childScreenId);
|
||||
}, [embedding.childScreenId]);
|
||||
|
||||
// 외부에서 호출 가능한 메서드
|
||||
useImperativeHandle(ref, () => ({
|
||||
getSelectedRows: () => selectedRows,
|
||||
// 외부에서 호출 가능한 메서드
|
||||
useImperativeHandle(ref, () => ({
|
||||
getSelectedRows: () => selectedRows,
|
||||
|
||||
clearSelection: () => {
|
||||
setSelectedRows([]);
|
||||
},
|
||||
clearSelection: () => {
|
||||
setSelectedRows([]);
|
||||
},
|
||||
|
||||
receiveData: async (data: any[], receivers: DataReceiver[]) => {
|
||||
// 각 데이터 수신자에게 데이터 전달
|
||||
for (const receiver of receivers) {
|
||||
const component = componentRefs.current.get(receiver.targetComponentId);
|
||||
receiveData: async (data: any[], receivers: DataReceiver[]) => {
|
||||
// 각 데이터 수신자에게 데이터 전달
|
||||
for (const receiver of receivers) {
|
||||
const component = componentRefs.current.get(receiver.targetComponentId);
|
||||
|
||||
if (!component) {
|
||||
console.warn(`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 조건 확인
|
||||
let filteredData = data;
|
||||
if (receiver.condition) {
|
||||
filteredData = filterData(data, receiver.condition);
|
||||
}
|
||||
|
||||
// 매핑 적용
|
||||
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
|
||||
|
||||
// 데이터 전달
|
||||
await component.receiveData(mappedData, receiver.mode);
|
||||
if (!component) {
|
||||
console.warn(
|
||||
`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
|
||||
getData: () => {
|
||||
const allData: Record<string, any> = {};
|
||||
componentRefs.current.forEach((component, id) => {
|
||||
allData[id] = component.getData();
|
||||
});
|
||||
return allData;
|
||||
// 조건 확인
|
||||
let filteredData = data;
|
||||
if (receiver.condition) {
|
||||
filteredData = filterData(data, receiver.condition);
|
||||
}
|
||||
|
||||
// 매핑 적용
|
||||
const mappedData = applyMappingRules(
|
||||
filteredData,
|
||||
receiver.mappingRules
|
||||
);
|
||||
|
||||
// 데이터 전달
|
||||
await component.receiveData(mappedData, receiver.mode);
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
// 컴포넌트 등록
|
||||
const registerComponent = (id: string, component: DataReceivable) => {
|
||||
componentRefs.current.set(id, component);
|
||||
};
|
||||
getData: () => {
|
||||
const allData: Record<string, any> = {};
|
||||
componentRefs.current.forEach((component, id) => {
|
||||
allData[id] = component.getData();
|
||||
});
|
||||
return allData;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{screenData && (
|
||||
<InteractiveScreenViewer
|
||||
screenData={screenData}
|
||||
mode={embedding.mode}
|
||||
onSelectionChanged={setSelectedRows}
|
||||
onComponentMount={registerComponent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
// 컴포넌트 등록
|
||||
const registerComponent = (id: string, component: DataReceivable) => {
|
||||
componentRefs.current.set(id, component);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{screenData && (
|
||||
<InteractiveScreenViewer
|
||||
screenData={screenData}
|
||||
mode={embedding.mode}
|
||||
onSelectionChanged={setSelectedRows}
|
||||
onComponentMount={registerComponent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. DataReceivable 구현 예시
|
||||
|
|
@ -727,8 +746,8 @@ class TableComponent implements DataReceivable {
|
|||
break;
|
||||
case "merge":
|
||||
// 키 기반 병합 (예: id 필드)
|
||||
const existingIds = new Set(this.rows.map(r => r.id));
|
||||
const newRows = data.filter(r => !existingIds.has(r.id));
|
||||
const existingIds = new Set(this.rows.map((r) => r.id));
|
||||
const newRows = data.filter((r) => !existingIds.has(r.id));
|
||||
this.rows = [...this.rows, ...newRows];
|
||||
break;
|
||||
}
|
||||
|
|
@ -835,7 +854,7 @@ export async function createScreenEmbedding(
|
|||
embedding.position,
|
||||
embedding.mode,
|
||||
JSON.stringify(embedding.config),
|
||||
companyCode
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
return { success: true, data: result.rows[0] };
|
||||
|
|
@ -923,7 +942,11 @@ export async function getScreenDataTransfer(
|
|||
AND company_code = $3
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [sourceScreenId, targetScreenId, companyCode]);
|
||||
const result = await pool.query(query, [
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." };
|
||||
|
|
@ -952,7 +975,7 @@ export async function createScreenDataTransfer(
|
|||
transfer.sourceComponentType,
|
||||
JSON.stringify(transfer.dataReceivers),
|
||||
JSON.stringify(transfer.buttonConfig),
|
||||
companyCode
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
return { success: true, data: result.rows[0] };
|
||||
|
|
@ -1042,13 +1065,22 @@ export async function createScreenSplitPanel(
|
|||
await client.query("BEGIN");
|
||||
|
||||
// 1. 좌측 임베딩 생성
|
||||
const leftEmbedding = await createScreenEmbedding(panel.leftEmbedding, companyCode);
|
||||
const leftEmbedding = await createScreenEmbedding(
|
||||
panel.leftEmbedding,
|
||||
companyCode
|
||||
);
|
||||
|
||||
// 2. 우측 임베딩 생성
|
||||
const rightEmbedding = await createScreenEmbedding(panel.rightEmbedding, companyCode);
|
||||
const rightEmbedding = await createScreenEmbedding(
|
||||
panel.rightEmbedding,
|
||||
companyCode
|
||||
);
|
||||
|
||||
// 3. 데이터 전달 설정 생성
|
||||
const dataTransfer = await createScreenDataTransfer(panel.dataTransfer, companyCode);
|
||||
const dataTransfer = await createScreenDataTransfer(
|
||||
panel.dataTransfer,
|
||||
companyCode
|
||||
);
|
||||
|
||||
// 4. 분할 패널 생성
|
||||
const query = `
|
||||
|
|
@ -1065,7 +1097,7 @@ export async function createScreenSplitPanel(
|
|||
rightEmbedding.data!.id,
|
||||
dataTransfer.data!.id,
|
||||
JSON.stringify(panel.layoutConfig),
|
||||
companyCode
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
|
@ -1087,6 +1119,7 @@ export async function createScreenSplitPanel(
|
|||
### Phase 1: 기본 인프라 구축 (1-2주)
|
||||
|
||||
#### 1.1 데이터베이스 마이그레이션
|
||||
|
||||
- [ ] `screen_embedding` 테이블 생성
|
||||
- [ ] `screen_data_transfer` 테이블 생성
|
||||
- [ ] `screen_split_panel` 테이블 생성
|
||||
|
|
@ -1094,12 +1127,14 @@ export async function createScreenSplitPanel(
|
|||
- [ ] 샘플 데이터 삽입
|
||||
|
||||
#### 1.2 타입 정의
|
||||
|
||||
- [ ] TypeScript 인터페이스 작성
|
||||
- [ ] `types/screen-embedding.ts`
|
||||
- [ ] `types/data-transfer.ts`
|
||||
- [ ] `types/split-panel.ts`
|
||||
|
||||
#### 1.3 백엔드 API
|
||||
|
||||
- [ ] 화면 임베딩 CRUD API
|
||||
- [ ] 데이터 전달 설정 CRUD API
|
||||
- [ ] 분할 패널 CRUD API
|
||||
|
|
@ -1108,12 +1143,14 @@ export async function createScreenSplitPanel(
|
|||
### Phase 2: 화면 임베딩 기능 (2-3주)
|
||||
|
||||
#### 2.1 EmbeddedScreen 컴포넌트
|
||||
|
||||
- [ ] 기본 임베딩 기능
|
||||
- [ ] 모드별 렌더링 (view, select, form, edit)
|
||||
- [ ] 선택 모드 구현 (체크박스)
|
||||
- [ ] 이벤트 핸들링
|
||||
|
||||
#### 2.2 DataReceivable 인터페이스 구현
|
||||
|
||||
- [ ] TableComponent
|
||||
- [ ] InputComponent
|
||||
- [ ] SelectComponent
|
||||
|
|
@ -1123,6 +1160,7 @@ export async function createScreenSplitPanel(
|
|||
- [ ] HiddenComponent
|
||||
|
||||
#### 2.3 컴포넌트 등록 시스템
|
||||
|
||||
- [ ] 컴포넌트 마운트 시 자동 등록
|
||||
- [ ] 컴포넌트 ID 관리
|
||||
- [ ] 컴포넌트 참조 관리
|
||||
|
|
@ -1130,6 +1168,7 @@ export async function createScreenSplitPanel(
|
|||
### Phase 3: 데이터 전달 시스템 (2-3주)
|
||||
|
||||
#### 3.1 매핑 엔진
|
||||
|
||||
- [ ] 매핑 규칙 파싱
|
||||
- [ ] 필드 매핑 적용
|
||||
- [ ] 변환 함수 구현
|
||||
|
|
@ -1139,11 +1178,13 @@ export async function createScreenSplitPanel(
|
|||
- [ ] concat, join
|
||||
|
||||
#### 3.2 조건부 전달
|
||||
|
||||
- [ ] 조건 파싱
|
||||
- [ ] 필터링 로직
|
||||
- [ ] 복합 조건 지원
|
||||
|
||||
#### 3.3 검증 시스템
|
||||
|
||||
- [ ] 필수 필드 검증
|
||||
- [ ] 최소/최대 행 수 검증
|
||||
- [ ] 커스텀 검증 함수 실행
|
||||
|
|
@ -1151,18 +1192,21 @@ export async function createScreenSplitPanel(
|
|||
### Phase 4: 분할 패널 UI (2-3주)
|
||||
|
||||
#### 4.1 ScreenSplitPanel 컴포넌트
|
||||
|
||||
- [ ] 기본 레이아웃
|
||||
- [ ] 리사이저 구현
|
||||
- [ ] 전달 버튼
|
||||
- [ ] 반응형 디자인
|
||||
|
||||
#### 4.2 설정 UI
|
||||
|
||||
- [ ] 화면 선택 드롭다운
|
||||
- [ ] 매핑 규칙 설정 UI
|
||||
- [ ] 드래그앤드롭 매핑
|
||||
- [ ] 미리보기 기능
|
||||
|
||||
#### 4.3 시각적 피드백
|
||||
|
||||
- [ ] 데이터 전달 애니메이션
|
||||
- [ ] 로딩 상태 표시
|
||||
- [ ] 성공/실패 토스트
|
||||
|
|
@ -1170,14 +1214,17 @@ export async function createScreenSplitPanel(
|
|||
### Phase 5: 고급 기능 (2-3주)
|
||||
|
||||
#### 5.1 양방향 동기화
|
||||
|
||||
- [ ] 우측 → 좌측 데이터 반영
|
||||
- [ ] 실시간 업데이트
|
||||
|
||||
#### 5.2 트랜잭션 지원
|
||||
|
||||
- [ ] 전체 성공 또는 전체 실패
|
||||
- [ ] 롤백 기능
|
||||
|
||||
#### 5.3 성능 최적화
|
||||
|
||||
- [ ] 대량 데이터 처리
|
||||
- [ ] 가상 스크롤링
|
||||
- [ ] 메모이제이션
|
||||
|
|
@ -1185,15 +1232,18 @@ export async function createScreenSplitPanel(
|
|||
### Phase 6: 테스트 및 문서화 (1-2주)
|
||||
|
||||
#### 6.1 단위 테스트
|
||||
|
||||
- [ ] 매핑 엔진 테스트
|
||||
- [ ] 변환 함수 테스트
|
||||
- [ ] 검증 로직 테스트
|
||||
|
||||
#### 6.2 통합 테스트
|
||||
|
||||
- [ ] 전체 워크플로우 테스트
|
||||
- [ ] 실제 시나리오 테스트
|
||||
|
||||
#### 6.3 문서화
|
||||
|
||||
- [ ] 사용자 가이드
|
||||
- [ ] 개발자 문서
|
||||
- [ ] API 문서
|
||||
|
|
@ -1205,6 +1255,7 @@ export async function createScreenSplitPanel(
|
|||
### 시나리오 1: 입고 등록
|
||||
|
||||
#### 요구사항
|
||||
|
||||
- 발주 목록에서 품목을 선택하여 입고 등록
|
||||
- 선택된 품목의 정보를 입고 처리 품목 테이블에 추가
|
||||
- 공급자 정보를 자동으로 입력 필드에 설정
|
||||
|
|
@ -1216,23 +1267,23 @@ export async function createScreenSplitPanel(
|
|||
const 입고등록_설정: ScreenSplitPanel = {
|
||||
screenId: 100,
|
||||
leftEmbedding: {
|
||||
childScreenId: 10, // 발주 목록 조회 화면
|
||||
childScreenId: 10, // 발주 목록 조회 화면
|
||||
position: "left",
|
||||
mode: "select",
|
||||
config: {
|
||||
width: "50%",
|
||||
multiSelect: true,
|
||||
showSearch: true,
|
||||
showPagination: true
|
||||
}
|
||||
showPagination: true,
|
||||
},
|
||||
},
|
||||
rightEmbedding: {
|
||||
childScreenId: 20, // 입고 등록 폼 화면
|
||||
childScreenId: 20, // 입고 등록 폼 화면
|
||||
position: "right",
|
||||
mode: "form",
|
||||
config: {
|
||||
width: "50%"
|
||||
}
|
||||
width: "50%",
|
||||
},
|
||||
},
|
||||
dataTransfer: {
|
||||
sourceScreenId: 10,
|
||||
|
|
@ -1248,8 +1299,8 @@ const 입고등록_설정: ScreenSplitPanel = {
|
|||
{ sourceField: "품목코드", targetField: "품목코드" },
|
||||
{ sourceField: "품목명", targetField: "품목명" },
|
||||
{ sourceField: "발주수량", targetField: "발주수량" },
|
||||
{ sourceField: "미입고수량", targetField: "입고수량" }
|
||||
]
|
||||
{ sourceField: "미입고수량", targetField: "입고수량" },
|
||||
],
|
||||
},
|
||||
{
|
||||
targetComponentId: "input-공급자",
|
||||
|
|
@ -1259,9 +1310,9 @@ const 입고등록_설정: ScreenSplitPanel = {
|
|||
{
|
||||
sourceField: "공급자",
|
||||
targetField: "value",
|
||||
transform: "first"
|
||||
}
|
||||
]
|
||||
transform: "first",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
targetComponentId: "input-품목수",
|
||||
|
|
@ -1271,10 +1322,10 @@ const 입고등록_설정: ScreenSplitPanel = {
|
|||
{
|
||||
sourceField: "품목코드",
|
||||
targetField: "value",
|
||||
transform: "count"
|
||||
}
|
||||
]
|
||||
}
|
||||
transform: "count",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
buttonConfig: {
|
||||
label: "선택 품목 추가",
|
||||
|
|
@ -1282,23 +1333,24 @@ const 입고등록_설정: ScreenSplitPanel = {
|
|||
icon: "ArrowRight",
|
||||
validation: {
|
||||
requireSelection: true,
|
||||
minSelection: 1
|
||||
}
|
||||
}
|
||||
minSelection: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
layoutConfig: {
|
||||
splitRatio: 50,
|
||||
resizable: true,
|
||||
minLeftWidth: 400,
|
||||
minRightWidth: 600,
|
||||
orientation: "horizontal"
|
||||
}
|
||||
orientation: "horizontal",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 시나리오 2: 수주 등록
|
||||
|
||||
#### 요구사항
|
||||
|
||||
- 견적서 목록에서 품목을 선택하여 수주 등록
|
||||
- 고객 정보를 자동으로 폼에 설정
|
||||
- 품목별 수량 및 금액 자동 계산
|
||||
|
|
@ -1310,21 +1362,21 @@ const 입고등록_설정: ScreenSplitPanel = {
|
|||
const 수주등록_설정: ScreenSplitPanel = {
|
||||
screenId: 101,
|
||||
leftEmbedding: {
|
||||
childScreenId: 30, // 견적서 목록 조회 화면
|
||||
childScreenId: 30, // 견적서 목록 조회 화면
|
||||
position: "left",
|
||||
mode: "select",
|
||||
config: {
|
||||
width: "40%",
|
||||
multiSelect: true
|
||||
}
|
||||
multiSelect: true,
|
||||
},
|
||||
},
|
||||
rightEmbedding: {
|
||||
childScreenId: 40, // 수주 등록 폼 화면
|
||||
childScreenId: 40, // 수주 등록 폼 화면
|
||||
position: "right",
|
||||
mode: "form",
|
||||
config: {
|
||||
width: "60%"
|
||||
}
|
||||
width: "60%",
|
||||
},
|
||||
},
|
||||
dataTransfer: {
|
||||
sourceScreenId: 30,
|
||||
|
|
@ -1344,18 +1396,18 @@ const 수주등록_설정: ScreenSplitPanel = {
|
|||
targetField: "금액",
|
||||
transform: "custom",
|
||||
transformConfig: {
|
||||
formula: "수량 * 단가"
|
||||
}
|
||||
}
|
||||
]
|
||||
formula: "수량 * 단가",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
targetComponentId: "input-고객명",
|
||||
targetComponentType: "input",
|
||||
mode: "replace",
|
||||
mappingRules: [
|
||||
{ sourceField: "고객명", targetField: "value", transform: "first" }
|
||||
]
|
||||
{ sourceField: "고객명", targetField: "value", transform: "first" },
|
||||
],
|
||||
},
|
||||
{
|
||||
targetComponentId: "input-총금액",
|
||||
|
|
@ -1365,28 +1417,29 @@ const 수주등록_설정: ScreenSplitPanel = {
|
|||
{
|
||||
sourceField: "금액",
|
||||
targetField: "value",
|
||||
transform: "sum"
|
||||
}
|
||||
]
|
||||
}
|
||||
transform: "sum",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
buttonConfig: {
|
||||
label: "견적서 불러오기",
|
||||
position: "center",
|
||||
icon: "Download"
|
||||
}
|
||||
icon: "Download",
|
||||
},
|
||||
},
|
||||
layoutConfig: {
|
||||
splitRatio: 40,
|
||||
resizable: true,
|
||||
orientation: "horizontal"
|
||||
}
|
||||
orientation: "horizontal",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 시나리오 3: 출고 등록
|
||||
|
||||
#### 요구사항
|
||||
|
||||
- 재고 목록에서 품목을 선택하여 출고 등록
|
||||
- 재고 수량 확인 및 경고
|
||||
- 출고 가능 수량만 필터링
|
||||
|
|
@ -1398,21 +1451,21 @@ const 수주등록_설정: ScreenSplitPanel = {
|
|||
const 출고등록_설정: ScreenSplitPanel = {
|
||||
screenId: 102,
|
||||
leftEmbedding: {
|
||||
childScreenId: 50, // 재고 목록 조회 화면
|
||||
childScreenId: 50, // 재고 목록 조회 화면
|
||||
position: "left",
|
||||
mode: "select",
|
||||
config: {
|
||||
width: "45%",
|
||||
multiSelect: true
|
||||
}
|
||||
multiSelect: true,
|
||||
},
|
||||
},
|
||||
rightEmbedding: {
|
||||
childScreenId: 60, // 출고 등록 폼 화면
|
||||
childScreenId: 60, // 출고 등록 폼 화면
|
||||
position: "right",
|
||||
mode: "form",
|
||||
config: {
|
||||
width: "55%"
|
||||
}
|
||||
width: "55%",
|
||||
},
|
||||
},
|
||||
dataTransfer: {
|
||||
sourceScreenId: 50,
|
||||
|
|
@ -1426,13 +1479,13 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
{ sourceField: "품목코드", targetField: "품목코드" },
|
||||
{ sourceField: "품목명", targetField: "품목명" },
|
||||
{ sourceField: "재고수량", targetField: "가용수량" },
|
||||
{ sourceField: "창고", targetField: "출고창고" }
|
||||
{ sourceField: "창고", targetField: "출고창고" },
|
||||
],
|
||||
condition: {
|
||||
field: "재고수량",
|
||||
operator: "greaterThan",
|
||||
value: 0
|
||||
}
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
targetComponentId: "input-총출고수량",
|
||||
|
|
@ -1442,10 +1495,10 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
{
|
||||
sourceField: "재고수량",
|
||||
targetField: "value",
|
||||
transform: "sum"
|
||||
}
|
||||
]
|
||||
}
|
||||
transform: "sum",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
buttonConfig: {
|
||||
label: "출고 품목 추가",
|
||||
|
|
@ -1453,15 +1506,15 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
icon: "ArrowRight",
|
||||
validation: {
|
||||
requireSelection: true,
|
||||
confirmMessage: "선택한 품목을 출고 처리하시겠습니까?"
|
||||
}
|
||||
}
|
||||
confirmMessage: "선택한 품목을 출고 처리하시겠습니까?",
|
||||
},
|
||||
},
|
||||
},
|
||||
layoutConfig: {
|
||||
splitRatio: 45,
|
||||
resizable: true,
|
||||
orientation: "horizontal"
|
||||
}
|
||||
orientation: "horizontal",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
|
|
@ -1472,11 +1525,13 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
### 1. 성능 최적화
|
||||
|
||||
#### 대량 데이터 처리
|
||||
|
||||
- 가상 스크롤링 적용
|
||||
- 청크 단위 데이터 전달
|
||||
- 백그라운드 처리
|
||||
|
||||
#### 메모리 관리
|
||||
|
||||
- 컴포넌트 언마운트 시 참조 해제
|
||||
- 이벤트 리스너 정리
|
||||
- 메모이제이션 활용
|
||||
|
|
@ -1484,11 +1539,13 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
### 2. 보안
|
||||
|
||||
#### 권한 검증
|
||||
|
||||
- 화면 접근 권한 확인
|
||||
- 데이터 전달 권한 확인
|
||||
- 멀티테넌시 격리
|
||||
|
||||
#### 데이터 검증
|
||||
|
||||
- 입력값 검증
|
||||
- SQL 인젝션 방지
|
||||
- XSS 방지
|
||||
|
|
@ -1496,22 +1553,26 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
### 3. 에러 처리
|
||||
|
||||
#### 사용자 친화적 메시지
|
||||
|
||||
- 명확한 오류 메시지
|
||||
- 복구 방법 안내
|
||||
- 로그 기록
|
||||
|
||||
#### 트랜잭션 롤백
|
||||
|
||||
- 부분 실패 시 전체 롤백
|
||||
- 데이터 일관성 유지
|
||||
|
||||
### 4. 확장성
|
||||
|
||||
#### 플러그인 시스템
|
||||
|
||||
- 커스텀 변환 함수 등록
|
||||
- 커스텀 검증 함수 등록
|
||||
- 커스텀 컴포넌트 타입 추가
|
||||
|
||||
#### 이벤트 시스템
|
||||
|
||||
- 데이터 전달 전/후 이벤트
|
||||
- 커스텀 이벤트 핸들러
|
||||
|
||||
|
|
@ -1520,31 +1581,37 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
## 마일스톤
|
||||
|
||||
### M1: 기본 인프라 (2주)
|
||||
|
||||
- 데이터베이스 스키마 완성
|
||||
- 백엔드 API 완성
|
||||
- 타입 정의 완성
|
||||
|
||||
### M2: 화면 임베딩 (3주)
|
||||
|
||||
- EmbeddedScreen 컴포넌트 완성
|
||||
- DataReceivable 인터페이스 구현 완료
|
||||
- 선택 모드 동작 확인
|
||||
|
||||
### M3: 데이터 전달 (3주)
|
||||
|
||||
- 매핑 엔진 완성
|
||||
- 변환 함수 구현 완료
|
||||
- 조건부 전달 동작 확인
|
||||
|
||||
### M4: 분할 패널 UI (3주)
|
||||
|
||||
- ScreenSplitPanel 컴포넌트 완성
|
||||
- 설정 UI 완성
|
||||
- 입고 등록 시나리오 완성
|
||||
|
||||
### M5: 고급 기능 및 최적화 (3주)
|
||||
|
||||
- 양방향 동기화 완성
|
||||
- 성능 최적화 완료
|
||||
- 전체 테스트 통과
|
||||
|
||||
### M6: 문서화 및 배포 (1주)
|
||||
|
||||
- 사용자 가이드 작성
|
||||
- 개발자 문서 작성
|
||||
- 프로덕션 배포
|
||||
|
|
@ -1567,6 +1634,7 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
## 성공 지표
|
||||
|
||||
### 기능적 지표
|
||||
|
||||
- [ ] 입고 등록 시나리오 완벽 동작
|
||||
- [ ] 수주 등록 시나리오 완벽 동작
|
||||
- [ ] 출고 등록 시나리오 완벽 동작
|
||||
|
|
@ -1574,11 +1642,13 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
- [ ] 모든 변환 함수 정상 동작
|
||||
|
||||
### 성능 지표
|
||||
|
||||
- [ ] 1000개 행 데이터 전달 < 1초
|
||||
- [ ] 화면 로딩 시간 < 2초
|
||||
- [ ] 메모리 사용량 < 100MB
|
||||
|
||||
### 사용성 지표
|
||||
|
||||
- [ ] 설정 UI 직관적
|
||||
- [ ] 에러 메시지 명확
|
||||
- [ ] 문서 완성도 90% 이상
|
||||
|
|
@ -1588,15 +1658,18 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
## 리스크 관리
|
||||
|
||||
### 기술적 리스크
|
||||
|
||||
- **복잡도 증가**: 단계별 구현으로 관리
|
||||
- **성능 문제**: 초기부터 최적화 고려
|
||||
- **호환성 문제**: 기존 시스템과 충돌 방지
|
||||
|
||||
### 일정 리스크
|
||||
|
||||
- **예상 기간 초과**: 버퍼 2주 확보
|
||||
- **우선순위 변경**: 핵심 기능 먼저 구현
|
||||
|
||||
### 인력 리스크
|
||||
|
||||
- **담당자 부재**: 문서화 철저히
|
||||
- **지식 공유**: 주간 리뷰 미팅
|
||||
|
||||
|
|
@ -1605,4 +1678,3 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
## 결론
|
||||
|
||||
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@
|
|||
**생성된 테이블**:
|
||||
|
||||
1. **screen_embedding** (화면 임베딩 설정)
|
||||
|
||||
- 한 화면을 다른 화면 안에 임베드
|
||||
- 위치 (left, right, top, bottom, center)
|
||||
- 모드 (view, select, form, edit)
|
||||
- 설정 (width, height, multiSelect 등)
|
||||
|
||||
2. **screen_data_transfer** (데이터 전달 설정)
|
||||
|
||||
- 소스 화면 → 타겟 화면 데이터 전달
|
||||
- 데이터 수신자 배열 (JSONB)
|
||||
- 매핑 규칙, 조건, 검증
|
||||
|
|
@ -38,6 +40,7 @@
|
|||
- 레이아웃 설정 (splitRatio, resizable 등)
|
||||
|
||||
**샘플 데이터**:
|
||||
|
||||
- 입고 등록 시나리오 샘플 데이터 포함
|
||||
- 발주 목록 → 입고 처리 품목 매핑 예시
|
||||
|
||||
|
|
@ -46,6 +49,7 @@
|
|||
**파일**: `frontend/types/screen-embedding.ts`
|
||||
|
||||
**주요 타입**:
|
||||
|
||||
```typescript
|
||||
// 화면 임베딩
|
||||
- EmbeddingMode: "view" | "select" | "form" | "edit"
|
||||
|
|
@ -68,12 +72,14 @@
|
|||
#### 1.3 백엔드 API
|
||||
|
||||
**파일**:
|
||||
|
||||
- `backend-node/src/controllers/screenEmbeddingController.ts`
|
||||
- `backend-node/src/routes/screenEmbeddingRoutes.ts`
|
||||
|
||||
**API 엔드포인트**:
|
||||
|
||||
**화면 임베딩**:
|
||||
|
||||
- `GET /api/screen-embedding?parentScreenId=1` - 목록 조회
|
||||
- `GET /api/screen-embedding/:id` - 상세 조회
|
||||
- `POST /api/screen-embedding` - 생성
|
||||
|
|
@ -81,18 +87,21 @@
|
|||
- `DELETE /api/screen-embedding/:id` - 삭제
|
||||
|
||||
**데이터 전달**:
|
||||
|
||||
- `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회
|
||||
- `POST /api/screen-data-transfer` - 생성
|
||||
- `PUT /api/screen-data-transfer/:id` - 수정
|
||||
- `DELETE /api/screen-data-transfer/:id` - 삭제
|
||||
|
||||
**분할 패널**:
|
||||
|
||||
- `GET /api/screen-split-panel/:screenId` - 조회
|
||||
- `POST /api/screen-split-panel` - 생성 (트랜잭션)
|
||||
- `PUT /api/screen-split-panel/:id` - 수정
|
||||
- `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE)
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 멀티테넌시 지원 (company_code 필터링)
|
||||
- ✅ 트랜잭션 처리 (분할 패널 생성/삭제)
|
||||
- ✅ 외래키 CASCADE 처리
|
||||
|
|
@ -103,25 +112,24 @@
|
|||
**파일**: `frontend/lib/api/screenEmbedding.ts`
|
||||
|
||||
**함수**:
|
||||
|
||||
```typescript
|
||||
// 화면 임베딩
|
||||
- getScreenEmbeddings(parentScreenId)
|
||||
- getScreenEmbeddingById(id)
|
||||
- createScreenEmbedding(data)
|
||||
- updateScreenEmbedding(id, data)
|
||||
- deleteScreenEmbedding(id)
|
||||
|
||||
// 데이터 전달
|
||||
- getScreenDataTransfer(sourceScreenId, targetScreenId)
|
||||
- createScreenDataTransfer(data)
|
||||
- updateScreenDataTransfer(id, data)
|
||||
- deleteScreenDataTransfer(id)
|
||||
|
||||
// 분할 패널
|
||||
- getScreenSplitPanel(screenId)
|
||||
- createScreenSplitPanel(data)
|
||||
- updateScreenSplitPanel(id, layoutConfig)
|
||||
- deleteScreenSplitPanel(id)
|
||||
-getScreenEmbeddings(parentScreenId) -
|
||||
getScreenEmbeddingById(id) -
|
||||
createScreenEmbedding(data) -
|
||||
updateScreenEmbedding(id, data) -
|
||||
deleteScreenEmbedding(id) -
|
||||
// 데이터 전달
|
||||
getScreenDataTransfer(sourceScreenId, targetScreenId) -
|
||||
createScreenDataTransfer(data) -
|
||||
updateScreenDataTransfer(id, data) -
|
||||
deleteScreenDataTransfer(id) -
|
||||
// 분할 패널
|
||||
getScreenSplitPanel(screenId) -
|
||||
createScreenSplitPanel(data) -
|
||||
updateScreenSplitPanel(id, layoutConfig) -
|
||||
deleteScreenSplitPanel(id);
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -133,6 +141,7 @@
|
|||
**파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx`
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- ✅ 화면 데이터 로드
|
||||
- ✅ 모드별 렌더링 (view, select, form, edit)
|
||||
- ✅ 선택 모드 지원 (체크박스)
|
||||
|
|
@ -141,6 +150,7 @@
|
|||
- ✅ 로딩/에러 상태 UI
|
||||
|
||||
**외부 인터페이스** (useImperativeHandle):
|
||||
|
||||
```typescript
|
||||
- getSelectedRows(): any[]
|
||||
- clearSelection(): void
|
||||
|
|
@ -149,6 +159,7 @@
|
|||
```
|
||||
|
||||
**데이터 수신 프로세스**:
|
||||
|
||||
1. 조건 필터링 (condition)
|
||||
2. 매핑 규칙 적용 (mappingRules)
|
||||
3. 검증 (validation)
|
||||
|
|
@ -165,10 +176,12 @@
|
|||
**주요 함수**:
|
||||
|
||||
1. **applyMappingRules(data, rules)**
|
||||
|
||||
- 일반 매핑: 각 행에 대해 필드 매핑
|
||||
- 변환 매핑: 집계 함수 적용
|
||||
|
||||
2. **변환 함수 지원**:
|
||||
|
||||
- `sum`: 합계
|
||||
- `average`: 평균
|
||||
- `count`: 개수
|
||||
|
|
@ -177,15 +190,18 @@
|
|||
- `concat`, `join`: 문자열 결합
|
||||
|
||||
3. **filterDataByCondition(data, condition)**
|
||||
|
||||
- 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn
|
||||
|
||||
4. **validateMappingResult(data, rules)**
|
||||
|
||||
- 필수 필드 검증
|
||||
|
||||
5. **previewMapping(sampleData, rules)**
|
||||
- 매핑 결과 미리보기
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 중첩 객체 지원 (`user.address.city`)
|
||||
- ✅ 타입 안전성
|
||||
- ✅ 에러 처리
|
||||
|
|
@ -195,6 +211,7 @@
|
|||
**파일**: `frontend/lib/utils/logger.ts`
|
||||
|
||||
**기능**:
|
||||
|
||||
- debug, info, warn, error 레벨
|
||||
- 개발 환경에서만 debug 출력
|
||||
- 타임스탬프 포함
|
||||
|
|
@ -208,6 +225,7 @@
|
|||
**파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx`
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- ✅ 좌우 화면 임베딩
|
||||
- ✅ 리사이저 (드래그로 비율 조정)
|
||||
- ✅ 데이터 전달 버튼
|
||||
|
|
@ -218,6 +236,7 @@
|
|||
- ✅ 전달 후 선택 초기화 (옵션)
|
||||
|
||||
**UI 구조**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │
|
||||
|
|
@ -230,6 +249,7 @@
|
|||
```
|
||||
|
||||
**이벤트 흐름**:
|
||||
|
||||
1. 좌측에서 행 선택 → 선택 카운트 업데이트
|
||||
2. 전달 버튼 클릭 → 검증
|
||||
3. 우측 화면의 컴포넌트들에 데이터 전달
|
||||
|
|
@ -281,7 +301,7 @@ ERP-node/
|
|||
const inboundConfig: ScreenSplitPanel = {
|
||||
screenId: 100,
|
||||
leftEmbedding: {
|
||||
childScreenId: 10, // 발주 목록 조회
|
||||
childScreenId: 10, // 발주 목록 조회
|
||||
position: "left",
|
||||
mode: "select",
|
||||
config: {
|
||||
|
|
@ -290,7 +310,7 @@ const inboundConfig: ScreenSplitPanel = {
|
|||
},
|
||||
},
|
||||
rightEmbedding: {
|
||||
childScreenId: 20, // 입고 등록 폼
|
||||
childScreenId: 20, // 입고 등록 폼
|
||||
position: "right",
|
||||
mode: "form",
|
||||
config: {
|
||||
|
|
@ -352,7 +372,7 @@ const inboundConfig: ScreenSplitPanel = {
|
|||
onDataTransferred={(data) => {
|
||||
console.log("전달된 데이터:", data);
|
||||
}}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -395,6 +415,7 @@ const inboundConfig: ScreenSplitPanel = {
|
|||
### Phase 5: 고급 기능 (예정)
|
||||
|
||||
1. **DataReceivable 인터페이스 구현**
|
||||
|
||||
- TableComponent
|
||||
- InputComponent
|
||||
- SelectComponent
|
||||
|
|
@ -402,6 +423,7 @@ const inboundConfig: ScreenSplitPanel = {
|
|||
- 기타 컴포넌트들
|
||||
|
||||
2. **양방향 동기화**
|
||||
|
||||
- 우측 → 좌측 데이터 반영
|
||||
- 실시간 업데이트
|
||||
|
||||
|
|
@ -412,6 +434,7 @@ const inboundConfig: ScreenSplitPanel = {
|
|||
### Phase 6: 설정 UI (예정)
|
||||
|
||||
1. **시각적 매핑 설정 UI**
|
||||
|
||||
- 드래그앤드롭으로 필드 매핑
|
||||
- 변환 함수 선택
|
||||
- 조건 설정
|
||||
|
|
@ -463,7 +486,7 @@ import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
|
|||
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] TypeScript 타입 정의
|
||||
- [x] 백엔드 API (15개 엔드포인트)
|
||||
|
|
@ -481,6 +505,7 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
- [x] 로거 유틸리티
|
||||
|
||||
### 다음 단계
|
||||
|
||||
- [ ] DataReceivable 구현 (각 컴포넌트 타입별)
|
||||
- [ ] 설정 UI (드래그앤드롭 매핑)
|
||||
- [ ] 미리보기 기능
|
||||
|
|
@ -500,4 +525,3 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
- ✅ 매핑 엔진 완성
|
||||
|
||||
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
### 1. 데이터베이스 스키마
|
||||
|
||||
#### 새로운 테이블 (독립적)
|
||||
|
||||
```sql
|
||||
- screen_embedding (신규)
|
||||
- screen_data_transfer (신규)
|
||||
|
|
@ -18,11 +19,13 @@
|
|||
```
|
||||
|
||||
**충돌 없는 이유**:
|
||||
|
||||
- ✅ 완전히 새로운 테이블명
|
||||
- ✅ 기존 테이블과 이름 중복 없음
|
||||
- ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용)
|
||||
|
||||
#### 기존 테이블 (영향 없음)
|
||||
|
||||
```sql
|
||||
- screen_definitions (변경 없음)
|
||||
- screen_layouts (변경 없음)
|
||||
|
|
@ -32,6 +35,7 @@
|
|||
```
|
||||
|
||||
**확인 사항**:
|
||||
|
||||
- ✅ 기존 테이블 구조 변경 없음
|
||||
- ✅ 기존 데이터 마이그레이션 불필요
|
||||
- ✅ 기존 쿼리 영향 없음
|
||||
|
|
@ -41,6 +45,7 @@
|
|||
### 2. API 엔드포인트
|
||||
|
||||
#### 새로운 엔드포인트 (독립적)
|
||||
|
||||
```
|
||||
POST /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/* (변경 없음)
|
||||
|
|
@ -75,16 +82,19 @@ DELETE /api/screen-split-panel/:id
|
|||
### 3. TypeScript 타입
|
||||
|
||||
#### 새로운 타입 파일 (독립적)
|
||||
|
||||
```typescript
|
||||
frontend/types/screen-embedding.ts (신규)
|
||||
frontend / types / screen - embedding.ts(신규);
|
||||
```
|
||||
|
||||
**충돌 없는 이유**:
|
||||
|
||||
- ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일
|
||||
- ✅ 타입명 중복 없음
|
||||
- ✅ 독립적인 네임스페이스
|
||||
|
||||
#### 기존 타입 (영향 없음)
|
||||
|
||||
```typescript
|
||||
frontend/types/screen.ts (변경 없음)
|
||||
frontend/types/screen-management.ts (변경 없음)
|
||||
|
|
@ -96,6 +106,7 @@ backend-node/src/types/screen.ts (변경 없음)
|
|||
### 4. 프론트엔드 컴포넌트
|
||||
|
||||
#### 새로운 컴포넌트 (독립적)
|
||||
|
||||
```
|
||||
frontend/components/screen-embedding/
|
||||
├── EmbeddedScreen.tsx (신규)
|
||||
|
|
@ -104,11 +115,13 @@ frontend/components/screen-embedding/
|
|||
```
|
||||
|
||||
**충돌 없는 이유**:
|
||||
|
||||
- ✅ 별도 디렉토리 (`screen-embedding/`)
|
||||
- ✅ 기존 컴포넌트 수정 없음
|
||||
- ✅ 독립적으로 import 가능
|
||||
|
||||
#### 기존 컴포넌트 (영향 없음)
|
||||
|
||||
```
|
||||
frontend/components/screen/ (변경 없음)
|
||||
frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
|
||||
|
|
@ -121,6 +134,7 @@ frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
|
|||
### 1. screen_definitions 테이블 참조
|
||||
|
||||
**현재 구조**:
|
||||
|
||||
```sql
|
||||
-- 새 테이블들이 screen_definitions를 참조
|
||||
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
||||
|
|
@ -128,10 +142,12 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
|||
```
|
||||
|
||||
**잠재적 문제**:
|
||||
|
||||
- ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE)
|
||||
- ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음
|
||||
|
||||
**해결 방법**:
|
||||
|
||||
```sql
|
||||
-- 이미 구현됨: ON DELETE CASCADE
|
||||
-- 화면 삭제 시 자동으로 관련 임베딩도 삭제
|
||||
|
|
@ -139,6 +155,7 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
|||
```
|
||||
|
||||
**권장 사항**:
|
||||
|
||||
- ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6)
|
||||
- ✅ 삭제 시 경고 메시지 표시
|
||||
|
||||
|
|
@ -147,6 +164,7 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
|||
### 2. 화면 렌더링 로직
|
||||
|
||||
**현재 화면 렌더링**:
|
||||
|
||||
```typescript
|
||||
// frontend/app/(main)/screens/[screenId]/page.tsx
|
||||
function ScreenViewPage() {
|
||||
|
|
@ -157,11 +175,12 @@ function ScreenViewPage() {
|
|||
const layout = await screenApi.getScreenLayout(screenId);
|
||||
|
||||
// 컴포넌트 렌더링
|
||||
<DynamicComponentRenderer components={layout.components} />
|
||||
<DynamicComponentRenderer components={layout.components} />;
|
||||
}
|
||||
```
|
||||
|
||||
**새로운 렌더링 (분할 패널)**:
|
||||
|
||||
```typescript
|
||||
// 분할 패널 화면인 경우
|
||||
if (isSplitPanelScreen) {
|
||||
|
|
@ -174,10 +193,12 @@ return <DynamicComponentRenderer components={layout.components} />;
|
|||
```
|
||||
|
||||
**잠재적 문제**:
|
||||
|
||||
- ⚠️ 화면 타입 구분 로직 필요
|
||||
- ⚠️ 기존 화면 렌더링 로직 수정 필요
|
||||
|
||||
**해결 방법**:
|
||||
|
||||
```typescript
|
||||
// 1. screen_definitions에 screen_type 컬럼 추가 (선택사항)
|
||||
ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal';
|
||||
|
|
@ -191,6 +212,7 @@ if (splitPanelConfig.success && splitPanelConfig.data) {
|
|||
```
|
||||
|
||||
**권장 구현**:
|
||||
|
||||
```typescript
|
||||
// frontend/app/(main)/screens/[screenId]/page.tsx 수정
|
||||
useEffect(() => {
|
||||
|
|
@ -200,7 +222,7 @@ useEffect(() => {
|
|||
|
||||
if (splitPanelResult.success && splitPanelResult.data) {
|
||||
// 분할 패널 화면
|
||||
setScreenType('split_panel');
|
||||
setScreenType("split_panel");
|
||||
setSplitPanelConfig(splitPanelResult.data);
|
||||
return;
|
||||
}
|
||||
|
|
@ -209,7 +231,7 @@ useEffect(() => {
|
|||
const screenResult = await screenApi.getScreen(screenId);
|
||||
const layoutResult = await screenApi.getScreenLayout(screenId);
|
||||
|
||||
setScreenType('normal');
|
||||
setScreenType("normal");
|
||||
setScreen(screenResult.data);
|
||||
setLayout(layoutResult.data);
|
||||
};
|
||||
|
|
@ -218,13 +240,17 @@ useEffect(() => {
|
|||
}, [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. 컴포넌트 등록 시스템
|
||||
|
||||
**현재 시스템**:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/registry/components.ts
|
||||
const componentRegistry = new Map<string, ComponentDefinition>();
|
||||
|
|
@ -242,6 +269,7 @@ export function registerComponent(id: string, component: any) {
|
|||
```
|
||||
|
||||
**새로운 요구사항**:
|
||||
|
||||
```typescript
|
||||
// DataReceivable 인터페이스 구현 필요
|
||||
interface DataReceivable {
|
||||
|
|
@ -254,10 +282,12 @@ interface DataReceivable {
|
|||
```
|
||||
|
||||
**잠재적 문제**:
|
||||
|
||||
- ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현
|
||||
- ⚠️ 데이터 수신 기능 없음
|
||||
|
||||
**해결 방법**:
|
||||
|
||||
```typescript
|
||||
// Phase 5에서 구현 예정
|
||||
// 기존 컴포넌트를 래핑하는 어댑터 패턴 사용
|
||||
|
|
@ -266,9 +296,9 @@ class TableComponentAdapter implements DataReceivable {
|
|||
constructor(private tableComponent: any) {}
|
||||
|
||||
async receiveData(data: any[], mode: DataReceiveMode) {
|
||||
if (mode === 'append') {
|
||||
if (mode === "append") {
|
||||
this.tableComponent.addRows(data);
|
||||
} else if (mode === 'replace') {
|
||||
} else if (mode === "replace") {
|
||||
this.tableComponent.setRows(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -284,6 +314,7 @@ class TableComponentAdapter implements DataReceivable {
|
|||
```
|
||||
|
||||
**권장 사항**:
|
||||
|
||||
- ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑
|
||||
- ✅ 점진적으로 DataReceivable 구현
|
||||
- ✅ 하위 호환성 유지
|
||||
|
|
@ -297,12 +328,15 @@ class TableComponentAdapter implements DataReceivable {
|
|||
**파일**: `frontend/app/(main)/screens/[screenId]/page.tsx`
|
||||
|
||||
**수정 내용**:
|
||||
|
||||
```typescript
|
||||
import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
|
||||
import { ScreenSplitPanel } from "@/components/screen-embedding";
|
||||
|
||||
function ScreenViewPage() {
|
||||
const [screenType, setScreenType] = useState<'normal' | 'split_panel'>('normal');
|
||||
const [screenType, setScreenType] = useState<"normal" | "split_panel">(
|
||||
"normal"
|
||||
);
|
||||
const [splitPanelConfig, setSplitPanelConfig] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -311,7 +345,7 @@ function ScreenViewPage() {
|
|||
const splitResult = await getScreenSplitPanel(screenId);
|
||||
|
||||
if (splitResult.success && splitResult.data) {
|
||||
setScreenType('split_panel');
|
||||
setScreenType("split_panel");
|
||||
setSplitPanelConfig(splitResult.data);
|
||||
setLoading(false);
|
||||
return;
|
||||
|
|
@ -325,7 +359,7 @@ function ScreenViewPage() {
|
|||
}, [screenId]);
|
||||
|
||||
// 렌더링
|
||||
if (screenType === 'split_panel' && splitPanelConfig) {
|
||||
if (screenType === "split_panel" && splitPanelConfig) {
|
||||
return <ScreenSplitPanel config={splitPanelConfig} />;
|
||||
}
|
||||
|
||||
|
|
@ -343,6 +377,7 @@ function ScreenViewPage() {
|
|||
**파일**: 화면 관리 페이지
|
||||
|
||||
**추가 기능**:
|
||||
|
||||
- 화면 생성 시 "분할 패널" 타입 선택
|
||||
- 분할 패널 설정 UI
|
||||
- 임베딩 설정 UI
|
||||
|
|
@ -354,15 +389,15 @@ function ScreenViewPage() {
|
|||
|
||||
## 📊 충돌 위험도 평가
|
||||
|
||||
| 항목 | 위험도 | 설명 | 조치 필요 |
|
||||
|------|--------|------|-----------|
|
||||
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
|
||||
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
|
||||
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
|
||||
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
|
||||
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
|
||||
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
|
||||
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
|
||||
| 항목 | 위험도 | 설명 | 조치 필요 |
|
||||
| -------------------- | ------- | ------------------- | ----------------- |
|
||||
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
|
||||
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
|
||||
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
|
||||
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
|
||||
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
|
||||
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
|
||||
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
|
||||
|
||||
**전체 위험도**: 🟢 **낮음** (대부분 독립적)
|
||||
|
||||
|
|
@ -371,24 +406,28 @@ function ScreenViewPage() {
|
|||
## ✅ 안전성 체크리스트
|
||||
|
||||
### 데이터베이스
|
||||
|
||||
- [x] 새 테이블명이 기존과 중복되지 않음
|
||||
- [x] 기존 테이블 구조 변경 없음
|
||||
- [x] 외래키 CASCADE 설정 완료
|
||||
- [x] 멀티테넌시 (company_code) 지원
|
||||
|
||||
### 백엔드
|
||||
|
||||
- [x] 새 라우트가 기존과 충돌하지 않음
|
||||
- [x] 독립적인 컨트롤러 파일
|
||||
- [x] 기존 API 수정 없음
|
||||
- [x] 에러 핸들링 완료
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
- [x] 새 컴포넌트가 별도 디렉토리
|
||||
- [x] 기존 컴포넌트 수정 없음
|
||||
- [x] 독립적인 타입 정의
|
||||
- [ ] 화면 페이지 수정 필요 (조건 분기)
|
||||
|
||||
### 호환성
|
||||
|
||||
- [x] 기존 화면 동작 영향 없음
|
||||
- [x] 하위 호환성 유지
|
||||
- [ ] 컴포넌트 어댑터 구현 (Phase 5)
|
||||
|
|
@ -400,6 +439,7 @@ function ScreenViewPage() {
|
|||
### 즉시 조치 (필수)
|
||||
|
||||
1. **화면 페이지 수정**
|
||||
|
||||
```typescript
|
||||
// frontend/app/(main)/screens/[screenId]/page.tsx
|
||||
// 분할 패널 확인 로직 추가
|
||||
|
|
@ -421,11 +461,13 @@ function ScreenViewPage() {
|
|||
### 단계적 조치 (Phase 5-6)
|
||||
|
||||
1. **컴포넌트 어댑터 구현**
|
||||
|
||||
- TableComponent → DataReceivable
|
||||
- InputComponent → DataReceivable
|
||||
- 기타 컴포넌트들
|
||||
|
||||
2. **설정 UI 개발**
|
||||
|
||||
- 분할 패널 생성 UI
|
||||
- 매핑 규칙 설정 UI
|
||||
- 미리보기 기능
|
||||
|
|
@ -442,6 +484,7 @@ function ScreenViewPage() {
|
|||
### ✅ 안전성 평가: 높음
|
||||
|
||||
**이유**:
|
||||
|
||||
1. ✅ 대부분의 코드가 독립적으로 추가됨
|
||||
2. ✅ 기존 시스템 수정 최소화
|
||||
3. ✅ 하위 호환성 유지
|
||||
|
|
@ -450,10 +493,12 @@ function ScreenViewPage() {
|
|||
### ⚠️ 주의 사항
|
||||
|
||||
1. **화면 페이지 수정 필요**
|
||||
|
||||
- 분할 패널 확인 로직 추가
|
||||
- 조건부 렌더링 구현
|
||||
|
||||
2. **점진적 구현 권장**
|
||||
|
||||
- Phase 5: 컴포넌트 어댑터
|
||||
- Phase 6: 설정 UI
|
||||
- 단계별 테스트
|
||||
|
|
@ -467,4 +512,3 @@ function ScreenViewPage() {
|
|||
**충돌 위험도: 낮음 (🟢)**
|
||||
|
||||
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue