Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-12-05 15:22:28 +09:00
commit e713f55442
31 changed files with 7018 additions and 1604 deletions

View File

@ -1,7 +1,7 @@
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
// 작성일: 2024-12-24
import { Response } from "express";
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import {
BatchManagementService,
@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService";
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
import { query } from "../database/db";
export class BatchManagementController {
/**
@ -422,6 +423,8 @@ export class BatchManagementController {
paramValue,
paramSource,
requestBody,
authServiceName, // DB에서 토큰 가져올 서비스명
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
} = req.body;
// apiUrl, endpoint는 항상 필수
@ -432,15 +435,47 @@ export class BatchManagementController {
});
}
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
if ((!method || method === "GET") && !apiKey) {
return res.status(400).json({
success: false,
message: "GET 메서드에서는 API Key가 필요합니다.",
});
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
let finalApiKey = apiKey || "";
if (authServiceName) {
const companyCode = req.user?.companyCode;
// DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
let tokenQuery: string;
let tokenParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사 토큰 조회 가능
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [authServiceName];
} else {
// 일반 회사: 자신의 회사 토큰만 조회
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1 AND company_code = $2
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [authServiceName, companyCode];
}
const tokenResult = await query<{ access_token: string }>(
tokenQuery,
tokenParams
);
if (tokenResult.length > 0 && tokenResult[0].access_token) {
finalApiKey = tokenResult[0].access_token;
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
} else {
return res.status(400).json({
success: false,
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
});
}
}
console.log("🔍 REST API 미리보기 요청:", {
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
console.log("REST API 미리보기 요청:", {
apiUrl,
endpoint,
method,
@ -449,6 +484,8 @@ export class BatchManagementController {
paramValue,
paramSource,
requestBody: requestBody ? "Included" : "None",
authServiceName: authServiceName || "직접 입력",
dataArrayPath: dataArrayPath || "전체 응답",
});
// RestApiConnector 사용하여 데이터 조회
@ -456,7 +493,7 @@ export class BatchManagementController {
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: apiKey || "",
apiKey: finalApiKey,
timeout: 30000,
});
@ -511,8 +548,50 @@ export class BatchManagementController {
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
});
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
// 데이터 배열 추출 헬퍼 함수
const getValueByPath = (obj: any, path: string): any => {
if (!path) return obj;
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
current = current[key];
}
return current;
};
// dataArrayPath가 있으면 해당 경로에서 배열 추출
let extractedData: any[] = [];
if (dataArrayPath) {
// result.rows가 단일 객체일 수 있음 (API 응답 전체)
const rawData = result.rows.length === 1 ? result.rows[0] : result.rows;
const arrayData = getValueByPath(rawData, dataArrayPath);
if (Array.isArray(arrayData)) {
extractedData = arrayData;
console.log(
`[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출`
);
} else {
console.warn(
`[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`,
typeof arrayData
);
// 배열이 아니면 단일 객체로 처리
if (arrayData) {
extractedData = [arrayData];
}
}
} else {
// dataArrayPath가 없으면 기존 로직 사용
extractedData = result.rows;
}
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
console.log(
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
data
);
if (data.length > 0) {
// 첫 번째 객체에서 필드명 추출
@ -524,9 +603,9 @@ export class BatchManagementController {
data: {
fields: fields,
samples: data,
totalCount: result.rowCount || data.length,
totalCount: extractedData.length,
},
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
});
} else {
return res.json({
@ -554,8 +633,17 @@ export class BatchManagementController {
*/
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
try {
const { batchName, batchType, cronSchedule, description, apiMappings } =
req.body;
const {
batchName,
batchType,
cronSchedule,
description,
apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
} = req.body;
if (
!batchName ||
@ -576,6 +664,10 @@ export class BatchManagementController {
cronSchedule,
description,
apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
});
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
@ -589,6 +681,10 @@ export class BatchManagementController {
cronSchedule: cronSchedule,
isActive: "Y",
companyCode,
authServiceName: authServiceName || undefined,
dataArrayPath: dataArrayPath || undefined,
saveMode: saveMode || "INSERT",
conflictKey: conflictKey || undefined,
mappings: apiMappings,
};
@ -625,4 +721,51 @@ export class BatchManagementController {
});
}
}
/**
*
*/
static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
// 멀티테넌시: company_code 필터링
let queryText: string;
let queryParams: any[] = [];
if (companyCode === "*") {
// 최고 관리자: 모든 서비스 조회
queryText = `SELECT DISTINCT service_name
FROM auth_tokens
WHERE service_name IS NOT NULL
ORDER BY service_name`;
} else {
// 일반 회사: 자신의 회사 서비스만 조회
queryText = `SELECT DISTINCT service_name
FROM auth_tokens
WHERE service_name IS NOT NULL
AND company_code = $1
ORDER BY service_name`;
queryParams = [companyCode];
}
const result = await query<{ service_name: string }>(
queryText,
queryParams
);
const serviceNames = result.map((row) => row.service_name);
return res.json({
success: true,
data: serviceNames,
});
} catch (error) {
console.error("인증 서비스 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
*/
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
/**
* GET /api/batch-management/auth-services
*
*/
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
export default router;

View File

@ -2,6 +2,7 @@ import cron, { ScheduledTask } from "node-cron";
import { BatchService } from "./batchService";
import { BatchExecutionLogService } from "./batchExecutionLogService";
import { logger } from "../utils/logger";
import { query } from "../database/db";
export class BatchSchedulerService {
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
@ -214,9 +215,16 @@ export class BatchSchedulerService {
}
// 테이블별로 매핑을 그룹화
// 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리
const tableGroups = new Map<string, typeof config.batch_mappings>();
const fixedMappingsGlobal: typeof config.batch_mappings = [];
for (const mapping of config.batch_mappings) {
// 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음)
if (mapping.mapping_type === "fixed") {
fixedMappingsGlobal.push(mapping);
continue;
}
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
if (!tableGroups.has(key)) {
tableGroups.set(key, []);
@ -224,6 +232,14 @@ export class BatchSchedulerService {
tableGroups.get(key)!.push(mapping);
}
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
logger.warn(
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
);
return { totalRecords, successRecords, failedRecords };
}
// 각 테이블 그룹별로 처리
for (const [tableKey, mappings] of tableGroups) {
try {
@ -244,10 +260,46 @@ export class BatchSchedulerService {
"./batchExternalDbService"
);
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
let apiKey = firstMapping.from_api_key || "";
if (config.auth_service_name) {
let tokenQuery: string;
let tokenParams: any[];
if (config.company_code === "*") {
// 최고 관리자 배치: 모든 회사 토큰 조회 가능
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [config.auth_service_name];
} else {
// 일반 회사 배치: 자신의 회사 토큰만 조회
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1 AND company_code = $2
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [config.auth_service_name, config.company_code];
}
const tokenResult = await query<{ access_token: string }>(
tokenQuery,
tokenParams
);
if (tokenResult.length > 0 && tokenResult[0].access_token) {
apiKey = tokenResult[0].access_token;
logger.info(
`auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}`
);
} else {
logger.warn(
`auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}`
);
}
}
// 👇 Body 파라미터 추가 (POST 요청 시)
const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!,
firstMapping.from_api_key!,
apiKey,
firstMapping.from_table_name,
(firstMapping.from_api_method as
| "GET"
@ -266,7 +318,36 @@ export class BatchSchedulerService {
);
if (apiResult.success && apiResult.data) {
fromData = apiResult.data;
// 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출
if (config.data_array_path) {
const extractArrayByPath = (obj: any, path: string): any[] => {
if (!path) return Array.isArray(obj) ? obj : [obj];
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current === null || current === undefined) return [];
current = current[key];
}
return Array.isArray(current)
? current
: current
? [current]
: [];
};
// apiResult.data가 단일 객체인 경우 (API 응답 전체)
const rawData =
Array.isArray(apiResult.data) && apiResult.data.length === 1
? apiResult.data[0]
: apiResult.data;
fromData = extractArrayByPath(rawData, config.data_array_path);
logger.info(
`데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출`
);
} else {
fromData = apiResult.data;
}
} else {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
}
@ -298,6 +379,11 @@ export class BatchSchedulerService {
const mappedData = fromData.map((row) => {
const mappedRow: any = {};
for (const mapping of mappings) {
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
if (mapping.mapping_type === "fixed") {
continue;
}
// DB → REST API 배치인지 확인
if (
firstMapping.to_connection_type === "restapi" &&
@ -315,6 +401,13 @@ export class BatchSchedulerService {
}
}
// 고정값 매핑 적용 (전역으로 분리된 fixedMappingsGlobal 사용)
for (const fixedMapping of fixedMappingsGlobal) {
// from_column_name에 고정값이 저장되어 있음
mappedRow[fixedMapping.to_column_name] =
fixedMapping.from_column_name;
}
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
// - 배치 설정에 company_code가 있고
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
@ -384,12 +477,14 @@ export class BatchSchedulerService {
insertResult = { successCount: 0, failedCount: 0 };
}
} else {
// DB에 데이터 삽입
// DB에 데이터 삽입 (save_mode, conflict_key 지원)
insertResult = await BatchService.insertDataToTable(
firstMapping.to_table_name,
mappedData,
firstMapping.to_connection_type as "internal" | "external",
firstMapping.to_connection_id || undefined
firstMapping.to_connection_id || undefined,
(config.save_mode as "INSERT" | "UPSERT") || "INSERT",
config.conflict_key || undefined
);
}

View File

@ -176,8 +176,8 @@ export class BatchService {
// 배치 설정 생성
const batchConfigResult = await client.query(
`INSERT INTO batch_configs
(batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
data.batchName,
@ -185,6 +185,10 @@ export class BatchService {
data.cronSchedule,
data.isActive || "Y",
data.companyCode,
data.saveMode || "INSERT",
data.conflictKey || null,
data.authServiceName || null,
data.dataArrayPath || null,
userId,
]
);
@ -201,37 +205,38 @@ export class BatchService {
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW())
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW())
RETURNING *`,
[
batchConfig.id,
data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용
mapping.from_connection_type,
mapping.from_connection_id,
mapping.from_table_name,
mapping.from_column_name,
mapping.from_column_type,
mapping.from_api_url,
mapping.from_api_key,
mapping.from_api_method,
mapping.from_api_param_type,
mapping.from_api_param_name,
mapping.from_api_param_value,
mapping.from_api_param_source,
mapping.from_api_body, // FROM REST API Body
mapping.to_connection_type,
mapping.to_connection_id,
mapping.to_table_name,
mapping.to_column_name,
mapping.to_column_type,
mapping.to_api_url,
mapping.to_api_key,
mapping.to_api_method,
mapping.to_api_body,
mapping.mapping_order || index + 1,
userId,
]
batchConfig.id,
data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용
mapping.from_connection_type,
mapping.from_connection_id,
mapping.from_table_name,
mapping.from_column_name,
mapping.from_column_type,
mapping.from_api_url,
mapping.from_api_key,
mapping.from_api_method,
mapping.from_api_param_type,
mapping.from_api_param_name,
mapping.from_api_param_value,
mapping.from_api_param_source,
mapping.from_api_body, // FROM REST API Body
mapping.to_connection_type,
mapping.to_connection_id,
mapping.to_table_name,
mapping.to_column_name,
mapping.to_column_type,
mapping.to_api_url,
mapping.to_api_key,
mapping.to_api_method,
mapping.to_api_body,
mapping.mapping_order || index + 1,
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
userId,
]
);
mappings.push(mappingResult.rows[0]);
}
@ -311,6 +316,22 @@ export class BatchService {
updateFields.push(`is_active = $${paramIndex++}`);
updateValues.push(data.isActive);
}
if (data.saveMode !== undefined) {
updateFields.push(`save_mode = $${paramIndex++}`);
updateValues.push(data.saveMode);
}
if (data.conflictKey !== undefined) {
updateFields.push(`conflict_key = $${paramIndex++}`);
updateValues.push(data.conflictKey || null);
}
if (data.authServiceName !== undefined) {
updateFields.push(`auth_service_name = $${paramIndex++}`);
updateValues.push(data.authServiceName || null);
}
if (data.dataArrayPath !== undefined) {
updateFields.push(`data_array_path = $${paramIndex++}`);
updateValues.push(data.dataArrayPath || null);
}
// 배치 설정 업데이트
const batchConfigResult = await client.query(
@ -339,8 +360,8 @@ export class BatchService {
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW())
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW())
RETURNING *`,
[
id,
@ -368,6 +389,7 @@ export class BatchService {
mapping.to_api_method,
mapping.to_api_body,
mapping.mapping_order || index + 1,
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
userId,
]
);
@ -554,9 +576,7 @@ export class BatchService {
try {
if (connectionType === "internal") {
// 내부 DB 데이터 조회
const data = await query<any>(
`SELECT * FROM ${tableName} LIMIT 10`
);
const data = await query<any>(`SELECT * FROM ${tableName} LIMIT 10`);
return {
success: true,
data,
@ -729,19 +749,27 @@ export class BatchService {
/**
* ( / DB )
* @param tableName
* @param data
* @param connectionType (internal/external)
* @param connectionId ID
* @param saveMode (INSERT/UPSERT)
* @param conflictKey UPSERT
*/
static async insertDataToTable(
tableName: string,
data: any[],
connectionType: "internal" | "external" = "internal",
connectionId?: number
connectionId?: number,
saveMode: "INSERT" | "UPSERT" = "INSERT",
conflictKey?: string
): Promise<{
successCount: number;
failedCount: number;
}> {
try {
console.log(
`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드`
`[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}`
);
if (!data || data.length === 0) {
@ -753,24 +781,54 @@ export class BatchService {
let successCount = 0;
let failedCount = 0;
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
// 각 레코드를 개별적으로 삽입
for (const record of data) {
try {
const columns = Object.keys(record);
const values = Object.values(record);
const placeholders = values
.map((_, i) => `$${i + 1}`)
.join(", ");
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const queryStr = `INSERT INTO ${tableName} (${columns.join(
", "
)}) VALUES (${placeholders})`;
let queryStr: string;
if (saveMode === "UPSERT" && conflictKey) {
// UPSERT 모드: ON CONFLICT DO UPDATE
// 충돌 키를 제외한 컬럼들만 UPDATE
const updateColumns = columns.filter(
(col) => col !== conflictKey
);
// 업데이트할 컬럼이 없으면 DO NOTHING 사용
if (updateColumns.length === 0) {
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
ON CONFLICT (${conflictKey})
DO NOTHING`;
} else {
const updateSet = updateColumns
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
// updated_date 컬럼이 있으면 현재 시간으로 업데이트
const hasUpdatedDate = columns.includes("updated_date");
const finalUpdateSet = hasUpdatedDate
? `${updateSet}, updated_date = NOW()`
: updateSet;
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
ON CONFLICT (${conflictKey})
DO UPDATE SET ${finalUpdateSet}`;
}
} else {
// INSERT 모드: 기존 방식
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
}
await query(queryStr, values);
successCount++;
} catch (insertError) {
console.error(
`내부 DB 데이터 삽입 실패 (${tableName}):`,
`내부 DB 데이터 ${saveMode} 실패 (${tableName}):`,
insertError
);
failedCount++;
@ -779,7 +837,13 @@ export class BatchService {
return { successCount, failedCount };
} else if (connectionType === "external" && connectionId) {
// 외부 DB에 데이터 삽입
// 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원)
if (saveMode === "UPSERT") {
console.warn(
`[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.`
);
}
const result = await BatchExternalDbService.insertDataToTable(
connectionId,
tableName,
@ -799,7 +863,7 @@ export class BatchService {
);
}
} catch (error) {
console.error(`데이터 삽입 오류 (${tableName}):`, error);
console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error);
return { successCount: 0, failedCount: data ? data.length : 0 };
}
}

View File

@ -32,7 +32,7 @@ export interface TableInfo {
// 연결 정보 타입
export interface ConnectionInfo {
type: 'internal' | 'external';
type: "internal" | "external";
id?: number;
name: string;
db_type?: string;
@ -52,27 +52,27 @@ export interface BatchMapping {
id?: number;
batch_config_id?: number;
company_code?: string;
from_connection_type: 'internal' | 'external' | 'restapi';
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_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_param_source?: "static" | "dynamic";
from_api_body?: string;
to_connection_type: 'internal' | 'external' | 'restapi';
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_method?: "GET" | "POST" | "PUT" | "DELETE";
to_api_body?: string;
mapping_order?: number;
created_by?: string;
@ -85,8 +85,12 @@ export interface BatchConfig {
batch_name: string;
description?: string;
cron_schedule: string;
is_active: 'Y' | 'N';
is_active: "Y" | "N";
company_code?: string;
save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items)
created_by?: string;
created_date?: Date;
updated_by?: string;
@ -95,7 +99,7 @@ export interface BatchConfig {
}
export interface BatchConnectionInfo {
type: 'internal' | 'external';
type: "internal" | "external";
id?: number;
name: string;
db_type?: string;
@ -109,38 +113,43 @@ export interface BatchColumnInfo {
}
export interface BatchMappingRequest {
from_connection_type: 'internal' | 'external' | 'restapi';
from_connection_type: "internal" | "external" | "restapi" | "fixed";
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'; // API 파라미터 타입
from_api_method?: "GET" | "POST" | "PUT" | "DELETE";
from_api_param_type?: "url" | "query"; // API 파라미터 타입
from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
from_api_param_source?: "static" | "dynamic"; // 파라미터 소스 타입
// REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
from_api_body?: string;
to_connection_type: 'internal' | 'external' | 'restapi';
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_method?: "GET" | "POST" | "PUT" | "DELETE";
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
mapping_order?: number;
mapping_type?: "direct" | "fixed"; // 매핑 타입: direct (API 필드) 또는 fixed (고정값)
}
export interface CreateBatchConfigRequest {
batchName: string;
description?: string;
cronSchedule: string;
isActive: 'Y' | 'N';
isActive: "Y" | "N";
companyCode: string;
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
mappings: BatchMappingRequest[];
}
@ -148,7 +157,11 @@ export interface UpdateBatchConfigRequest {
batchName?: string;
description?: string;
cronSchedule?: string;
isActive?: 'Y' | 'N';
isActive?: "Y" | "N";
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
mappings?: BatchMappingRequest[];
}

View File

@ -12,7 +12,7 @@ services:
NODE_ENV: production
PORT: "3001"
HOST: 0.0.0.0
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
JWT_EXPIRES_IN: 24h
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
});
// Location 객체들의 자재 개수 로드
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" ||
@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
obj.type === "location-dest") &&
obj.locaKey,
);
if (locationObjects.length > 0) {
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
setTimeout(() => {
loadMaterialCountsForLocations(locaKeys);
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
}, 100);
}
} else {
@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
};
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
const loadMaterialCountsForLocations = async (locaKeys: string[]) => {
if (!selectedDbConnection || locaKeys.length === 0) return;
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
const connectionId = dbConnectionId || selectedDbConnection;
const tableName = materialTableName || selectedTables.material;
if (!connectionId || locaKeys.length === 0) return;
try {
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
console.log("📊 자재 개수 API 응답:", response);
if (response.success && response.data) {
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) =>
@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
) {
return obj;
}
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
const materialCount = response.data?.find(
(mc: any) =>
mc.LOCAKEY === obj.locaKey ||
mc.location_key === obj.locaKey ||
mc.locakey === obj.locaKey
);
if (materialCount) {
// count 또는 material_count 필드 사용
const count = materialCount.count || materialCount.material_count || 0;
const maxLayer = materialCount.max_layer || count;
console.log(`📊 ${obj.locaKey}: 자재 ${count}`);
return {
...obj,
materialCount: materialCount.material_count,
materialCount: Number(count),
materialPreview: {
height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적)
height: maxLayer * 1.5, // 층당 1.5 높이 (시각적)
},
};
}

View File

@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName);
setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
setExternalDbConnectionId(dbConnectionId);
// hierarchy_config 저장
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
const config =
hierarchyConfigData =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
setHierarchyConfig(hierarchyConfigData);
}
// 객체 데이터 변환
@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
});
setPlacedObjects(loadedObjects);
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch (e) {
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
}
return { id: obj.id, count: 0 };
});
const materialCounts = await Promise.all(materialCountPromises);
// materialCount 업데이트
setPlacedObjects((prev) =>
prev.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return obj;
})
);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
}

View File

@ -1,7 +1,7 @@
"use client";
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
import { OrbitControls, Box, Text } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
import * as THREE from "three";
@ -525,68 +525,77 @@ function MaterialBox({
case "location-bed":
case "location-temp":
case "location-dest":
// 베드 타입 Location: 초록색 상자
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
const locPlateThickness = 0.15; // 각 철판 두께
const locPlateGap = 0.03; // 철판 사이 미세한 간격
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
const locVisibleStackHeight = locVisiblePlateCount * (locPlateThickness + locPlateGap);
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
const locYOffset = -placement.position_y;
const locPlateBaseY = locYOffset + locPlateThickness / 2;
return (
<>
<Box args={[boxWidth, boxHeight, boxDepth]}>
<meshStandardMaterial
color={placement.color}
roughness={0.5}
metalness={0.3}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</Box>
{/* 대표 자재 스택 (자재가 있을 때만) */}
{placement.material_count !== undefined &&
placement.material_count > 0 &&
placement.material_preview_height && (
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
// 약간의 랜덤 오프셋으로 자연스러움 추가
const xOffset = (Math.sin(idx * 0.5) * 0.02);
const zOffset = (Math.cos(idx * 0.7) * 0.02);
return (
<Box
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
key={`loc-plate-${idx}`}
args={[boxWidth, locPlateThickness, boxDepth]}
position={[xOffset, yPos, zOffset]}
>
<meshStandardMaterial
color="#ef4444"
roughness={0.6}
metalness={0.2}
emissive={isSelected ? "#ef4444" : "#000000"}
color="#6b7280" // 회색 (고정)
roughness={0.4}
metalness={0.7}
emissive={isSelected ? "#9ca3af" : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
transparent
opacity={0.7}
/>
{/* 각 철판 외곽선 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, locPlateThickness, boxDepth)]} />
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
</lineSegments>
</Box>
)}
{/* Location 이름 */}
);
})}
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
{placement.name && (
<Text
position={[0, boxHeight / 2 + 0.3, 0]}
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#ffffff"
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
color="#374151"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
outlineColor="#ffffff"
>
{placement.name}
</Text>
)}
{/* 자재 개수 */}
{placement.material_count !== undefined && placement.material_count > 0 && (
{/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */}
{locPlateCount > 0 && (
<Text
position={[0, boxHeight / 2 + 0.6, 0]}
position={[0, locYOffset + locVisibleStackHeight + 0.3, -boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
color="#fbbf24"
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#1f2937"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
outlineWidth={0.02}
outlineColor="#ffffff"
>
{`자재: ${placement.material_count}`}
{`${locPlateCount}`}
</Text>
)}
</>
@ -886,83 +895,79 @@ function MaterialBox({
case "plate-stack":
default:
// 후판 스택: 팔레트 + 박스 (기존 렌더링)
// 후판 스택: 회색 철판들이 데이터 개수만큼 쌓이는 형태
const plateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 (기본 5장)
const visiblePlateCount = plateCount; // 데이터 개수만큼 모두 렌더링
const plateThickness = 0.15; // 각 철판 두께
const plateGap = 0.03; // 철판 사이 미세한 간격
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
const visibleStackHeight = visiblePlateCount * (plateThickness + plateGap);
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
const yOffset = -placement.position_y;
const plateBaseY = yOffset + plateThickness / 2;
return (
<>
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
<group position={[0, palletYOffset, 0]}>
{/* 상단 가로 판자들 (5개) */}
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
{Array.from({ length: visiblePlateCount }).map((_, idx) => {
const yPos = plateBaseY + idx * (plateThickness + plateGap);
// 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼)
const xOffset = (Math.sin(idx * 0.5) * 0.02);
const zOffset = (Math.cos(idx * 0.7) * 0.02);
return (
<Box
key={`top-${idx}`}
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
position={[0, palletHeight * 0.35, zOffset]}
key={`plate-${idx}`}
args={[boxWidth, plateThickness, boxDepth]}
position={[xOffset, yPos, zOffset]}
>
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
<meshStandardMaterial
color="#6b7280" // 회색 (고정)
roughness={0.4}
metalness={0.7}
emissive={isSelected ? "#9ca3af" : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
/>
{/* 각 철판 외곽선 */}
<lineSegments>
<edgesGeometry
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]}
/>
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, plateThickness, boxDepth)]} />
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
</lineSegments>
</Box>
))}
{/* 중간 세로 받침대 (3개) */}
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
<Box
key={`middle-${idx}`}
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
position={[xOffset, 0, 0]}
>
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
<lineSegments>
<edgesGeometry
args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]}
/>
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
</lineSegments>
</Box>
))}
{/* 하단 가로 판자들 (3개) */}
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
<Box
key={`bottom-${idx}`}
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
position={[0, -palletHeight * 0.35, zOffset]}
>
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
<lineSegments>
<edgesGeometry
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]}
/>
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
</lineSegments>
</Box>
))}
</group>
{/* 메인 박스 */}
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
{/* 메인 재질 - 골판지 느낌 */}
<meshStandardMaterial
color={placement.color}
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
wireframe={!isConfigured}
roughness={0.95}
metalness={0.05}
/>
{/* 외곽선 - 더 진하게 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
<lineBasicMaterial color="#000000" opacity={0.6} transparent linewidth={1.5} />
</lineSegments>
</Box>
);
})}
{/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */}
{plateCount > 0 && (
<Text
position={[0, yOffset + visibleStackHeight + 0.3, -boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
color="#374151"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#ffffff"
>
{`${plateCount}`}
</Text>
)}
{/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */}
{placement.material_name && (
<Text
position={[0, yOffset + visibleStackHeight + 0.3, boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#1f2937"
anchorX="center"
anchorY="middle"
outlineWidth={0.02}
outlineColor="#ffffff"
>
{placement.material_name}
</Text>
)}
</>
);
}
@ -1114,20 +1119,11 @@ function Scene({
{/* 배경색 */}
<color attach="background" args={["#f3f4f6"]} />
{/* 바닥 그리드 (타일을 4등분) */}
<Grid
args={[100, 100]}
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
cellThickness={0.6}
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
sectionSize={gridSize} // 타일 경계선 (5칸마다)
sectionThickness={1.5}
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
fadeDistance={200}
fadeStrength={1}
followCamera={false}
infiniteGrid={true}
/>
{/* 바닥 - 단색 평면 (그리드 제거) */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
<planeGeometry args={[200, 200]} />
<meshStandardMaterial color="#e5e7eb" roughness={0.9} metalness={0.1} />
</mesh>
{/* 자재 박스들 */}
{placements.map((placement) => (

View File

@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
@ -47,6 +47,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
const [customSeparator, setCustomSeparator] = useState("");
useEffect(() => {
loadRules();
@ -87,6 +91,50 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화
useEffect(() => {
if (currentRule) {
const sep = currentRule.separator ?? "-";
// 빈 문자열이면 "none"
if (sep === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
}
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
// 구분자 변경 핸들러
const handleSeparatorChange = useCallback((type: SeparatorType) => {
setSeparatorType(type);
if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const newSeparator = option?.displayValue ?? "";
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
setCustomSeparator("");
}
}, []);
// 직접 입력 구분자 변경 핸들러
const handleCustomSeparatorChange = useCallback((value: string) => {
// 최대 2자 제한
const trimmedValue = value.slice(0, 2);
setCustomSeparator(trimmedValue);
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
}, []);
const handleAddPart = useCallback(() => {
if (!currentRule) return;
@ -373,7 +421,44 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
</div>
{/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */}
{/* 두 번째 줄: 구분자 설정 */}
<div className="flex items-end gap-3">
<div className="w-48 space-y-2">
<Label className="text-sm font-medium"></Label>
<Select
value={separatorType}
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="구분자 선택" />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{separatorType === "custom" && (
<div className="w-32 space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
value={customSeparator}
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
className="h-9"
placeholder="최대 2자"
maxLength={2}
/>
</div>
)}
<p className="text-muted-foreground pb-2 text-xs">
</p>
</div>
{/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>

View File

@ -304,7 +304,24 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
};
// 저장 버튼 클릭 시 - UPDATE 액션 실행
const handleSave = async () => {
const handleSave = async (saveData?: any) => {
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
if (saveData?._saveCompleted) {
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
handleClose();
return;
}
if (!screenData?.screenInfo?.tableName) {
toast.error("테이블 정보가 없습니다.");
return;

View File

@ -1953,6 +1953,139 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 🆕 버튼 활성화 조건 설정 */}
<div className="mt-4 border-t pt-4">
<h5 className="mb-3 text-xs font-medium text-muted-foreground"> </h5>
{/* 출발지/도착지 필수 체크 */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="require-location">/ </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="require-location"
checked={config.action?.requireLocationFields === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.requireLocationFields", checked)}
/>
</div>
{config.action?.requireLocationFields && (
<div className="mt-3 space-y-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Input
placeholder="departure"
value={config.action?.trackingDepartureField || "departure"}
onChange={(e) => onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="destination"
value={config.action?.trackingArrivalField || "destination"}
onChange={(e) => onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 상태 기반 활성화 조건 */}
<div className="mt-4 flex items-center justify-between">
<div>
<Label htmlFor="enable-on-status"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="enable-on-status"
checked={config.action?.enableOnStatusCheck === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)}
/>
</div>
{config.action?.enableOnStatusCheck && (
<div className="mt-3 space-y-2 rounded-md bg-purple-50 p-3 dark:bg-purple-950">
<div>
<Label> </Label>
<Select
value={config.action?.statusCheckTableName || "vehicles"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusCheckTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
(기본: vehicles)
</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="user_id"
value={config.action?.statusCheckKeyField || "user_id"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
ID로 (기본: user_id)
</p>
</div>
<div>
<Label> </Label>
<Input
placeholder="status"
value={config.action?.statusCheckField || "status"}
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(기본: status)
</p>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.statusConditionType || "enableOn"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusConditionType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enableOn"> </SelectItem>
<SelectItem value="disableOn"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> ( )</Label>
<Input
placeholder="예: active, inactive"
value={config.action?.statusConditionValues || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(,)
</p>
</div>
</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>

View File

@ -10,6 +10,9 @@ export interface BatchConfig {
cron_schedule: string;
is_active?: string;
company_code?: string;
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
created_date?: Date;
created_by?: string;
updated_date?: Date;
@ -386,6 +389,26 @@ export class BatchAPI {
throw error;
}
}
/**
* auth_tokens
*/
static async getAuthServiceNames(): Promise<string[]> {
try {
const response = await apiClient.get<{
success: boolean;
data: string[];
}>(`/batch-management/auth-services`);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("인증 서비스 목록 조회 오류:", error);
return [];
}
}
}
// BatchJob export 추가 (이미 위에서 interface로 정의됨)

View File

@ -5,7 +5,7 @@ import { apiClient } from "./client";
// 배치관리 전용 타입 정의
export interface BatchConnectionInfo {
type: 'internal' | 'external';
type: "internal" | "external";
id?: number;
name: string;
db_type?: string;
@ -39,9 +39,7 @@ class BatchManagementAPIClass {
*/
static async getAvailableConnections(): Promise<BatchConnectionInfo[]> {
try {
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(
`${this.BASE_PATH}/connections`
);
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(`${this.BASE_PATH}/connections`);
if (!response.data.success) {
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
@ -58,15 +56,15 @@ class BatchManagementAPIClass {
*
*/
static async getTablesFromConnection(
connectionType: 'internal' | 'external',
connectionId?: number
connectionType: "internal" | "external",
connectionId?: number,
): Promise<string[]> {
try {
let url = `${this.BASE_PATH}/connections/${connectionType}`;
if (connectionType === 'external' && connectionId) {
if (connectionType === "external" && connectionId) {
url += `/${connectionId}`;
}
url += '/tables';
url += "/tables";
const response = await apiClient.get<BatchApiResponse<string[]>>(url);
@ -85,13 +83,13 @@ class BatchManagementAPIClass {
*
*/
static async getTableColumns(
connectionType: 'internal' | 'external',
connectionType: "internal" | "external",
tableName: string,
connectionId?: number
connectionId?: number,
): Promise<BatchColumnInfo[]> {
try {
let url = `${this.BASE_PATH}/connections/${connectionType}`;
if (connectionType === 'external' && connectionId) {
if (connectionType === "external" && connectionId) {
url += `/${connectionId}`;
}
url += `/tables/${encodeURIComponent(tableName)}/columns`;
@ -120,14 +118,16 @@ class BatchManagementAPIClass {
apiUrl: string,
apiKey: string,
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
paramInfo?: {
paramType: 'url' | 'query';
paramType: "url" | "query";
paramName: string;
paramValue: string;
paramSource: 'static' | 'dynamic';
paramSource: "static" | "dynamic";
},
requestBody?: string
requestBody?: string,
authServiceName?: string, // DB에서 토큰 가져올 서비스명
dataArrayPath?: string, // 데이터 배열 경로 (예: response, data.items)
): Promise<{
fields: string[];
samples: any[];
@ -139,7 +139,7 @@ class BatchManagementAPIClass {
apiKey,
endpoint,
method,
requestBody
requestBody,
};
// 파라미터 정보가 있으면 추가
@ -150,11 +150,23 @@ class BatchManagementAPIClass {
requestData.paramSource = paramInfo.paramSource;
}
const response = await apiClient.post<BatchApiResponse<{
fields: string[];
samples: any[];
totalCount: number;
}>>(`${this.BASE_PATH}/rest-api/preview`, requestData);
// DB에서 토큰 가져올 서비스명 추가
if (authServiceName) {
requestData.authServiceName = authServiceName;
}
// 데이터 배열 경로 추가
if (dataArrayPath) {
requestData.dataArrayPath = dataArrayPath;
}
const response = await apiClient.post<
BatchApiResponse<{
fields: string[];
samples: any[];
totalCount: number;
}>
>(`${this.BASE_PATH}/rest-api/preview`, requestData);
if (!response.data.success) {
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");
@ -167,6 +179,24 @@ class BatchManagementAPIClass {
}
}
/**
*
*/
static async getAuthServiceNames(): Promise<string[]> {
try {
const response = await apiClient.get<BatchApiResponse<string[]>>(`${this.BASE_PATH}/auth-services`);
if (!response.data.success) {
throw new Error(response.data.message || "인증 서비스 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("인증 서비스 목록 조회 오류:", error);
throw error;
}
}
/**
* REST API
*/
@ -176,15 +206,17 @@ class BatchManagementAPIClass {
cronSchedule: string;
description?: string;
apiMappings: any[];
}): Promise<{ success: boolean; message: string; data?: any; }> {
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
}): Promise<{ success: boolean; message: string; data?: any }> {
try {
const response = await apiClient.post<BatchApiResponse<any>>(
`${this.BASE_PATH}/rest-api/save`, batchData
);
const response = await apiClient.post<BatchApiResponse<any>>(`${this.BASE_PATH}/rest-api/save`, batchData);
return {
success: response.data.success,
message: response.data.message || "",
data: response.data.data
data: response.data.data,
};
} catch (error) {
console.error("REST API 배치 저장 오류:", error);
@ -193,4 +225,4 @@ class BatchManagementAPIClass {
}
}
export const BatchManagementAPI = BatchManagementAPIClass;
export const BatchManagementAPI = BatchManagementAPIClass;

View File

@ -26,6 +26,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig;
@ -148,6 +149,149 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return result;
}, [flowConfig, currentStep, component.id, component.label]);
// 🆕 운행알림 버튼 조건부 비활성화 (출발지/도착지 필수, 상태 체크)
// 상태는 API로 조회 (formData에 없는 경우)
const [vehicleStatus, setVehicleStatus] = useState<string | null>(null);
const [statusLoading, setStatusLoading] = useState(false);
// 상태 조회 (operation_control + enableOnStatusCheck일 때)
const actionConfig = component.componentConfig?.action;
const shouldFetchStatus = actionConfig?.type === "operation_control" && actionConfig?.enableOnStatusCheck && userId;
const statusTableName = actionConfig?.statusCheckTableName || "vehicles";
const statusKeyField = actionConfig?.statusCheckKeyField || "user_id";
const statusFieldName = actionConfig?.statusCheckField || "status";
useEffect(() => {
if (!shouldFetchStatus) return;
let isMounted = true;
const fetchStatus = async () => {
if (!isMounted) return;
try {
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
page: 1,
size: 1,
search: { [statusKeyField]: userId },
autoFilter: true,
});
if (!isMounted) return;
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
const firstRow = Array.isArray(rows) ? rows[0] : null;
if (response.data?.success && firstRow) {
const newStatus = firstRow[statusFieldName];
if (newStatus !== vehicleStatus) {
// console.log("🔄 [ButtonPrimary] 상태 변경 감지:", { 이전: vehicleStatus, 현재: newStatus, buttonLabel: component.label });
}
setVehicleStatus(newStatus);
} else {
setVehicleStatus(null);
}
} catch (error: any) {
// console.error("❌ [ButtonPrimary] 상태 조회 오류:", error?.message);
if (isMounted) setVehicleStatus(null);
} finally {
if (isMounted) setStatusLoading(false);
}
};
// 즉시 실행
setStatusLoading(true);
fetchStatus();
// 2초마다 갱신
const interval = setInterval(fetchStatus, 2000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, [shouldFetchStatus, statusTableName, statusKeyField, statusFieldName, userId, component.label]);
// 버튼 비활성화 조건 계산
const isOperationButtonDisabled = useMemo(() => {
const actionConfig = component.componentConfig?.action;
if (actionConfig?.type !== "operation_control") return false;
// 1. 출발지/도착지 필수 체크
if (actionConfig?.requireLocationFields) {
const departureField = actionConfig.trackingDepartureField || "departure";
const destinationField = actionConfig.trackingArrivalField || "destination";
const departure = formData?.[departureField];
const destination = formData?.[destinationField];
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
// departureField, destinationField, departure, destination,
// buttonLabel: component.label
// });
if (!departure || departure === "" || !destination || destination === "") {
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
return true;
}
}
// 2. 상태 기반 활성화 조건 (API로 조회한 vehicleStatus 우선 사용)
if (actionConfig?.enableOnStatusCheck) {
const statusField = actionConfig.statusCheckField || "status";
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
const currentStatus = vehicleStatus || formData?.[statusField];
const conditionType = actionConfig.statusConditionType || "enableOn";
const conditionValues = (actionConfig.statusConditionValues || "")
.split(",")
.map((v: string) => v.trim())
.filter((v: string) => v);
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
// statusField,
// formDataStatus: formData?.[statusField],
// apiStatus: vehicleStatus,
// currentStatus,
// conditionType,
// conditionValues,
// buttonLabel: component.label,
// });
// 상태 로딩 중이면 비활성화
if (statusLoading) {
// console.log("⏳ [ButtonPrimary] 상태 로딩 중 → 비활성화:", component.label);
return true;
}
// 상태값이 없으면 → 비활성화 (조건 확인 불가)
if (!currentStatus) {
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
return true;
}
if (conditionValues.length > 0) {
if (conditionType === "enableOn") {
// 이 상태일 때만 활성화
if (!conditionValues.includes(currentStatus)) {
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∉ [${conditionValues}] → 비활성화:`, component.label);
return true;
}
} else if (conditionType === "disableOn") {
// 이 상태일 때 비활성화
if (conditionValues.includes(currentStatus)) {
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∈ [${conditionValues}] → 비활성화:`, component.label);
return true;
}
}
}
}
// console.log("✅ [ButtonPrimary] 버튼 활성화:", component.label);
return false;
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
// 확인 다이얼로그 상태
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<{
@ -877,6 +1021,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화)
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading;
// 공통 버튼 스타일
const buttonElementStyle: React.CSSProperties = {
width: "100%",
@ -884,12 +1031,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
minHeight: "40px",
border: "none",
borderRadius: "0.5rem",
background: componentConfig.disabled ? "#e5e7eb" : buttonColor,
color: componentConfig.disabled ? "#9ca3af" : "white",
background: finalDisabled ? "#e5e7eb" : buttonColor,
color: finalDisabled ? "#9ca3af" : "white",
// 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
fontWeight: "600",
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
cursor: finalDisabled ? "not-allowed" : "pointer",
outline: "none",
boxSizing: "border-box",
display: "flex",
@ -900,7 +1047,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
margin: "0",
lineHeight: "1.25",
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
...(component.style ? Object.fromEntries(
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
@ -925,7 +1072,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 일반 모드: button으로 렌더링
<button
type={componentConfig.actionType || "button"}
disabled={componentConfig.disabled || false}
disabled={finalDisabled}
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
style={buttonElementStyle}
onClick={handleClick}

View File

@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer";
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
// 🆕 범용 폼 모달 컴포넌트
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
/**
*
*/

View File

@ -193,7 +193,18 @@ export function ModalRepeaterTableComponent({
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해)
const [localValue, setLocalValue] = useState<any[]>(externalValue);
// 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화
useEffect(() => {
// 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화
if (JSON.stringify(externalValue) !== JSON.stringify(localValue)) {
setLocalValue(externalValue);
}
}, [externalValue]);
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
const handleChange = (newData: any[]) => {
@ -249,6 +260,9 @@ export function ModalRepeaterTableComponent({
}
}
// 🆕 내부 상태 즉시 업데이트 (UI 즉시 반영) - 일괄 적용된 데이터로 업데이트
setLocalValue(processedData);
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
@ -321,7 +335,7 @@ export function ModalRepeaterTableComponent({
const handleSaveRequest = async (event: Event) => {
const componentKey = columnName || component?.id || "modal_repeater_data";
if (value.length === 0) {
if (localValue.length === 0) {
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
return;
}
@ -332,7 +346,7 @@ export function ModalRepeaterTableComponent({
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
.map(col => col.field);
const filteredData = value.map((item: any) => {
const filteredData = localValue.map((item: any) => {
const filtered: Record<string, any> = {};
Object.keys(item).forEach((key) => {
@ -389,16 +403,16 @@ export function ModalRepeaterTableComponent({
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [value, columnName, component?.id, onFormDataChange, targetTable]);
}, [localValue, columnName, component?.id, onFormDataChange, targetTable]);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
if (localValue.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(localValue);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
if (JSON.stringify(calculated) !== JSON.stringify(localValue)) {
handleChange(calculated);
}
}
@ -506,7 +520,7 @@ export function ModalRepeaterTableComponent({
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
const newData = [...value, ...calculatedItems];
const newData = [...localValue, ...calculatedItems];
console.log("✅ 최종 데이터:", newData.length, "개 항목");
// ✅ 통합 onChange 호출 (formData 반영 포함)
@ -518,7 +532,7 @@ export function ModalRepeaterTableComponent({
const calculatedRow = calculateRow(newRow);
// 데이터 업데이트
const newData = [...value];
const newData = [...localValue];
newData[index] = calculatedRow;
// ✅ 통합 onChange 호출 (formData 반영 포함)
@ -526,7 +540,7 @@ export function ModalRepeaterTableComponent({
};
const handleRowDelete = (index: number) => {
const newData = value.filter((_, i) => i !== index);
const newData = localValue.filter((_, i) => i !== index);
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
@ -543,7 +557,7 @@ export function ModalRepeaterTableComponent({
{/* 추가 버튼 */}
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{value.length > 0 && `${value.length}개 항목`}
{localValue.length > 0 && `${localValue.length}개 항목`}
</div>
<Button
onClick={() => setModalOpen(true)}
@ -557,7 +571,7 @@ export function ModalRepeaterTableComponent({
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={value}
data={localValue}
onDataChange={handleChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
@ -573,7 +587,7 @@ export function ModalRepeaterTableComponent({
multiSelect={multiSelect}
filterCondition={filterCondition}
modalTitle={modalTitle}
alreadySelected={value}
alreadySelected={localValue}
uniqueField={uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}

View File

@ -75,10 +75,28 @@ export function RepeaterTable({
);
case "date":
// ISO 형식(2025-11-23T00:00:00.000Z)을 yyyy-mm-dd로 변환
const formatDateValue = (val: any): string => {
if (!val) return "";
// 이미 yyyy-mm-dd 형식이면 그대로 반환
if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}$/.test(val)) {
return val;
}
// ISO 형식이면 날짜 부분만 추출
if (typeof val === "string" && val.includes("T")) {
return val.split("T")[0];
}
// Date 객체이면 변환
if (val instanceof Date) {
return val.toISOString().split("T")[0];
}
return String(val);
};
return (
<Input
type="date"
value={value || ""}
value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>

View File

@ -6,10 +6,30 @@ import {
SplitPanelLayout2Config,
ColumnConfig,
DataTransferField,
ActionButtonConfig,
} from "./types";
import { defaultConfig } from "./config";
import { cn } from "@/lib/utils";
import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react";
import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@ -59,6 +79,14 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({});
// 우측 패널 선택 상태 (체크박스용)
const [selectedRightItems, setSelectedRightItems] = useState<Set<string | number>>(new Set());
// 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<any>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
@ -233,6 +261,178 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
}, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]);
// 기본키 컬럼명 가져오기
const getPrimaryKeyColumn = useCallback(() => {
return config.rightPanel?.primaryKeyColumn || "id";
}, [config.rightPanel?.primaryKeyColumn]);
// 우측 패널 수정 버튼 클릭
const handleEditItem = useCallback((item: any) => {
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
if (!modalScreenId) {
toast.error("연결된 모달 화면이 없습니다.");
return;
}
// EditModal 열기 이벤트 발생 (수정 모드)
const event = new CustomEvent("openEditModal", {
detail: {
screenId: modalScreenId,
title: "수정",
modalSize: "lg",
editData: item, // 기존 데이터 전달
isCreateMode: false, // 수정 모드
onSave: () => {
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(event);
console.log("[SplitPanelLayout2] 수정 모달 열기:", item);
}, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]);
// 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
const handleDeleteClick = useCallback((item: any) => {
setItemToDelete(item);
setIsBulkDelete(false);
setDeleteDialogOpen(true);
}, []);
// 일괄 삭제 버튼 클릭 (확인 다이얼로그 표시)
const handleBulkDeleteClick = useCallback(() => {
if (selectedRightItems.size === 0) {
toast.error("삭제할 항목을 선택해주세요.");
return;
}
setIsBulkDelete(true);
setDeleteDialogOpen(true);
}, [selectedRightItems.size]);
// 실제 삭제 실행
const executeDelete = useCallback(async () => {
if (!config.rightPanel?.tableName) {
toast.error("테이블 설정이 없습니다.");
return;
}
const pkColumn = getPrimaryKeyColumn();
try {
if (isBulkDelete) {
// 일괄 삭제
const idsToDelete = Array.from(selectedRightItems);
console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete);
for (const id of idsToDelete) {
await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`);
}
toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`);
setSelectedRightItems(new Set());
} else if (itemToDelete) {
// 단일 삭제
const itemId = itemToDelete[pkColumn];
console.log("[SplitPanelLayout2] 단일 삭제:", itemId);
await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`);
toast.success("항목이 삭제되었습니다.");
}
// 데이터 새로고침
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} catch (error: any) {
console.error("[SplitPanelLayout2] 삭제 실패:", error);
toast.error(`삭제 실패: ${error.message}`);
} finally {
setDeleteDialogOpen(false);
setItemToDelete(null);
setIsBulkDelete(false);
}
}, [config.rightPanel?.tableName, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadRightData]);
// 개별 체크박스 선택/해제
const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => {
setSelectedRightItems((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(itemId);
} else {
newSet.delete(itemId);
}
return newSet;
});
}, []);
// 액션 버튼 클릭 핸들러
const handleActionButton = useCallback((btn: ActionButtonConfig) => {
switch (btn.action) {
case "add":
if (btn.modalScreenId) {
// 데이터 전달 필드 설정
const initialData: Record<string, any> = {};
if (selectedLeftItem && config.dataTransferFields) {
for (const field of config.dataTransferFields) {
if (field.sourceColumn && field.targetColumn) {
initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn];
}
}
}
const event = new CustomEvent("openEditModal", {
detail: {
screenId: btn.modalScreenId,
title: btn.label || "추가",
modalSize: "lg",
editData: initialData,
isCreateMode: true,
onSave: () => {
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(event);
}
break;
case "edit":
// 선택된 항목이 1개일 때만 수정
if (selectedRightItems.size === 1) {
const pkColumn = getPrimaryKeyColumn();
const selectedId = Array.from(selectedRightItems)[0];
const item = rightData.find((d) => d[pkColumn] === selectedId);
if (item) {
handleEditItem(item);
}
} else if (selectedRightItems.size > 1) {
toast.error("수정할 항목을 1개만 선택해주세요.");
} else {
toast.error("수정할 항목을 선택해주세요.");
}
break;
case "delete":
case "bulk-delete":
handleBulkDeleteClick();
break;
case "custom":
// 커스텀 액션 (추후 확장)
console.log("[SplitPanelLayout2] 커스텀 액션:", btn);
break;
default:
break;
}
}, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]);
// 컬럼 라벨 로드
const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
if (!tableName) return;
@ -366,6 +566,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
});
}, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]);
// 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함)
const handleSelectAll = useCallback((checked: boolean) => {
if (checked) {
const pkColumn = getPrimaryKeyColumn();
const allIds = new Set(filteredRightData.map((item) => item[pkColumn]));
setSelectedRightItems(allIds);
} else {
setSelectedRightItems(new Set());
}
}, [filteredRightData, getPrimaryKeyColumn]);
// 리사이즈 핸들러
const handleResizeStart = useCallback((e: React.MouseEvent) => {
if (!config.resizable) return;
@ -564,6 +775,10 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 우측 패널 카드 렌더링
const renderRightCard = (item: any, index: number) => {
const displayColumns = config.rightPanel?.displayColumns || [];
const showLabels = config.rightPanel?.showLabels ?? false;
const showCheckbox = config.rightPanel?.showCheckbox ?? false;
const pkColumn = getPrimaryKeyColumn();
const itemId = item[pkColumn];
// displayRow 설정에 따라 컬럼 분류
// displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info)
@ -577,72 +792,113 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
return (
<Card key={index} className="mb-2 py-0 hover:shadow-md transition-shadow">
<CardContent className="px-4 py-2">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{/* 체크박스 */}
{showCheckbox && (
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
className="mt-1"
/>
)}
<div className="flex-1">
{/* 이름 행 (Name Row) */}
{nameRowColumns.length > 0 && (
<div className="flex items-center gap-2 mb-2">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (!value && idx > 0) return null;
// 첫 번째 컬럼은 굵게 표시
if (idx === 0) {
return (
<span key={idx} className="font-semibold text-lg">
{formatValue(value, col.format) || "이름 없음"}
</span>
);
}
// 나머지는 배지 스타일
return (
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
{formatValue(value, col.format)}
</span>
);
})}
{/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */}
{showLabels ? (
<div className="space-y-1">
{/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">{col.label || col.name}:</span>
<span className="text-sm font-semibold">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
{/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-sm">{col.label || col.name}:</span>
<span className="text-sm">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
</div>
)}
{/* 정보 행 (Info Row) */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-base text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (!value) return null;
// 아이콘 결정
let icon = null;
const colName = col.name.toLowerCase();
if (colName.includes("tel") || colName.includes("phone")) {
icon = <span className="text-sm">tel</span>;
} else if (colName.includes("email")) {
icon = <span className="text-sm">@</span>;
} else if (colName.includes("sabun") || colName.includes("id")) {
icon = <span className="text-sm">ID</span>;
}
return (
<span key={idx} className="flex items-center gap-1">
{icon}
{formatValue(value, col.format)}
</span>
);
})}
) : (
// showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만)
<div className="space-y-1">
{/* 이름 행 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
if (idx === 0) {
return (
<span key={idx} className="font-semibold text-base">
{formatValue(value, col.format)}
</span>
);
}
return (
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
{/* 정보 행 */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="text-sm">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
</div>
)}
</div>
{/* 액션 버튼 */}
{/* 액션 버튼 (개별 수정/삭제) */}
<div className="flex gap-1">
{config.rightPanel?.showEditButton && (
<Button variant="outline" size="sm" className="h-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEditItem(item)}
>
<Edit className="h-4 w-4" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button variant="outline" size="sm" className="h-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
@ -652,6 +908,139 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
);
};
// 우측 패널 테이블 렌더링
const renderRightTable = () => {
const displayColumns = config.rightPanel?.displayColumns || [];
const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시
const pkColumn = getPrimaryKeyColumn();
const allSelected = filteredRightData.length > 0 &&
filteredRightData.every((item) => selectedRightItems.has(item[pkColumn]));
const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn]));
return (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
{showCheckbox && (
<TableHead className="w-12">
<Checkbox
checked={allSelected}
ref={(el) => {
if (el) {
(el as any).indeterminate = someSelected && !allSelected;
}
}}
onCheckedChange={handleSelectAll}
/>
</TableHead>
)}
{displayColumns.map((col, idx) => (
<TableHead
key={idx}
style={{ width: col.width ? `${col.width}px` : "auto" }}
>
{col.label || col.name}
</TableHead>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableHead className="w-24 text-center"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{filteredRightData.length === 0 ? (
<TableRow>
<TableCell
colSpan={displayColumns.length + (showCheckbox ? 1 : 0) + ((config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) ? 1 : 0)}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
filteredRightData.map((item, index) => {
const itemId = item[pkColumn];
return (
<TableRow key={index} className="hover:bg-muted/50">
{showCheckbox && (
<TableCell>
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
/>
</TableCell>
)}
{displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}>
{formatValue(item[col.name], col.format)}
</TableCell>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center">
<div className="flex justify-center gap-1">
{config.rightPanel?.showEditButton && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditItem(item)}
>
<Edit className="h-3.5 w-3.5" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
};
// 액션 버튼 렌더링
const renderActionButtons = () => {
const actionButtons = config.rightPanel?.actionButtons;
if (!actionButtons || actionButtons.length === 0) return null;
return (
<div className="flex gap-2">
{actionButtons.map((btn) => (
<Button
key={btn.id}
variant={btn.variant || "default"}
size="sm"
className="h-8 text-sm"
onClick={() => handleActionButton(btn)}
disabled={
// 일괄 삭제 버튼은 선택된 항목이 없으면 비활성화
(btn.action === "bulk-delete" || btn.action === "delete") && selectedRightItems.size === 0
}
>
{btn.icon === "Plus" && <Plus className="h-4 w-4 mr-1" />}
{btn.icon === "Edit" && <Edit className="h-4 w-4 mr-1" />}
{btn.icon === "Trash2" && <Trash2 className="h-4 w-4 mr-1" />}
{btn.label}
</Button>
))}
</div>
);
};
// 디자인 모드 렌더링
if (isDesignMode) {
return (
@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{/* 헤더 */}
<div className="p-4 border-b bg-muted/30">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-base">
{selectedLeftItem
? config.leftPanel?.displayColumns?.[0]
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
: config.rightPanel?.title || "상세"
: config.rightPanel?.title || "상세"}
</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-base">
{selectedLeftItem
? config.leftPanel?.displayColumns?.[0]
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
: config.rightPanel?.title || "상세"
: config.rightPanel?.title || "상세"}
</h3>
{selectedLeftItem && (
<span className="text-sm text-muted-foreground">
{rightData.length}
({rightData.length})
</span>
)}
{config.rightPanel?.showAddButton && selectedLeftItem && (
{/* 선택된 항목 수 표시 */}
{selectedRightItems.size > 0 && (
<span className="text-sm text-primary font-medium">
{selectedRightItems.size}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* 복수 액션 버튼 (actionButtons 설정 시) */}
{selectedLeftItem && renderActionButtons()}
{/* 기존 단일 추가 버튼 (하위 호환성) */}
{config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
<Plus className="h-4 w-4 mr-1" />
{config.rightPanel?.addButtonLabel || "추가"}
@ -812,18 +1213,50 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
...
</div>
) : filteredRightData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Users className="h-16 w-16 mb-3 opacity-30" />
<span className="text-base"> </span>
</div>
) : (
<div>
{filteredRightData.map((item, index) => renderRightCard(item, index))}
</div>
<>
{/* displayMode에 따라 카드 또는 테이블 렌더링 */}
{config.rightPanel?.displayMode === "table" ? (
renderRightTable()
) : filteredRightData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Users className="h-16 w-16 mb-3 opacity-30" />
<span className="text-base"> </span>
</div>
) : (
<div>
{filteredRightData.map((item, index) => renderRightCard(item, index))}
</div>
)}
</>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{isBulkDelete
? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?`
: "이 항목을 삭제하시겠습니까?"}
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={executeDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
onValueChange={(value) => updateDisplayColumn("left", index, "name", value)}
placeholder="컬럼 선택"
/>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("left", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Select
@ -707,6 +716,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
placeholder="컬럼 선택"
/>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Select
@ -826,6 +844,254 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</div>
</>
)}
{/* 표시 모드 설정 */}
<div className="pt-3 border-t">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.rightPanel?.displayMode || "card"}
onValueChange={(value) => updateConfig("rightPanel.displayMode", value)}
>
<SelectTrigger className="h-9 text-sm mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="card"></SelectItem>
<SelectItem value="table"></SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
카드형: 카드 , 테이블형:
</p>
</div>
{/* 카드 모드 전용 옵션 */}
{(config.rightPanel?.displayMode || "card") === "card" && (
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">라벨: </p>
</div>
<Switch
checked={config.rightPanel?.showLabels || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showLabels", checked)}
/>
</div>
)}
{/* 체크박스 표시 */}
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.rightPanel?.showCheckbox || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showCheckbox", checked)}
/>
</div>
{/* 수정/삭제 버튼 */}
<div className="pt-3 border-t">
<Label className="text-xs font-medium"> /</Label>
<div className="mt-2 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rightPanel?.showEditButton || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rightPanel?.showDeleteButton || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showDeleteButton", checked)}
/>
</div>
</div>
</div>
{/* 수정 모달 화면 (수정 버튼 활성화 시) */}
{config.rightPanel?.showEditButton && (
<div>
<Label className="text-xs"> </Label>
<ScreenSelect
value={config.rightPanel?.editModalScreenId}
onValueChange={(value) => updateConfig("rightPanel.editModalScreenId", value)}
placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)"
open={false}
onOpenChange={() => {}}
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
)}
{/* 기본키 컬럼 */}
<div>
<Label className="text-xs"> </Label>
<ColumnSelect
columns={rightColumns}
value={config.rightPanel?.primaryKeyColumn || ""}
onValueChange={(value) => updateConfig("rightPanel.primaryKeyColumn", value)}
placeholder="기본키 컬럼 선택 (기본: id)"
/>
<p className="text-[10px] text-muted-foreground mt-1">
/ ( id )
</p>
</div>
{/* 복수 액션 버튼 설정 */}
<div className="pt-3 border-t">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs font-medium"> ()</Label>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() => {
const current = config.rightPanel?.actionButtons || [];
updateConfig("rightPanel.actionButtons", [
...current,
{
id: `btn-${Date.now()}`,
label: "새 버튼",
variant: "default",
action: "add",
},
]);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground mb-2">
</p>
<div className="space-y-3">
{(config.rightPanel?.actionButtons || []).map((btn, index) => (
<div key={btn.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => {
const current = config.rightPanel?.actionButtons || [];
updateConfig(
"rightPanel.actionButtons",
current.filter((_, i) => i !== index)
);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={btn.label}
onChange={(e) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], label: e.target.value };
updateConfig("rightPanel.actionButtons", current);
}}
placeholder="버튼 라벨"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.action || "add"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], action: value as any };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="add"> ( )</SelectItem>
<SelectItem value="edit"> ( )</SelectItem>
<SelectItem value="bulk-delete"> ( )</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.variant || "default"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], variant: value as any };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Primary)</SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="destructive"> ()</SelectItem>
<SelectItem value="ghost"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.icon || "none"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], icon: value === "none" ? undefined : value };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="Plus">+ ()</SelectItem>
<SelectItem value="Edit"></SelectItem>
<SelectItem value="Trash2"></SelectItem>
</SelectContent>
</Select>
</div>
{btn.action === "add" && (
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<ScreenSelect
value={btn.modalScreenId}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], modalScreenId: value };
updateConfig("rightPanel.actionButtons", current);
}}
placeholder="모달 화면 선택"
open={false}
onOpenChange={() => {}}
/>
</div>
)}
</div>
))}
{(config.rightPanel?.actionButtons || []).length === 0 && (
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md">
()
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -22,6 +22,18 @@ export interface ColumnConfig {
};
}
/**
*
*/
export interface ActionButtonConfig {
id: string; // 고유 ID
label: string; // 버튼 라벨
variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일
icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2")
modalScreenId?: number; // 연결할 모달 화면 ID
action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형
}
/**
*
*/
@ -70,12 +82,17 @@ export interface RightPanelConfig {
searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성)
searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수)
showSearch?: boolean; // 검색 표시 여부
showAddButton?: boolean; // 추가 버튼 표시
addButtonLabel?: string; // 추가 버튼 라벨
addModalScreenId?: number; // 추가 모달 화면 ID
showEditButton?: boolean; // 수정 버튼 표시
showDeleteButton?: boolean; // 삭제 버튼 표시
displayMode?: "card" | "list"; // 표시 모드
showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성)
addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성)
addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성)
showEditButton?: boolean; // 수정 버튼 표시 (하위 호환성)
showDeleteButton?: boolean; // 삭제 버튼 표시 (하위 호환성)
editModalScreenId?: number; // 수정 모달 화면 ID
displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형)
showLabels?: boolean; // 카드 모드에서 라벨 표시 여부 (라벨: 값 형식)
showCheckbox?: boolean; // 체크박스 표시 여부 (테이블 모드에서 일괄 선택용)
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id)
emptyMessage?: string; // 데이터 없을 때 메시지
}
@ -110,4 +127,3 @@ export interface SplitPanelLayout2Config {
// 동작 설정
autoLoad?: boolean; // 자동 데이터 로드
}

View File

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { UniversalFormModalDefinition } from "./index";
import { UniversalFormModalComponent } from "./UniversalFormModalComponent";
/**
*
*
*/
export class UniversalFormModalRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = UniversalFormModalDefinition;
render(): React.ReactElement {
return <UniversalFormModalComponent {...this.props} />;
}
/**
*
*/
protected handleFormDataChange = (data: any) => {
this.updateComponent({ formData: data });
};
/**
*
*/
protected handleSave = (data: any) => {
console.log("[UniversalFormModalRenderer] 저장 완료:", data);
};
}
// 자동 등록 실행
UniversalFormModalRenderer.registerSelf();

View File

@ -0,0 +1,138 @@
/**
*
*/
import { UniversalFormModalConfig } from "./types";
// 기본 설정값
export const defaultConfig: UniversalFormModalConfig = {
modal: {
title: "데이터 입력",
description: "",
size: "lg",
closeOnOutsideClick: false,
showCloseButton: true,
saveButtonText: "저장",
cancelButtonText: "취소",
showResetButton: false,
resetButtonText: "초기화",
},
sections: [
{
id: "default",
title: "기본 정보",
description: "",
collapsible: false,
defaultCollapsed: false,
columns: 2,
gap: "16px",
fields: [],
repeatable: false,
},
],
saveConfig: {
tableName: "",
primaryKeyColumn: "id",
multiRowSave: {
enabled: false,
commonFields: [],
repeatSectionId: "",
typeColumn: "",
mainTypeValue: "main",
subTypeValue: "concurrent",
mainSectionFields: [],
},
afterSave: {
closeModal: true,
refreshParent: true,
showToast: true,
},
},
editMode: {
enabled: false,
loadDataOnOpen: true,
identifierField: "id",
},
};
// 기본 필드 설정
export const defaultFieldConfig = {
id: "",
columnName: "",
label: "",
fieldType: "text" as const,
required: false,
defaultValue: "",
placeholder: "",
disabled: false,
readOnly: false,
width: "100%",
gridSpan: 6,
receiveFromParent: false,
};
// 기본 섹션 설정
export const defaultSectionConfig = {
id: "",
title: "새 섹션",
description: "",
collapsible: false,
defaultCollapsed: false,
columns: 2,
gap: "16px",
fields: [],
repeatable: false,
repeatConfig: {
minItems: 0,
maxItems: 10,
addButtonText: "+ 추가",
removeButtonText: "삭제",
itemTitle: "항목 {index}",
confirmRemove: false,
},
};
// 기본 채번규칙 설정
export const defaultNumberingRuleConfig = {
enabled: false,
ruleId: "",
editable: false,
hidden: false,
generateOnOpen: true,
generateOnSave: false,
};
// 기본 Select 옵션 설정
export const defaultSelectOptionsConfig = {
type: "static" as const,
staticOptions: [],
tableName: "",
valueColumn: "",
labelColumn: "",
filterCondition: "",
codeCategory: "",
};
// 모달 크기별 너비
export const MODAL_SIZE_MAP = {
sm: 400,
md: 600,
lg: 800,
xl: 1000,
full: "100%",
} as const;
// 유틸리티: 고유 ID 생성
export const generateUniqueId = (prefix: string = "item"): string => {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
// 유틸리티: 섹션 ID 생성
export const generateSectionId = (): string => {
return generateUniqueId("section");
};
// 유틸리티: 필드 ID 생성
export const generateFieldId = (): string => {
return generateUniqueId("field");
};

View File

@ -0,0 +1,77 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { UniversalFormModalComponent } from "./UniversalFormModalComponent";
import { UniversalFormModalConfigPanel } from "./UniversalFormModalConfigPanel";
import { defaultConfig } from "./config";
/**
*
*
* , ,
* .
*/
export const UniversalFormModalDefinition = createComponentDefinition({
id: "universal-form-modal",
name: "범용 폼 모달",
nameEng: "Universal Form Modal",
description: "섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는 범용 모달 컴포넌트",
category: ComponentCategory.INPUT,
webType: "form",
component: UniversalFormModalComponent,
defaultConfig: defaultConfig,
defaultSize: {
width: 800,
height: 600,
gridColumnSpan: "12",
},
configPanel: UniversalFormModalConfigPanel,
icon: "FormInput",
tags: ["폼", "모달", "입력", "저장", "채번", "겸직", "다중행"],
version: "1.0.0",
author: "개발팀",
documentation: `
##
###
- ** **: ,
- ** **:
- ** **: ( )
- ** **: +
- ** **:
###
1. +
2. +
3. +
###
1.
2. ( , )
3.
4. ( )
5. ( )
6. ( )
`,
});
// 컴포넌트 내보내기
export { UniversalFormModalComponent } from "./UniversalFormModalComponent";
export { UniversalFormModalConfigPanel } from "./UniversalFormModalConfigPanel";
export { defaultConfig } from "./config";
// 타입 내보내기
export type {
UniversalFormModalConfig,
UniversalFormModalComponentProps,
UniversalFormModalConfigPanelProps,
FormSectionConfig,
FormFieldConfig,
SaveConfig,
MultiRowSaveConfig,
NumberingRuleConfig,
SelectOptionConfig,
FormDataState,
RepeatSectionItem,
} from "./types";

View File

@ -0,0 +1,259 @@
/**
*
*
* , ,
* .
*/
// Select 옵션 설정
export interface SelectOptionConfig {
type?: "static" | "table" | "code"; // 옵션 타입 (기본: static)
// 정적 옵션
staticOptions?: { value: string; label: string }[];
// 테이블 기반 옵션
tableName?: string;
valueColumn?: string;
labelColumn?: string;
filterCondition?: string;
// 공통코드 기반 옵션
codeCategory?: string;
}
// 채번규칙 설정
export interface NumberingRuleConfig {
enabled?: boolean; // 사용 여부 (기본: false)
ruleId?: string; // 채번규칙 ID
editable?: boolean; // 사용자 수정 가능 여부 (기본: false)
hidden?: boolean; // 숨김 여부 - 자동 저장만 (기본: false)
generateOnOpen?: boolean; // 모달 열릴 때 생성 (기본: true)
generateOnSave?: boolean; // 저장 시점에 생성 (기본: false)
}
// 필드 유효성 검사 설정
export interface FieldValidationConfig {
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
pattern?: string;
patternMessage?: string;
customValidator?: string; // 커스텀 검증 함수명
}
// 필드 설정
export interface FormFieldConfig {
id: string;
columnName: string; // DB 컬럼명
label: string; // 표시 라벨
fieldType:
| "text"
| "number"
| "date"
| "datetime"
| "select"
| "checkbox"
| "textarea"
| "password"
| "email"
| "tel";
required?: boolean;
defaultValue?: any;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
hidden?: boolean; // 화면에 표시하지 않고 자동 저장만
// 레이아웃
width?: string; // 필드 너비 (예: "50%", "100%")
gridColumn?: number; // 그리드 컬럼 위치 (1-12)
gridSpan?: number; // 그리드 컬럼 스팬 (1-12)
// 채번규칙 설정
numberingRule?: NumberingRuleConfig;
// Select 옵션
selectOptions?: SelectOptionConfig;
// 유효성 검사
validation?: FieldValidationConfig;
// 외부 데이터 수신
receiveFromParent?: boolean; // 부모에서 값 받기
parentFieldName?: string; // 부모 필드명 (다르면 지정)
// 조건부 표시
visibleCondition?: {
field: string; // 참조할 필드
operator: "eq" | "ne" | "gt" | "lt" | "in" | "notIn";
value: any;
};
// 필드 간 연동
dependsOn?: {
field: string; // 의존하는 필드
action: "filter" | "setValue" | "clear";
config?: any;
};
}
// 반복 섹션 설정
export interface RepeatSectionConfig {
minItems?: number; // 최소 항목 수 (기본: 0)
maxItems?: number; // 최대 항목 수 (기본: 10)
addButtonText?: string; // 추가 버튼 텍스트 (기본: "+ 추가")
removeButtonText?: string; // 삭제 버튼 텍스트 (기본: "삭제")
itemTitle?: string; // 항목 제목 템플릿 (예: "겸직 {index}")
confirmRemove?: boolean; // 삭제 시 확인 (기본: false)
}
// 섹션 설정
export interface FormSectionConfig {
id: string;
title: string;
description?: string;
collapsible?: boolean; // 접을 수 있는지 (기본: false)
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
fields: FormFieldConfig[];
// 반복 섹션 (겸직 등)
repeatable?: boolean;
repeatConfig?: RepeatSectionConfig;
// 섹션 레이아웃
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
gap?: string; // 필드 간 간격
}
// 다중 행 저장 설정
export interface MultiRowSaveConfig {
enabled?: boolean; // 사용 여부 (기본: false)
commonFields?: string[]; // 모든 행에 공통 저장할 필드 (columnName 기준)
repeatSectionId?: string; // 반복 섹션 ID
typeColumn?: string; // 구분 컬럼명 (예: "employment_type")
mainTypeValue?: string; // 메인 행 값 (예: "main")
subTypeValue?: string; // 서브 행 값 (예: "concurrent")
// 메인 섹션 필드 (반복 섹션이 아닌 곳의 부서/직급 등)
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
}
// 저장 설정
export interface SaveConfig {
tableName: string;
primaryKeyColumn?: string; // PK 컬럼 (수정 시 사용)
// 다중 행 저장 설정
multiRowSave?: MultiRowSaveConfig;
// 저장 후 동작 (간편 설정)
showToast?: boolean; // 토스트 메시지 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)
// 저장 후 동작 (상세 설정)
afterSave?: {
closeModal?: boolean; // 모달 닫기 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)
showToast?: boolean; // 토스트 메시지 (기본: true)
customAction?: string; // 커스텀 액션 이벤트명
};
}
// 모달 설정
export interface ModalConfig {
title: string;
description?: string;
size: "sm" | "md" | "lg" | "xl" | "full";
closeOnOutsideClick?: boolean;
showCloseButton?: boolean;
// 버튼 설정
saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장")
cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소")
showResetButton?: boolean; // 초기화 버튼 표시
resetButtonText?: string; // 초기화 버튼 텍스트
}
// 전체 설정
export interface UniversalFormModalConfig {
modal: ModalConfig;
sections: FormSectionConfig[];
saveConfig: SaveConfig;
// 수정 모드 설정
editMode?: {
enabled: boolean;
loadDataOnOpen?: boolean; // 모달 열릴 때 데이터 로드
identifierField?: string; // 식별자 필드 (user_id 등)
};
}
// 반복 섹션 데이터 아이템
export interface RepeatSectionItem {
_id: string; // 내부 고유 ID
_index: number; // 인덱스
[key: string]: any; // 필드 데이터
}
// 폼 데이터 상태
export interface FormDataState {
// 일반 필드 데이터
[key: string]: any;
// 반복 섹션 데이터
_repeatSections?: {
[sectionId: string]: RepeatSectionItem[];
};
}
// 컴포넌트 Props
export interface UniversalFormModalComponentProps {
component?: any;
config?: UniversalFormModalConfig;
isDesignMode?: boolean;
isSelected?: boolean;
className?: string;
style?: React.CSSProperties;
// 외부에서 전달받는 초기 데이터
initialData?: Record<string, any>;
// 이벤트 핸들러
onSave?: (data: any) => void;
onCancel?: () => void;
onChange?: (data: FormDataState) => void;
}
// ConfigPanel Props
export interface UniversalFormModalConfigPanelProps {
config: UniversalFormModalConfig;
onChange: (config: UniversalFormModalConfig) => void;
}
// 필드 타입 옵션
export const FIELD_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "날짜시간" },
{ value: "select", label: "선택(드롭다운)" },
{ value: "checkbox", label: "체크박스" },
{ value: "textarea", label: "여러 줄 텍스트" },
{ value: "password", label: "비밀번호" },
{ value: "email", label: "이메일" },
{ value: "tel", label: "전화번호" },
] as const;
// 모달 크기 옵션
export const MODAL_SIZE_OPTIONS = [
{ value: "sm", label: "작게 (400px)" },
{ value: "md", label: "보통 (600px)" },
{ value: "lg", label: "크게 (800px)" },
{ value: "xl", label: "매우 크게 (1000px)" },
{ value: "full", label: "전체 화면" },
] as const;
// Select 옵션 타입
export const SELECT_OPTION_TYPE_OPTIONS = [
{ value: "static", label: "직접 입력" },
{ value: "table", label: "테이블 참조" },
{ value: "code", label: "공통코드" },
] as const;

View File

@ -3613,6 +3613,112 @@ export class ButtonActionExecutor {
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed");
// 🆕 거리/시간 계산 및 저장
if (tripId) {
try {
const tripStats = await this.calculateTripStats(tripId);
console.log("📊 운행 통계:", tripStats);
// 운행 통계를 두 테이블에 저장
if (tripStats) {
const distanceMeters = Math.round(tripStats.totalDistanceKm * 1000); // km → m
const timeMinutes = tripStats.totalTimeMinutes;
const userId = this.trackingUserId || context.userId;
console.log("💾 운행 통계 DB 저장 시도:", {
tripId,
userId,
distanceMeters,
timeMinutes,
startTime: tripStats.startTime,
endTime: tripStats.endTime,
});
const { apiClient } = await import("@/lib/api/client");
// 1⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용)
try {
const lastRecordResponse = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, {
page: 1,
size: 1,
search: { trip_id: tripId },
sortBy: "recorded_at",
sortOrder: "desc",
autoFilter: true,
});
const lastRecordData = lastRecordResponse.data?.data?.data || lastRecordResponse.data?.data?.rows || [];
if (lastRecordData.length > 0) {
const lastRecordId = lastRecordData[0].id;
console.log("📍 마지막 레코드 ID:", lastRecordId);
const historyUpdates = [
{ field: "trip_distance", value: distanceMeters },
{ field: "trip_time", value: timeMinutes },
{ field: "trip_start", value: tripStats.startTime },
{ field: "trip_end", value: tripStats.endTime },
];
for (const update of historyUpdates) {
await apiClient.put(`/dynamic-form/update-field`, {
tableName: "vehicle_location_history",
keyField: "id",
keyValue: lastRecordId,
updateField: update.field,
updateValue: update.value,
});
}
console.log("✅ vehicle_location_history 통계 저장 완료");
} else {
console.warn("⚠️ trip_id에 해당하는 레코드를 찾을 수 없음:", tripId);
}
} catch (historyError) {
console.warn("⚠️ vehicle_location_history 저장 실패:", historyError);
}
// 2⃣ vehicles 테이블에도 마지막 운행 통계 업데이트 (최신 정보용)
if (userId) {
try {
const vehicleUpdates = [
{ field: "last_trip_distance", value: distanceMeters },
{ field: "last_trip_time", value: timeMinutes },
{ field: "last_trip_start", value: tripStats.startTime },
{ field: "last_trip_end", value: tripStats.endTime },
];
for (const update of vehicleUpdates) {
await apiClient.put(`/dynamic-form/update-field`, {
tableName: "vehicles",
keyField: "user_id",
keyValue: userId,
updateField: update.field,
updateValue: update.value,
});
}
console.log("✅ vehicles 테이블 통계 업데이트 완료");
} catch (vehicleError) {
console.warn("⚠️ vehicles 테이블 저장 실패:", vehicleError);
}
}
// 이벤트로 통계 전달 (UI에서 표시용)
window.dispatchEvent(new CustomEvent("tripCompleted", {
detail: {
tripId,
totalDistanceKm: tripStats.totalDistanceKm,
totalTimeMinutes: tripStats.totalTimeMinutes,
startTime: tripStats.startTime,
endTime: tripStats.endTime,
}
}));
toast.success(`운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`);
}
} catch (statsError) {
console.warn("⚠️ 운행 통계 계산 실패:", statsError);
}
}
// 상태 변경 (vehicles 테이블 등)
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
const effectiveContext = context.userId ? context : this.trackingContext;
@ -3662,6 +3768,104 @@ export class ButtonActionExecutor {
}
}
/**
* (, )
*/
private static async calculateTripStats(tripId: string): Promise<{
totalDistanceKm: number;
totalTimeMinutes: number;
startTime: string | null;
endTime: string | null;
} | null> {
try {
// vehicle_location_history에서 해당 trip의 모든 위치 조회
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, {
page: 1,
size: 10000,
search: { trip_id: tripId },
sortBy: "recorded_at",
sortOrder: "asc",
});
if (!response.data?.success) {
console.log("📊 통계 계산: API 응답 실패");
return null;
}
// 응답 형식: data.data.data 또는 data.data.rows
const rows = response.data?.data?.data || response.data?.data?.rows || [];
if (!rows.length) {
console.log("📊 통계 계산: 데이터 없음");
return null;
}
const locations = rows;
console.log(`📊 통계 계산: ${locations.length}개 위치 데이터`);
// 시간 계산
const startTime = locations[0].recorded_at;
const endTime = locations[locations.length - 1].recorded_at;
const totalTimeMs = new Date(endTime).getTime() - new Date(startTime).getTime();
const totalTimeMinutes = Math.round(totalTimeMs / 60000);
// 거리 계산 (Haversine 공식)
let totalDistanceM = 0;
for (let i = 1; i < locations.length; i++) {
const prev = locations[i - 1];
const curr = locations[i];
if (prev.latitude && prev.longitude && curr.latitude && curr.longitude) {
const distance = this.calculateDistance(
parseFloat(prev.latitude),
parseFloat(prev.longitude),
parseFloat(curr.latitude),
parseFloat(curr.longitude)
);
totalDistanceM += distance;
}
}
const totalDistanceKm = totalDistanceM / 1000;
console.log("📊 운행 통계 결과:", {
tripId,
totalDistanceKm,
totalTimeMinutes,
startTime,
endTime,
pointCount: locations.length,
});
return {
totalDistanceKm,
totalTimeMinutes,
startTime,
endTime,
};
} catch (error) {
console.error("❌ 운행 통계 계산 오류:", error);
return null;
}
}
/**
* (Haversine , )
*/
private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; // 지구 반경 (미터)
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* ( )
* + vehicles latitude/longitude도
@ -4217,6 +4421,28 @@ export class ButtonActionExecutor {
try {
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
// 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만)
// updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우
const isStartMode = config.updateTrackingMode === "start" ||
config.updateTargetValue === "active" ||
config.updateTargetValue === "inactive";
if (isStartMode) {
// 출발지/도착지 필드명 (기본값: departure, destination)
const departureField = config.trackingDepartureField || "departure";
const destinationField = config.trackingArrivalField || "destination";
const departure = context.formData?.[departureField];
const destination = context.formData?.[destinationField];
console.log("📍 출발지/도착지 체크:", { departureField, destinationField, departure, destination });
if (!departure || departure === "" || !destination || destination === "") {
toast.error("출발지와 도착지를 먼저 선택해주세요.");
return false;
}
}
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
if (this.emptyVehicleWatchId !== null) {
this.stopEmptyVehicleTracking();

View File

@ -123,3 +123,24 @@ export const RESET_PERIOD_OPTIONS: Array<{
{ value: "monthly", label: "월별 초기화" },
{ value: "yearly", label: "연별 초기화" },
];
/**
*
* -
* - "none"
* - "custom" ( 2)
*/
export type SeparatorType = "none" | "-" | "_" | "." | "/" | "custom";
export const SEPARATOR_OPTIONS: Array<{
value: SeparatorType;
label: string;
displayValue: string;
}> = [
{ value: "none", label: "없음", displayValue: "" },
{ value: "-", label: "하이픈 (-)", displayValue: "-" },
{ value: "_", label: "언더스코어 (_)", displayValue: "_" },
{ value: ".", label: "점 (.)", displayValue: "." },
{ value: "/", label: "슬래시 (/)", displayValue: "/" },
{ value: "custom", label: "직접입력", displayValue: "" },
];