Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
07f49b1f6a
|
|
@ -1,7 +1,7 @@
|
||||||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import { Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import {
|
import {
|
||||||
BatchManagementService,
|
BatchManagementService,
|
||||||
|
|
@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService";
|
||||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
export class BatchManagementController {
|
export class BatchManagementController {
|
||||||
/**
|
/**
|
||||||
|
|
@ -422,6 +423,8 @@ export class BatchManagementController {
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
requestBody,
|
requestBody,
|
||||||
|
authServiceName, // DB에서 토큰 가져올 서비스명
|
||||||
|
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// apiUrl, endpoint는 항상 필수
|
// apiUrl, endpoint는 항상 필수
|
||||||
|
|
@ -432,15 +435,47 @@ export class BatchManagementController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
|
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
|
||||||
if ((!method || method === "GET") && !apiKey) {
|
let finalApiKey = apiKey || "";
|
||||||
return res.status(400).json({
|
if (authServiceName) {
|
||||||
success: false,
|
const companyCode = req.user?.companyCode;
|
||||||
message: "GET 메서드에서는 API Key가 필요합니다.",
|
|
||||||
});
|
// 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,
|
apiUrl,
|
||||||
endpoint,
|
endpoint,
|
||||||
method,
|
method,
|
||||||
|
|
@ -449,6 +484,8 @@ export class BatchManagementController {
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
requestBody: requestBody ? "Included" : "None",
|
requestBody: requestBody ? "Included" : "None",
|
||||||
|
authServiceName: authServiceName || "직접 입력",
|
||||||
|
dataArrayPath: dataArrayPath || "전체 응답",
|
||||||
});
|
});
|
||||||
|
|
||||||
// RestApiConnector 사용하여 데이터 조회
|
// RestApiConnector 사용하여 데이터 조회
|
||||||
|
|
@ -456,7 +493,7 @@ export class BatchManagementController {
|
||||||
|
|
||||||
const connector = new RestApiConnector({
|
const connector = new RestApiConnector({
|
||||||
baseUrl: apiUrl,
|
baseUrl: apiUrl,
|
||||||
apiKey: apiKey || "",
|
apiKey: finalApiKey,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -511,8 +548,50 @@ export class BatchManagementController {
|
||||||
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
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) {
|
if (data.length > 0) {
|
||||||
// 첫 번째 객체에서 필드명 추출
|
// 첫 번째 객체에서 필드명 추출
|
||||||
|
|
@ -524,9 +603,9 @@ export class BatchManagementController {
|
||||||
data: {
|
data: {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
samples: data,
|
samples: data,
|
||||||
totalCount: result.rowCount || data.length,
|
totalCount: extractedData.length,
|
||||||
},
|
},
|
||||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
|
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -554,8 +633,17 @@ export class BatchManagementController {
|
||||||
*/
|
*/
|
||||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { batchName, batchType, cronSchedule, description, apiMappings } =
|
const {
|
||||||
req.body;
|
batchName,
|
||||||
|
batchType,
|
||||||
|
cronSchedule,
|
||||||
|
description,
|
||||||
|
apiMappings,
|
||||||
|
authServiceName,
|
||||||
|
dataArrayPath,
|
||||||
|
saveMode,
|
||||||
|
conflictKey,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!batchName ||
|
!batchName ||
|
||||||
|
|
@ -576,6 +664,10 @@ export class BatchManagementController {
|
||||||
cronSchedule,
|
cronSchedule,
|
||||||
description,
|
description,
|
||||||
apiMappings,
|
apiMappings,
|
||||||
|
authServiceName,
|
||||||
|
dataArrayPath,
|
||||||
|
saveMode,
|
||||||
|
conflictKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||||
|
|
@ -589,6 +681,10 @@ export class BatchManagementController {
|
||||||
cronSchedule: cronSchedule,
|
cronSchedule: cronSchedule,
|
||||||
isActive: "Y",
|
isActive: "Y",
|
||||||
companyCode,
|
companyCode,
|
||||||
|
authServiceName: authServiceName || undefined,
|
||||||
|
dataArrayPath: dataArrayPath || undefined,
|
||||||
|
saveMode: saveMode || "INSERT",
|
||||||
|
conflictKey: conflictKey || undefined,
|
||||||
mappings: apiMappings,
|
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: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
|
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -125,6 +126,19 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
let parsedExcludeFilter: any = undefined;
|
||||||
|
if (excludeFilter) {
|
||||||
|
try {
|
||||||
|
parsedExcludeFilter =
|
||||||
|
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
|
||||||
|
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("제외 필터 파싱 오류:", error);
|
||||||
|
parsedExcludeFilter = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -141,6 +155,7 @@ export class EntityJoinController {
|
||||||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
|
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
|
||||||
*/
|
*/
|
||||||
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/auth-services
|
||||||
|
* 인증 토큰 서비스명 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import cron, { ScheduledTask } from "node-cron";
|
||||||
import { BatchService } from "./batchService";
|
import { BatchService } from "./batchService";
|
||||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
export class BatchSchedulerService {
|
export class BatchSchedulerService {
|
||||||
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
|
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 tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||||
|
const fixedMappingsGlobal: typeof config.batch_mappings = [];
|
||||||
|
|
||||||
for (const mapping of 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}`;
|
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
|
||||||
if (!tableGroups.has(key)) {
|
if (!tableGroups.has(key)) {
|
||||||
tableGroups.set(key, []);
|
tableGroups.set(key, []);
|
||||||
|
|
@ -224,6 +232,14 @@ export class BatchSchedulerService {
|
||||||
tableGroups.get(key)!.push(mapping);
|
tableGroups.get(key)!.push(mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
|
||||||
|
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
|
||||||
|
);
|
||||||
|
return { totalRecords, successRecords, failedRecords };
|
||||||
|
}
|
||||||
|
|
||||||
// 각 테이블 그룹별로 처리
|
// 각 테이블 그룹별로 처리
|
||||||
for (const [tableKey, mappings] of tableGroups) {
|
for (const [tableKey, mappings] of tableGroups) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -244,10 +260,46 @@ export class BatchSchedulerService {
|
||||||
"./batchExternalDbService"
|
"./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 요청 시)
|
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||||
firstMapping.from_api_url!,
|
firstMapping.from_api_url!,
|
||||||
firstMapping.from_api_key!,
|
apiKey,
|
||||||
firstMapping.from_table_name,
|
firstMapping.from_table_name,
|
||||||
(firstMapping.from_api_method as
|
(firstMapping.from_api_method as
|
||||||
| "GET"
|
| "GET"
|
||||||
|
|
@ -266,7 +318,36 @@ export class BatchSchedulerService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiResult.success && apiResult.data) {
|
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 {
|
} else {
|
||||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -298,6 +379,11 @@ export class BatchSchedulerService {
|
||||||
const mappedData = fromData.map((row) => {
|
const mappedData = fromData.map((row) => {
|
||||||
const mappedRow: any = {};
|
const mappedRow: any = {};
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
|
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
|
||||||
|
if (mapping.mapping_type === "fixed") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// DB → REST API 배치인지 확인
|
// DB → REST API 배치인지 확인
|
||||||
if (
|
if (
|
||||||
firstMapping.to_connection_type === "restapi" &&
|
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 자동 주입
|
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
|
||||||
// - 배치 설정에 company_code가 있고
|
// - 배치 설정에 company_code가 있고
|
||||||
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
||||||
|
|
@ -384,12 +477,14 @@ export class BatchSchedulerService {
|
||||||
insertResult = { successCount: 0, failedCount: 0 };
|
insertResult = { successCount: 0, failedCount: 0 };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// DB에 데이터 삽입
|
// DB에 데이터 삽입 (save_mode, conflict_key 지원)
|
||||||
insertResult = await BatchService.insertDataToTable(
|
insertResult = await BatchService.insertDataToTable(
|
||||||
firstMapping.to_table_name,
|
firstMapping.to_table_name,
|
||||||
mappedData,
|
mappedData,
|
||||||
firstMapping.to_connection_type as "internal" | "external",
|
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,8 @@ export class BatchService {
|
||||||
// 배치 설정 생성
|
// 배치 설정 생성
|
||||||
const batchConfigResult = await client.query(
|
const batchConfigResult = await client.query(
|
||||||
`INSERT INTO batch_configs
|
`INSERT INTO batch_configs
|
||||||
(batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date)
|
(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, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.batchName,
|
data.batchName,
|
||||||
|
|
@ -185,6 +185,10 @@ export class BatchService {
|
||||||
data.cronSchedule,
|
data.cronSchedule,
|
||||||
data.isActive || "Y",
|
data.isActive || "Y",
|
||||||
data.companyCode,
|
data.companyCode,
|
||||||
|
data.saveMode || "INSERT",
|
||||||
|
data.conflictKey || null,
|
||||||
|
data.authServiceName || null,
|
||||||
|
data.dataArrayPath || null,
|
||||||
userId,
|
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_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,
|
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_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)
|
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, NOW())
|
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 *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
batchConfig.id,
|
batchConfig.id,
|
||||||
data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용
|
data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용
|
||||||
mapping.from_connection_type,
|
mapping.from_connection_type,
|
||||||
mapping.from_connection_id,
|
mapping.from_connection_id,
|
||||||
mapping.from_table_name,
|
mapping.from_table_name,
|
||||||
mapping.from_column_name,
|
mapping.from_column_name,
|
||||||
mapping.from_column_type,
|
mapping.from_column_type,
|
||||||
mapping.from_api_url,
|
mapping.from_api_url,
|
||||||
mapping.from_api_key,
|
mapping.from_api_key,
|
||||||
mapping.from_api_method,
|
mapping.from_api_method,
|
||||||
mapping.from_api_param_type,
|
mapping.from_api_param_type,
|
||||||
mapping.from_api_param_name,
|
mapping.from_api_param_name,
|
||||||
mapping.from_api_param_value,
|
mapping.from_api_param_value,
|
||||||
mapping.from_api_param_source,
|
mapping.from_api_param_source,
|
||||||
mapping.from_api_body, // FROM REST API Body
|
mapping.from_api_body, // FROM REST API Body
|
||||||
mapping.to_connection_type,
|
mapping.to_connection_type,
|
||||||
mapping.to_connection_id,
|
mapping.to_connection_id,
|
||||||
mapping.to_table_name,
|
mapping.to_table_name,
|
||||||
mapping.to_column_name,
|
mapping.to_column_name,
|
||||||
mapping.to_column_type,
|
mapping.to_column_type,
|
||||||
mapping.to_api_url,
|
mapping.to_api_url,
|
||||||
mapping.to_api_key,
|
mapping.to_api_key,
|
||||||
mapping.to_api_method,
|
mapping.to_api_method,
|
||||||
mapping.to_api_body,
|
mapping.to_api_body,
|
||||||
mapping.mapping_order || index + 1,
|
mapping.mapping_order || index + 1,
|
||||||
userId,
|
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
||||||
]
|
userId,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
mappings.push(mappingResult.rows[0]);
|
mappings.push(mappingResult.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +316,22 @@ export class BatchService {
|
||||||
updateFields.push(`is_active = $${paramIndex++}`);
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
updateValues.push(data.isActive);
|
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(
|
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_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,
|
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_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)
|
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, NOW())
|
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 *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
id,
|
id,
|
||||||
|
|
@ -368,6 +389,7 @@ export class BatchService {
|
||||||
mapping.to_api_method,
|
mapping.to_api_method,
|
||||||
mapping.to_api_body,
|
mapping.to_api_body,
|
||||||
mapping.mapping_order || index + 1,
|
mapping.mapping_order || index + 1,
|
||||||
|
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
||||||
userId,
|
userId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -554,9 +576,7 @@ export class BatchService {
|
||||||
try {
|
try {
|
||||||
if (connectionType === "internal") {
|
if (connectionType === "internal") {
|
||||||
// 내부 DB 데이터 조회
|
// 내부 DB 데이터 조회
|
||||||
const data = await query<any>(
|
const data = await query<any>(`SELECT * FROM ${tableName} LIMIT 10`);
|
||||||
`SELECT * FROM ${tableName} LIMIT 10`
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
|
|
@ -729,19 +749,27 @@ export class BatchService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param data 삽입할 데이터 배열
|
||||||
|
* @param connectionType 연결 타입 (internal/external)
|
||||||
|
* @param connectionId 외부 연결 ID
|
||||||
|
* @param saveMode 저장 모드 (INSERT/UPSERT)
|
||||||
|
* @param conflictKey UPSERT 시 충돌 기준 컬럼명
|
||||||
*/
|
*/
|
||||||
static async insertDataToTable(
|
static async insertDataToTable(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: any[],
|
data: any[],
|
||||||
connectionType: "internal" | "external" = "internal",
|
connectionType: "internal" | "external" = "internal",
|
||||||
connectionId?: number
|
connectionId?: number,
|
||||||
|
saveMode: "INSERT" | "UPSERT" = "INSERT",
|
||||||
|
conflictKey?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드`
|
`[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
|
|
@ -753,24 +781,54 @@ export class BatchService {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
|
||||||
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
// 각 레코드를 개별적으로 삽입
|
||||||
for (const record of data) {
|
for (const record of data) {
|
||||||
try {
|
try {
|
||||||
const columns = Object.keys(record);
|
const columns = Object.keys(record);
|
||||||
const values = Object.values(record);
|
const values = Object.values(record);
|
||||||
const placeholders = values
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
.map((_, i) => `$${i + 1}`)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
const queryStr = `INSERT INTO ${tableName} (${columns.join(
|
let queryStr: string;
|
||||||
", "
|
|
||||||
)}) VALUES (${placeholders})`;
|
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);
|
await query(queryStr, values);
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (insertError) {
|
} catch (insertError) {
|
||||||
console.error(
|
console.error(
|
||||||
`내부 DB 데이터 삽입 실패 (${tableName}):`,
|
`내부 DB 데이터 ${saveMode} 실패 (${tableName}):`,
|
||||||
insertError
|
insertError
|
||||||
);
|
);
|
||||||
failedCount++;
|
failedCount++;
|
||||||
|
|
@ -779,7 +837,13 @@ export class BatchService {
|
||||||
|
|
||||||
return { successCount, failedCount };
|
return { successCount, failedCount };
|
||||||
} else if (connectionType === "external" && connectionId) {
|
} else if (connectionType === "external" && connectionId) {
|
||||||
// 외부 DB에 데이터 삽입
|
// 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원)
|
||||||
|
if (saveMode === "UPSERT") {
|
||||||
|
console.warn(
|
||||||
|
`[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await BatchExternalDbService.insertDataToTable(
|
const result = await BatchExternalDbService.insertDataToTable(
|
||||||
connectionId,
|
connectionId,
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -799,7 +863,7 @@ export class BatchService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`데이터 삽입 오류 (${tableName}):`, error);
|
console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error);
|
||||||
return { successCount: 0, failedCount: data ? data.length : 0 };
|
return { successCount: 0, failedCount: data ? data.length : 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2462,6 +2462,14 @@ export class TableManagementService {
|
||||||
}>;
|
}>;
|
||||||
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
||||||
dataFilter?: any; // 🆕 데이터 필터
|
dataFilter?: any; // 🆕 데이터 필터
|
||||||
|
excludeFilter?: {
|
||||||
|
enabled: boolean;
|
||||||
|
referenceTable: string;
|
||||||
|
referenceColumn: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
filterColumn?: string;
|
||||||
|
filterValue?: any;
|
||||||
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
}
|
}
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
@ -2716,6 +2724,44 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
if (options.excludeFilter && options.excludeFilter.enabled) {
|
||||||
|
const {
|
||||||
|
referenceTable,
|
||||||
|
referenceColumn,
|
||||||
|
sourceColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
} = options.excludeFilter;
|
||||||
|
|
||||||
|
if (referenceTable && referenceColumn && sourceColumn) {
|
||||||
|
// 서브쿼리로 이미 존재하는 데이터 제외
|
||||||
|
let excludeSubquery = `main."${sourceColumn}" NOT IN (
|
||||||
|
SELECT "${referenceColumn}" FROM "${referenceTable}"
|
||||||
|
WHERE "${referenceColumn}" IS NOT NULL`;
|
||||||
|
|
||||||
|
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
|
||||||
|
if (filterColumn && filterValue !== undefined && filterValue !== null) {
|
||||||
|
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
excludeSubquery += ")";
|
||||||
|
|
||||||
|
whereClause = whereClause
|
||||||
|
? `${whereClause} AND ${excludeSubquery}`
|
||||||
|
: excludeSubquery;
|
||||||
|
|
||||||
|
logger.info(`🚫 제외 필터 적용 (Entity 조인):`, {
|
||||||
|
referenceTable,
|
||||||
|
referenceColumn,
|
||||||
|
sourceColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
excludeSubquery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ORDER BY 절 구성
|
// ORDER BY 절 구성
|
||||||
const orderBy = options.sortBy
|
const orderBy = options.sortBy
|
||||||
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export interface TableInfo {
|
||||||
|
|
||||||
// 연결 정보 타입
|
// 연결 정보 타입
|
||||||
export interface ConnectionInfo {
|
export interface ConnectionInfo {
|
||||||
type: 'internal' | 'external';
|
type: "internal" | "external";
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
db_type?: string;
|
db_type?: string;
|
||||||
|
|
@ -52,27 +52,27 @@ export interface BatchMapping {
|
||||||
id?: number;
|
id?: number;
|
||||||
batch_config_id?: number;
|
batch_config_id?: number;
|
||||||
company_code?: string;
|
company_code?: string;
|
||||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
from_connection_type: "internal" | "external" | "restapi";
|
||||||
from_connection_id?: number;
|
from_connection_id?: number;
|
||||||
from_table_name: string;
|
from_table_name: string;
|
||||||
from_column_name: string;
|
from_column_name: string;
|
||||||
from_column_type?: string;
|
from_column_type?: string;
|
||||||
from_api_url?: string;
|
from_api_url?: string;
|
||||||
from_api_key?: string;
|
from_api_key?: string;
|
||||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
from_api_method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
from_api_param_type?: 'url' | 'query';
|
from_api_param_type?: "url" | "query";
|
||||||
from_api_param_name?: string;
|
from_api_param_name?: string;
|
||||||
from_api_param_value?: string;
|
from_api_param_value?: string;
|
||||||
from_api_param_source?: 'static' | 'dynamic';
|
from_api_param_source?: "static" | "dynamic";
|
||||||
from_api_body?: string;
|
from_api_body?: string;
|
||||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
to_connection_type: "internal" | "external" | "restapi";
|
||||||
to_connection_id?: number;
|
to_connection_id?: number;
|
||||||
to_table_name: string;
|
to_table_name: string;
|
||||||
to_column_name: string;
|
to_column_name: string;
|
||||||
to_column_type?: string;
|
to_column_type?: string;
|
||||||
to_api_url?: string;
|
to_api_url?: string;
|
||||||
to_api_key?: string;
|
to_api_key?: string;
|
||||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
to_api_method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
to_api_body?: string;
|
to_api_body?: string;
|
||||||
mapping_order?: number;
|
mapping_order?: number;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
|
|
@ -85,8 +85,12 @@ export interface BatchConfig {
|
||||||
batch_name: string;
|
batch_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cron_schedule: string;
|
cron_schedule: string;
|
||||||
is_active: 'Y' | 'N';
|
is_active: "Y" | "N";
|
||||||
company_code?: string;
|
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_by?: string;
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
updated_by?: string;
|
updated_by?: string;
|
||||||
|
|
@ -95,7 +99,7 @@ export interface BatchConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchConnectionInfo {
|
export interface BatchConnectionInfo {
|
||||||
type: 'internal' | 'external';
|
type: "internal" | "external";
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
db_type?: string;
|
db_type?: string;
|
||||||
|
|
@ -109,38 +113,43 @@ export interface BatchColumnInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchMappingRequest {
|
export interface BatchMappingRequest {
|
||||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
from_connection_type: "internal" | "external" | "restapi" | "fixed";
|
||||||
from_connection_id?: number;
|
from_connection_id?: number;
|
||||||
from_table_name: string;
|
from_table_name: string;
|
||||||
from_column_name: string;
|
from_column_name: string;
|
||||||
from_column_type?: string;
|
from_column_type?: string;
|
||||||
from_api_url?: string;
|
from_api_url?: string;
|
||||||
from_api_key?: string;
|
from_api_key?: string;
|
||||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
from_api_method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
|
from_api_param_type?: "url" | "query"; // API 파라미터 타입
|
||||||
from_api_param_name?: string; // API 파라미터명
|
from_api_param_name?: string; // API 파라미터명
|
||||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
from_api_param_source?: "static" | "dynamic"; // 파라미터 소스 타입
|
||||||
// REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
// REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||||
from_api_body?: string;
|
from_api_body?: string;
|
||||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
to_connection_type: "internal" | "external" | "restapi";
|
||||||
to_connection_id?: number;
|
to_connection_id?: number;
|
||||||
to_table_name: string;
|
to_table_name: string;
|
||||||
to_column_name: string;
|
to_column_name: string;
|
||||||
to_column_type?: string;
|
to_column_type?: string;
|
||||||
to_api_url?: string;
|
to_api_url?: string;
|
||||||
to_api_key?: 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 배치용)
|
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
|
||||||
mapping_order?: number;
|
mapping_order?: number;
|
||||||
|
mapping_type?: "direct" | "fixed"; // 매핑 타입: direct (API 필드) 또는 fixed (고정값)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBatchConfigRequest {
|
export interface CreateBatchConfigRequest {
|
||||||
batchName: string;
|
batchName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
isActive: 'Y' | 'N';
|
isActive: "Y" | "N";
|
||||||
companyCode: string;
|
companyCode: string;
|
||||||
|
saveMode?: "INSERT" | "UPSERT";
|
||||||
|
conflictKey?: string;
|
||||||
|
authServiceName?: string;
|
||||||
|
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
||||||
mappings: BatchMappingRequest[];
|
mappings: BatchMappingRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +157,11 @@ export interface UpdateBatchConfigRequest {
|
||||||
batchName?: string;
|
batchName?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cronSchedule?: string;
|
cronSchedule?: string;
|
||||||
isActive?: 'Y' | 'N';
|
isActive?: "Y" | "N";
|
||||||
|
saveMode?: "INSERT" | "UPSERT";
|
||||||
|
conflictKey?: string;
|
||||||
|
authServiceName?: string;
|
||||||
|
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
||||||
mappings?: BatchMappingRequest[];
|
mappings?: BatchMappingRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: 0.0.0.0
|
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_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||||
JWT_EXPIRES_IN: 24h
|
JWT_EXPIRES_IN: 24h
|
||||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
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
|
|
@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
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(
|
const locationObjects = loadedObjects.filter(
|
||||||
(obj) =>
|
(obj) =>
|
||||||
(obj.type === "location-bed" ||
|
(obj.type === "location-bed" ||
|
||||||
|
|
@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
obj.type === "location-dest") &&
|
obj.type === "location-dest") &&
|
||||||
obj.locaKey,
|
obj.locaKey,
|
||||||
);
|
);
|
||||||
if (locationObjects.length > 0) {
|
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
||||||
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadMaterialCountsForLocations(locaKeys);
|
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
};
|
};
|
||||||
|
|
||||||
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
||||||
const loadMaterialCountsForLocations = async (locaKeys: string[]) => {
|
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
|
||||||
if (!selectedDbConnection || locaKeys.length === 0) return;
|
const connectionId = dbConnectionId || selectedDbConnection;
|
||||||
|
const tableName = materialTableName || selectedTables.material;
|
||||||
|
if (!connectionId || locaKeys.length === 0) return;
|
||||||
|
|
||||||
try {
|
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) {
|
if (response.success && response.data) {
|
||||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||||
setPlacedObjects((prev) =>
|
setPlacedObjects((prev) =>
|
||||||
|
|
@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
) {
|
) {
|
||||||
return obj;
|
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) {
|
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 {
|
return {
|
||||||
...obj,
|
...obj,
|
||||||
materialCount: materialCount.material_count,
|
materialCount: Number(count),
|
||||||
materialPreview: {
|
materialPreview: {
|
||||||
height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적)
|
height: maxLayer * 1.5, // 층당 1.5 높이 (시각적)
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
|
|
||||||
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
||||||
setLayoutName(layout.layout_name || layout.layoutName);
|
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 저장
|
// hierarchy_config 저장
|
||||||
|
let hierarchyConfigData: any = null;
|
||||||
if (layout.hierarchy_config) {
|
if (layout.hierarchy_config) {
|
||||||
const config =
|
hierarchyConfigData =
|
||||||
typeof layout.hierarchy_config === "string"
|
typeof layout.hierarchy_config === "string"
|
||||||
? JSON.parse(layout.hierarchy_config)
|
? JSON.parse(layout.hierarchy_config)
|
||||||
: layout.hierarchy_config;
|
: layout.hierarchy_config;
|
||||||
setHierarchyConfig(config);
|
setHierarchyConfig(hierarchyConfigData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 객체 데이터 변환
|
// 객체 데이터 변환
|
||||||
|
|
@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
});
|
});
|
||||||
|
|
||||||
setPlacedObjects(loadedObjects);
|
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 {
|
} else {
|
||||||
throw new Error(response.error || "레이아웃 조회 실패");
|
throw new Error(response.error || "레이아웃 조회 실패");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Canvas, useThree } from "@react-three/fiber";
|
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 { Suspense, useRef, useState, useEffect, useMemo } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
|
@ -525,68 +525,77 @@ function MaterialBox({
|
||||||
case "location-bed":
|
case "location-bed":
|
||||||
case "location-temp":
|
case "location-temp":
|
||||||
case "location-dest":
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||||
<meshStandardMaterial
|
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
||||||
color={placement.color}
|
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
||||||
roughness={0.5}
|
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
||||||
metalness={0.3}
|
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||||
emissive={isSelected ? placement.color : "#000000"}
|
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 대표 자재 스택 (자재가 있을 때만) */}
|
return (
|
||||||
{placement.material_count !== undefined &&
|
|
||||||
placement.material_count > 0 &&
|
|
||||||
placement.material_preview_height && (
|
|
||||||
<Box
|
<Box
|
||||||
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
|
key={`loc-plate-${idx}`}
|
||||||
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
|
args={[boxWidth, locPlateThickness, boxDepth]}
|
||||||
|
position={[xOffset, yPos, zOffset]}
|
||||||
>
|
>
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color="#ef4444"
|
color="#6b7280" // 회색 (고정)
|
||||||
roughness={0.6}
|
roughness={0.4}
|
||||||
metalness={0.2}
|
metalness={0.7}
|
||||||
emissive={isSelected ? "#ef4444" : "#000000"}
|
emissive={isSelected ? "#9ca3af" : "#000000"}
|
||||||
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
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>
|
</Box>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Location 이름 */}
|
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
||||||
{placement.name && (
|
{placement.name && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||||
color="#ffffff"
|
color="#374151"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
outlineWidth={0.03}
|
outlineWidth={0.03}
|
||||||
outlineColor="#000000"
|
outlineColor="#ffffff"
|
||||||
>
|
>
|
||||||
{placement.name}
|
{placement.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 자재 개수 */}
|
{/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */}
|
||||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
{locPlateCount > 0 && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
position={[0, locYOffset + locVisibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||||
color="#fbbf24"
|
color="#1f2937"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
outlineWidth={0.03}
|
outlineWidth={0.02}
|
||||||
outlineColor="#000000"
|
outlineColor="#ffffff"
|
||||||
>
|
>
|
||||||
{`자재: ${placement.material_count}개`}
|
{`${locPlateCount}장`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -886,83 +895,79 @@ function MaterialBox({
|
||||||
|
|
||||||
case "plate-stack":
|
case "plate-stack":
|
||||||
default:
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||||
<group position={[0, palletYOffset, 0]}>
|
{Array.from({ length: visiblePlateCount }).map((_, idx) => {
|
||||||
{/* 상단 가로 판자들 (5개) */}
|
const yPos = plateBaseY + idx * (plateThickness + plateGap);
|
||||||
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
|
// 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼)
|
||||||
|
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||||
|
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||||
|
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={`top-${idx}`}
|
key={`plate-${idx}`}
|
||||||
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
|
args={[boxWidth, plateThickness, boxDepth]}
|
||||||
position={[0, palletHeight * 0.35, zOffset]}
|
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>
|
<lineSegments>
|
||||||
<edgesGeometry
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, plateThickness, boxDepth)]} />
|
||||||
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]}
|
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
|
||||||
/>
|
|
||||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
|
||||||
</lineSegments>
|
</lineSegments>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* 중간 세로 받침대 (3개) */}
|
{/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */}
|
||||||
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
|
{plateCount > 0 && (
|
||||||
<Box
|
<Text
|
||||||
key={`middle-${idx}`}
|
position={[0, yOffset + visibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||||
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
position={[xOffset, 0, 0]}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||||
>
|
color="#374151"
|
||||||
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
|
anchorX="center"
|
||||||
<lineSegments>
|
anchorY="middle"
|
||||||
<edgesGeometry
|
outlineWidth={0.03}
|
||||||
args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]}
|
outlineColor="#ffffff"
|
||||||
/>
|
>
|
||||||
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
|
{`${plateCount}장`}
|
||||||
</lineSegments>
|
</Text>
|
||||||
</Box>
|
)}
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 하단 가로 판자들 (3개) */}
|
{/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */}
|
||||||
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
|
{placement.material_name && (
|
||||||
<Box
|
<Text
|
||||||
key={`bottom-${idx}`}
|
position={[0, yOffset + visibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||||
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
position={[0, -palletHeight * 0.35, zOffset]}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||||
>
|
color="#1f2937"
|
||||||
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
|
anchorX="center"
|
||||||
<lineSegments>
|
anchorY="middle"
|
||||||
<edgesGeometry
|
outlineWidth={0.02}
|
||||||
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]}
|
outlineColor="#ffffff"
|
||||||
/>
|
>
|
||||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
{placement.material_name}
|
||||||
</lineSegments>
|
</Text>
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1114,20 +1119,11 @@ function Scene({
|
||||||
{/* 배경색 */}
|
{/* 배경색 */}
|
||||||
<color attach="background" args={["#f3f4f6"]} />
|
<color attach="background" args={["#f3f4f6"]} />
|
||||||
|
|
||||||
{/* 바닥 그리드 (타일을 4등분) */}
|
{/* 바닥 - 단색 평면 (그리드 제거) */}
|
||||||
<Grid
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
|
||||||
args={[100, 100]}
|
<planeGeometry args={[200, 200]} />
|
||||||
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
<meshStandardMaterial color="#e5e7eb" roughness={0.9} metalness={0.1} />
|
||||||
cellThickness={0.6}
|
</mesh>
|
||||||
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
|
|
||||||
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
|
||||||
sectionThickness={1.5}
|
|
||||||
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
|
|
||||||
fadeDistance={200}
|
|
||||||
fadeStrength={1}
|
|
||||||
followCamera={false}
|
|
||||||
infiniteGrid={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 자재 박스들 */}
|
{/* 자재 박스들 */}
|
||||||
{placements.map((placement) => (
|
{placements.map((placement) => (
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker),
|
||||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||||
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
|
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
|
||||||
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
|
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
|
||||||
|
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
|
||||||
|
|
||||||
// 브이월드 API 키
|
// 브이월드 API 키
|
||||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||||
|
|
@ -78,6 +79,13 @@ interface PolygonData {
|
||||||
opacity?: number; // 투명도 (0.0 ~ 1.0)
|
opacity?: number; // 투명도 (0.0 ~ 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이동경로 타입
|
||||||
|
interface RoutePoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
recordedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||||
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
|
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
|
||||||
|
|
@ -87,6 +95,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// 이동경로 상태
|
||||||
|
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
|
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식
|
||||||
|
|
||||||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||||
const dataSources = useMemo(() => {
|
const dataSources = useMemo(() => {
|
||||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
|
|
@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
return heading;
|
return heading;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 이동경로 로드 함수
|
||||||
|
const loadRoute = useCallback(async (userId: string, date?: string) => {
|
||||||
|
if (!userId) {
|
||||||
|
console.log("🛣️ 이동경로 조회 불가: userId 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRouteLoading(true);
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 선택한 날짜 기준으로 이동경로 조회
|
||||||
|
const targetDate = date || routeDate;
|
||||||
|
const startOfDay = `${targetDate}T00:00:00.000Z`;
|
||||||
|
const endOfDay = `${targetDate}T23:59:59.999Z`;
|
||||||
|
|
||||||
|
const query = `SELECT latitude, longitude, recorded_at
|
||||||
|
FROM vehicle_location_history
|
||||||
|
WHERE user_id = '${userId}'
|
||||||
|
AND recorded_at >= '${startOfDay}'
|
||||||
|
AND recorded_at <= '${endOfDay}'
|
||||||
|
ORDER BY recorded_at ASC`;
|
||||||
|
|
||||||
|
console.log("🛣️ 이동경로 쿼리:", query);
|
||||||
|
|
||||||
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.data.rows.length > 0) {
|
||||||
|
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
|
||||||
|
lat: parseFloat(row.latitude),
|
||||||
|
lng: parseFloat(row.longitude),
|
||||||
|
recordedAt: row.recorded_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
|
||||||
|
setRoutePoints(points);
|
||||||
|
} else {
|
||||||
|
console.log("🛣️ 이동경로 데이터 없음");
|
||||||
|
setRoutePoints([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("이동경로 로드 실패:", error);
|
||||||
|
setRoutePoints([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRouteLoading(false);
|
||||||
|
}, [routeDate]);
|
||||||
|
|
||||||
|
// 이동경로 숨기기
|
||||||
|
const clearRoute = useCallback(() => {
|
||||||
|
setSelectedUserId(null);
|
||||||
|
setRoutePoints([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 다중 데이터 소스 로딩
|
// 다중 데이터 소스 로딩
|
||||||
const loadMultipleDataSources = useCallback(async () => {
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
if (!dataSources || dataSources.length === 0) {
|
if (!dataSources || dataSources.length === 0) {
|
||||||
|
|
@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||||
|
|
@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 이동경로 날짜 선택 */}
|
||||||
|
{selectedUserId && (
|
||||||
|
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
|
||||||
|
<span className="text-xs text-blue-600">🛣️</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={routeDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRouteDate(e.target.value);
|
||||||
|
if (selectedUserId) {
|
||||||
|
loadRoute(selectedUserId, e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-blue-600">
|
||||||
|
({routePoints.length}개)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearRoute}
|
||||||
|
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1306,6 +1410,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
||||||
const rotation = heading - 90;
|
const rotation = heading - 90;
|
||||||
|
|
||||||
|
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
|
||||||
|
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
|
||||||
|
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||||
|
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
|
||||||
|
const transformStyle = isFlipped
|
||||||
|
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
|
||||||
|
: `translate(-50%, -50%) rotate(${rotation}deg)`;
|
||||||
|
|
||||||
markerIcon = L.divIcon({
|
markerIcon = L.divIcon({
|
||||||
className: "custom-truck-marker",
|
className: "custom-truck-marker",
|
||||||
html: `
|
html: `
|
||||||
|
|
@ -1315,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transform: translate(-50%, -50%) rotate(${rotation}deg);
|
transform: ${transformStyle};
|
||||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||||
">
|
">
|
||||||
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
@ -1528,12 +1640,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 이동경로 버튼 */}
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(marker.description || "{}");
|
||||||
|
const userId = parsed.user_id;
|
||||||
|
if (userId) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 border-t pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => loadRoute(userId)}
|
||||||
|
disabled={routeLoading}
|
||||||
|
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{routeLoading && selectedUserId === userId
|
||||||
|
? "로딩 중..."
|
||||||
|
: "🛣️ 이동경로 보기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 이동경로 Polyline */}
|
||||||
|
{routePoints.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
|
||||||
|
pathOptions={{
|
||||||
|
color: "#3b82f6",
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.8,
|
||||||
|
dashArray: "10, 5",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLa
|
||||||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||||
const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false });
|
const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false });
|
||||||
|
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
|
||||||
|
|
||||||
// 브이월드 API 키
|
// 브이월드 API 키
|
||||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||||
|
|
@ -37,6 +38,16 @@ interface Vehicle {
|
||||||
status: "active" | "inactive" | "maintenance" | "warning" | "off";
|
status: "active" | "inactive" | "maintenance" | "warning" | "off";
|
||||||
speed: number;
|
speed: number;
|
||||||
destination: string;
|
destination: string;
|
||||||
|
userId?: string; // 이동경로 조회용
|
||||||
|
tripId?: string; // 현재 운행 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이동경로 좌표
|
||||||
|
interface RoutePoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
recordedAt: string;
|
||||||
|
speed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VehicleMapOnlyWidgetProps {
|
interface VehicleMapOnlyWidgetProps {
|
||||||
|
|
@ -49,6 +60,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||||
|
|
||||||
|
// 이동경로 상태
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||||
|
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||||
|
const [isRouteLoading, setIsRouteLoading] = useState(false);
|
||||||
|
|
||||||
const loadVehicles = async () => {
|
const loadVehicles = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
|
@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
: "inactive",
|
: "inactive",
|
||||||
speed: parseFloat(row.speed) || 0,
|
speed: parseFloat(row.speed) || 0,
|
||||||
destination: row.destination || "대기 중",
|
destination: row.destination || "대기 중",
|
||||||
|
userId: row.user_id || row.userId || undefined,
|
||||||
|
tripId: row.trip_id || row.tripId || undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// 유효한 위도/경도가 있는 차량만 필터링
|
// 유효한 위도/경도가 있는 차량만 필터링
|
||||||
|
|
@ -140,6 +158,78 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 이동경로 로드 함수
|
||||||
|
const loadRoute = async (vehicle: Vehicle) => {
|
||||||
|
if (!vehicle.userId && !vehicle.tripId) {
|
||||||
|
console.log("🛣️ 이동경로 조회 불가: userId 또는 tripId 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRouteLoading(true);
|
||||||
|
setSelectedVehicle(vehicle);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 오늘 날짜 기준으로 최근 이동경로 조회
|
||||||
|
const today = new Date();
|
||||||
|
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString();
|
||||||
|
|
||||||
|
// trip_id가 있으면 해당 운행만, 없으면 user_id로 오늘 전체 조회
|
||||||
|
let query = "";
|
||||||
|
if (vehicle.tripId) {
|
||||||
|
query = `SELECT latitude, longitude, speed, recorded_at
|
||||||
|
FROM vehicle_location_history
|
||||||
|
WHERE trip_id = '${vehicle.tripId}'
|
||||||
|
ORDER BY recorded_at ASC`;
|
||||||
|
} else if (vehicle.userId) {
|
||||||
|
query = `SELECT latitude, longitude, speed, recorded_at
|
||||||
|
FROM vehicle_location_history
|
||||||
|
WHERE user_id = '${vehicle.userId}'
|
||||||
|
AND recorded_at >= '${startOfDay}'
|
||||||
|
ORDER BY recorded_at ASC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🛣️ 이동경로 쿼리:", query);
|
||||||
|
|
||||||
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.data.rows.length > 0) {
|
||||||
|
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
|
||||||
|
lat: parseFloat(row.latitude),
|
||||||
|
lng: parseFloat(row.longitude),
|
||||||
|
recordedAt: row.recorded_at,
|
||||||
|
speed: row.speed ? parseFloat(row.speed) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
|
||||||
|
setRoutePoints(points);
|
||||||
|
} else {
|
||||||
|
console.log("🛣️ 이동경로 데이터 없음");
|
||||||
|
setRoutePoints([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("이동경로 로드 실패:", error);
|
||||||
|
setRoutePoints([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRouteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이동경로 숨기기
|
||||||
|
const clearRoute = () => {
|
||||||
|
setSelectedVehicle(null);
|
||||||
|
setRoutePoints([]);
|
||||||
|
};
|
||||||
|
|
||||||
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
|
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadVehicles();
|
loadVehicles();
|
||||||
|
|
@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
keepBuffer={2}
|
keepBuffer={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 이동경로 Polyline */}
|
||||||
|
{routePoints.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
|
||||||
|
pathOptions={{
|
||||||
|
color: "#3b82f6",
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.8,
|
||||||
|
dashArray: "10, 5",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 차량 마커 */}
|
{/* 차량 마커 */}
|
||||||
{vehicles.map((vehicle) => (
|
{vehicles.map((vehicle) => (
|
||||||
<React.Fragment key={vehicle.id}>
|
<React.Fragment key={vehicle.id}>
|
||||||
|
|
@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
<div>
|
<div>
|
||||||
<strong>목적지:</strong> {vehicle.destination}
|
<strong>목적지:</strong> {vehicle.destination}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 이동경로 버튼 */}
|
||||||
|
{(vehicle.userId || vehicle.tripId) && (
|
||||||
|
<div className="mt-2 border-t pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => loadRoute(vehicle)}
|
||||||
|
disabled={isRouteLoading}
|
||||||
|
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRouteLoading && selectedVehicle?.id === vehicle.id
|
||||||
|
? "로딩 중..."
|
||||||
|
: "🛣️ 이동경로 보기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|
@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
<div className="text-xs text-foreground">데이터를 연결하세요</div>
|
<div className="text-xs text-foreground">데이터를 연결하세요</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */}
|
||||||
|
{/* {selectedVehicle && routePoints.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 right-2 z-[1000] rounded-lg bg-blue-500/90 p-2 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-xs text-white">
|
||||||
|
<div className="font-semibold">🛣️ {selectedVehicle.name} 이동경로</div>
|
||||||
|
<div>{routePoints.length}개 포인트</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearRoute}
|
||||||
|
className="rounded bg-white/20 px-2 py-1 text-xs text-white hover:bg-white/30"
|
||||||
|
>
|
||||||
|
숨기기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
|
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 { NumberingRuleCard } from "./NumberingRuleCard";
|
||||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,6 +48,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||||
|
|
||||||
|
// 구분자 관련 상태
|
||||||
|
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
|
||||||
|
const [customSeparator, setCustomSeparator] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRules();
|
loadRules();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -87,6 +91,50 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
}
|
}
|
||||||
}, [currentRule, onChange]);
|
}, [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(() => {
|
const handleAddPart = useCallback(() => {
|
||||||
if (!currentRule) return;
|
if (!currentRule) return;
|
||||||
|
|
||||||
|
|
@ -373,7 +421,44 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{currentTableName && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">적용 테이블</Label>
|
<Label className="text-sm font-medium">적용 테이블</Label>
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,24 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 저장 버튼 클릭 시 - UPDATE 액션 실행
|
// 저장 버튼 클릭 시 - 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) {
|
if (!screenData?.screenInfo?.tableName) {
|
||||||
toast.error("테이블 정보가 없습니다.");
|
toast.error("테이블 정보가 없습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1953,6 +1953,139 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 버튼 활성화 조건 설정 */}
|
||||||
|
<div className="mt-4 border-t pt-4">
|
||||||
|
<h5 className="mb-3 text-xs font-medium text-muted-foreground">버튼 활성화 조건</h5>
|
||||||
|
|
||||||
|
{/* 출발지/도착지 필수 체크 */}
|
||||||
|
<div className="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">
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
<strong>사용 예시:</strong>
|
<strong>사용 예시:</strong>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ export interface BatchConfig {
|
||||||
cron_schedule: string;
|
cron_schedule: string;
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
company_code?: string;
|
company_code?: string;
|
||||||
|
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
|
||||||
|
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
||||||
|
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
updated_date?: Date;
|
updated_date?: Date;
|
||||||
|
|
@ -386,6 +389,26 @@ export class BatchAPI {
|
||||||
throw error;
|
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로 정의됨)
|
// BatchJob export 추가 (이미 위에서 interface로 정의됨)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { apiClient } from "./client";
|
||||||
|
|
||||||
// 배치관리 전용 타입 정의
|
// 배치관리 전용 타입 정의
|
||||||
export interface BatchConnectionInfo {
|
export interface BatchConnectionInfo {
|
||||||
type: 'internal' | 'external';
|
type: "internal" | "external";
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
db_type?: string;
|
db_type?: string;
|
||||||
|
|
@ -39,9 +39,7 @@ class BatchManagementAPIClass {
|
||||||
*/
|
*/
|
||||||
static async getAvailableConnections(): Promise<BatchConnectionInfo[]> {
|
static async getAvailableConnections(): Promise<BatchConnectionInfo[]> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(
|
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(`${this.BASE_PATH}/connections`);
|
||||||
`${this.BASE_PATH}/connections`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
|
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
|
||||||
|
|
@ -58,15 +56,15 @@ class BatchManagementAPIClass {
|
||||||
* 특정 커넥션의 테이블 목록 조회
|
* 특정 커넥션의 테이블 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getTablesFromConnection(
|
static async getTablesFromConnection(
|
||||||
connectionType: 'internal' | 'external',
|
connectionType: "internal" | "external",
|
||||||
connectionId?: number
|
connectionId?: number,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||||
if (connectionType === 'external' && connectionId) {
|
if (connectionType === "external" && connectionId) {
|
||||||
url += `/${connectionId}`;
|
url += `/${connectionId}`;
|
||||||
}
|
}
|
||||||
url += '/tables';
|
url += "/tables";
|
||||||
|
|
||||||
const response = await apiClient.get<BatchApiResponse<string[]>>(url);
|
const response = await apiClient.get<BatchApiResponse<string[]>>(url);
|
||||||
|
|
||||||
|
|
@ -85,13 +83,13 @@ class BatchManagementAPIClass {
|
||||||
* 특정 테이블의 컬럼 정보 조회
|
* 특정 테이블의 컬럼 정보 조회
|
||||||
*/
|
*/
|
||||||
static async getTableColumns(
|
static async getTableColumns(
|
||||||
connectionType: 'internal' | 'external',
|
connectionType: "internal" | "external",
|
||||||
tableName: string,
|
tableName: string,
|
||||||
connectionId?: number
|
connectionId?: number,
|
||||||
): Promise<BatchColumnInfo[]> {
|
): Promise<BatchColumnInfo[]> {
|
||||||
try {
|
try {
|
||||||
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||||
if (connectionType === 'external' && connectionId) {
|
if (connectionType === "external" && connectionId) {
|
||||||
url += `/${connectionId}`;
|
url += `/${connectionId}`;
|
||||||
}
|
}
|
||||||
url += `/tables/${encodeURIComponent(tableName)}/columns`;
|
url += `/tables/${encodeURIComponent(tableName)}/columns`;
|
||||||
|
|
@ -120,14 +118,16 @@ class BatchManagementAPIClass {
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||||
paramInfo?: {
|
paramInfo?: {
|
||||||
paramType: 'url' | 'query';
|
paramType: "url" | "query";
|
||||||
paramName: string;
|
paramName: string;
|
||||||
paramValue: string;
|
paramValue: string;
|
||||||
paramSource: 'static' | 'dynamic';
|
paramSource: "static" | "dynamic";
|
||||||
},
|
},
|
||||||
requestBody?: string
|
requestBody?: string,
|
||||||
|
authServiceName?: string, // DB에서 토큰 가져올 서비스명
|
||||||
|
dataArrayPath?: string, // 데이터 배열 경로 (예: response, data.items)
|
||||||
): Promise<{
|
): Promise<{
|
||||||
fields: string[];
|
fields: string[];
|
||||||
samples: any[];
|
samples: any[];
|
||||||
|
|
@ -139,7 +139,7 @@ class BatchManagementAPIClass {
|
||||||
apiKey,
|
apiKey,
|
||||||
endpoint,
|
endpoint,
|
||||||
method,
|
method,
|
||||||
requestBody
|
requestBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 파라미터 정보가 있으면 추가
|
// 파라미터 정보가 있으면 추가
|
||||||
|
|
@ -150,11 +150,23 @@ class BatchManagementAPIClass {
|
||||||
requestData.paramSource = paramInfo.paramSource;
|
requestData.paramSource = paramInfo.paramSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post<BatchApiResponse<{
|
// DB에서 토큰 가져올 서비스명 추가
|
||||||
fields: string[];
|
if (authServiceName) {
|
||||||
samples: any[];
|
requestData.authServiceName = authServiceName;
|
||||||
totalCount: number;
|
}
|
||||||
}>>(`${this.BASE_PATH}/rest-api/preview`, requestData);
|
|
||||||
|
// 데이터 배열 경로 추가
|
||||||
|
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) {
|
if (!response.data.success) {
|
||||||
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");
|
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 배치 저장
|
* REST API 배치 저장
|
||||||
*/
|
*/
|
||||||
|
|
@ -176,15 +206,17 @@ class BatchManagementAPIClass {
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
apiMappings: any[];
|
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 {
|
try {
|
||||||
const response = await apiClient.post<BatchApiResponse<any>>(
|
const response = await apiClient.post<BatchApiResponse<any>>(`${this.BASE_PATH}/rest-api/save`, batchData);
|
||||||
`${this.BASE_PATH}/rest-api/save`, batchData
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
success: response.data.success,
|
success: response.data.success,
|
||||||
message: response.data.message || "",
|
message: response.data.message || "",
|
||||||
data: response.data.data
|
data: response.data.data,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("REST API 배치 저장 오류:", error);
|
console.error("REST API 배치 저장 오류:", error);
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@ export const entityJoinApi = {
|
||||||
}>;
|
}>;
|
||||||
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
|
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
|
||||||
dataFilter?: any; // 🆕 데이터 필터
|
dataFilter?: any; // 🆕 데이터 필터
|
||||||
|
excludeFilter?: {
|
||||||
|
enabled: boolean;
|
||||||
|
referenceTable: string;
|
||||||
|
referenceColumn: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
filterColumn?: string;
|
||||||
|
filterValue?: any;
|
||||||
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||||
|
|
@ -90,6 +98,7 @@ export const entityJoinApi = {
|
||||||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||||
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
||||||
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
||||||
|
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 disabledFields 체크
|
// 🆕 disabledFields 체크 + readonly 체크
|
||||||
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly;
|
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled;
|
||||||
|
const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CategorySelectComponent
|
<CategorySelectComponent
|
||||||
|
|
@ -182,6 +183,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
placeholder={component.componentConfig?.placeholder || "선택하세요"}
|
placeholder={component.componentConfig?.placeholder || "선택하세요"}
|
||||||
required={(component as any).required}
|
required={(component as any).required}
|
||||||
disabled={isFieldDisabled}
|
disabled={isFieldDisabled}
|
||||||
|
readonly={isFieldReadonly}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,26 @@ export function AutocompleteSearchInputComponent({
|
||||||
// config prop 우선, 없으면 개별 prop 사용
|
// config prop 우선, 없으면 개별 prop 사용
|
||||||
const tableName = config?.tableName || propTableName || "";
|
const tableName = config?.tableName || propTableName || "";
|
||||||
const displayField = config?.displayField || propDisplayField || "";
|
const displayField = config?.displayField || propDisplayField || "";
|
||||||
|
const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
|
||||||
|
const displaySeparator = config?.displaySeparator || " → "; // 구분자
|
||||||
const valueField = config?.valueField || propValueField || "";
|
const valueField = config?.valueField || propValueField || "";
|
||||||
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
|
||||||
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
||||||
|
|
||||||
|
// 다중 필드 값을 조합하여 표시 문자열 생성
|
||||||
|
const getDisplayValue = (item: EntitySearchResult): string => {
|
||||||
|
if (displayFields.length > 1) {
|
||||||
|
// 여러 필드를 구분자로 조합
|
||||||
|
const values = displayFields
|
||||||
|
.map((field) => item[field])
|
||||||
|
.filter((v) => v !== null && v !== undefined && v !== "")
|
||||||
|
.map((v) => String(v));
|
||||||
|
return values.join(displaySeparator);
|
||||||
|
}
|
||||||
|
// 단일 필드
|
||||||
|
return item[displayField] || "";
|
||||||
|
};
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||||
|
|
@ -115,7 +131,7 @@ export function AutocompleteSearchInputComponent({
|
||||||
|
|
||||||
const handleSelect = (item: EntitySearchResult) => {
|
const handleSelect = (item: EntitySearchResult) => {
|
||||||
setSelectedData(item);
|
setSelectedData(item);
|
||||||
setInputValue(item[displayField] || "");
|
setInputValue(getDisplayValue(item));
|
||||||
|
|
||||||
console.log("🔍 AutocompleteSearchInput handleSelect:", {
|
console.log("🔍 AutocompleteSearchInput handleSelect:", {
|
||||||
item,
|
item,
|
||||||
|
|
@ -239,7 +255,7 @@ export function AutocompleteSearchInputComponent({
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
|
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
|
||||||
>
|
>
|
||||||
<div className="font-medium">{item[displayField]}</div>
|
<div className="font-medium">{getDisplayValue(item)}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -184,52 +184,118 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. 표시 필드 선택 */}
|
{/* 2. 표시 필드 선택 (다중 선택 가능) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-semibold sm:text-sm">2. 표시 필드 *</Label>
|
<Label className="text-xs font-semibold sm:text-sm">2. 표시 필드 * (여러 개 선택 가능)</Label>
|
||||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
<div className="space-y-2">
|
||||||
<PopoverTrigger asChild>
|
{/* 선택된 필드 표시 */}
|
||||||
<Button
|
{(localConfig.displayFields && localConfig.displayFields.length > 0) ? (
|
||||||
variant="outline"
|
<div className="flex flex-wrap gap-1 rounded-md border p-2 min-h-[40px]">
|
||||||
role="combobox"
|
{localConfig.displayFields.map((fieldName) => {
|
||||||
aria-expanded={openDisplayFieldCombo}
|
const col = sourceTableColumns.find((c) => c.columnName === fieldName);
|
||||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
return (
|
||||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
<span
|
||||||
>
|
key={fieldName}
|
||||||
{localConfig.displayField
|
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs"
|
||||||
? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
>
|
||||||
: isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
|
{col?.displayName || fieldName}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
</PopoverTrigger>
|
onClick={() => {
|
||||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
const newFields = localConfig.displayFields?.filter((f) => f !== fieldName) || [];
|
||||||
<Command>
|
updateConfig({
|
||||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
displayFields: newFields,
|
||||||
<CommandList>
|
displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
|
||||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
});
|
||||||
<CommandGroup>
|
|
||||||
{sourceTableColumns.map((column) => (
|
|
||||||
<CommandItem
|
|
||||||
key={column.columnName}
|
|
||||||
value={column.columnName}
|
|
||||||
onSelect={() => {
|
|
||||||
updateConfig({ displayField: column.columnName });
|
|
||||||
setOpenDisplayFieldCombo(false);
|
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="hover:text-destructive"
|
||||||
>
|
>
|
||||||
<Check className={cn("mr-2 h-4 w-4", localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0")} />
|
<X className="h-3 w-3" />
|
||||||
<div className="flex flex-col">
|
</button>
|
||||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
</span>
|
||||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
);
|
||||||
</div>
|
})}
|
||||||
</CommandItem>
|
</div>
|
||||||
))}
|
) : (
|
||||||
</CommandGroup>
|
<div className="rounded-md border border-dashed p-2 text-center text-xs text-muted-foreground">
|
||||||
</CommandList>
|
아래에서 표시할 필드를 선택하세요
|
||||||
</Command>
|
</div>
|
||||||
</PopoverContent>
|
)}
|
||||||
</Popover>
|
|
||||||
|
{/* 필드 선택 드롭다운 */}
|
||||||
|
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={openDisplayFieldCombo}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||||
|
>
|
||||||
|
{isLoadingSourceColumns ? "로딩 중..." : "필드 추가..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{sourceTableColumns.map((column) => {
|
||||||
|
const isSelected = localConfig.displayFields?.includes(column.columnName);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={column.columnName}
|
||||||
|
value={column.columnName}
|
||||||
|
onSelect={() => {
|
||||||
|
const currentFields = localConfig.displayFields || [];
|
||||||
|
let newFields: string[];
|
||||||
|
if (isSelected) {
|
||||||
|
newFields = currentFields.filter((f) => f !== column.columnName);
|
||||||
|
} else {
|
||||||
|
newFields = [...currentFields, column.columnName];
|
||||||
|
}
|
||||||
|
updateConfig({
|
||||||
|
displayFields: newFields,
|
||||||
|
displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||||
|
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* 구분자 설정 */}
|
||||||
|
{localConfig.displayFields && localConfig.displayFields.length > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs whitespace-nowrap">구분자:</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.displaySeparator || " → "}
|
||||||
|
onChange={(e) => updateConfig({ displaySeparator: e.target.value })}
|
||||||
|
placeholder=" → "
|
||||||
|
className="h-7 w-20 text-xs text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
미리보기: {localConfig.displayFields.map((f) => {
|
||||||
|
const col = sourceTableColumns.find((c) => c.columnName === f);
|
||||||
|
return col?.displayName || f;
|
||||||
|
}).join(localConfig.displaySeparator || " → ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. 저장 대상 테이블 선택 */}
|
{/* 3. 저장 대상 테이블 선택 */}
|
||||||
|
|
@ -419,7 +485,9 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
<strong>외부 테이블:</strong> {localConfig.tableName}
|
<strong>외부 테이블:</strong> {localConfig.tableName}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>표시 필드:</strong> {localConfig.displayField}
|
<strong>표시 필드:</strong> {localConfig.displayFields?.length
|
||||||
|
? localConfig.displayFields.join(localConfig.displaySeparator || " → ")
|
||||||
|
: localConfig.displayField}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>저장 테이블:</strong> {localConfig.targetTable}
|
<strong>저장 테이블:</strong> {localConfig.targetTable}
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,8 @@ export interface AutocompleteSearchInputConfig {
|
||||||
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
||||||
// 저장 대상 테이블 (간소화 버전)
|
// 저장 대상 테이블 (간소화 버전)
|
||||||
targetTable?: string;
|
targetTable?: string;
|
||||||
|
// 🆕 다중 표시 필드 설정 (여러 컬럼 조합)
|
||||||
|
displayFields?: string[]; // 여러 컬럼을 조합하여 표시
|
||||||
|
displaySeparator?: string; // 구분자 (기본값: " - ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
config?: ButtonPrimaryConfig;
|
config?: ButtonPrimaryConfig;
|
||||||
|
|
@ -148,6 +149,149 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
return result;
|
return result;
|
||||||
}, [flowConfig, currentStep, component.id, component.label]);
|
}, [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 [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<{
|
const [pendingAction, setPendingAction] = useState<{
|
||||||
|
|
@ -663,9 +807,29 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||||
|
let effectiveSelectedRowsData = selectedRowsData;
|
||||||
|
if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) {
|
||||||
|
try {
|
||||||
|
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
||||||
|
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
||||||
|
const modalData = dataRegistry[effectiveTableName];
|
||||||
|
if (modalData && modalData.length > 0) {
|
||||||
|
effectiveSelectedRowsData = modalData;
|
||||||
|
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
|
||||||
|
tableName: effectiveTableName,
|
||||||
|
count: modalData.length,
|
||||||
|
data: modalData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("modalDataStore 접근 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||||
const hasDataToDelete =
|
const hasDataToDelete =
|
||||||
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||||
|
|
||||||
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
||||||
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
||||||
|
|
@ -724,9 +888,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
onClose,
|
onClose,
|
||||||
onFlowRefresh, // 플로우 새로고침 콜백 추가
|
onFlowRefresh, // 플로우 새로고침 콜백 추가
|
||||||
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
|
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
|
||||||
// 테이블 선택된 행 정보 추가
|
// 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선)
|
||||||
selectedRows,
|
selectedRows,
|
||||||
selectedRowsData,
|
selectedRowsData: effectiveSelectedRowsData,
|
||||||
// 테이블 정렬 정보 추가
|
// 테이블 정렬 정보 추가
|
||||||
sortBy, // 🆕 정렬 컬럼
|
sortBy, // 🆕 정렬 컬럼
|
||||||
sortOrder, // 🆕 정렬 방향
|
sortOrder, // 🆕 정렬 방향
|
||||||
|
|
@ -857,6 +1021,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화)
|
||||||
|
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading;
|
||||||
|
|
||||||
// 공통 버튼 스타일
|
// 공통 버튼 스타일
|
||||||
const buttonElementStyle: React.CSSProperties = {
|
const buttonElementStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
@ -864,12 +1031,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
minHeight: "40px",
|
minHeight: "40px",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
background: componentConfig.disabled ? "#e5e7eb" : buttonColor,
|
background: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||||
color: componentConfig.disabled ? "#9ca3af" : "white",
|
color: finalDisabled ? "#9ca3af" : "white",
|
||||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
cursor: finalDisabled ? "not-allowed" : "pointer",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -880,7 +1047,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
lineHeight: "1.25",
|
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 제외)
|
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
||||||
...(component.style ? Object.fromEntries(
|
...(component.style ? Object.fromEntries(
|
||||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||||
|
|
@ -905,7 +1072,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 일반 모드: button으로 렌더링
|
// 일반 모드: button으로 렌더링
|
||||||
<button
|
<button
|
||||||
type={componentConfig.actionType || "button"}
|
type={componentConfig.actionType || "button"}
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={finalDisabled}
|
||||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
||||||
style={buttonElementStyle}
|
style={buttonElementStyle}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||||
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
||||||
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
||||||
|
|
||||||
|
// 🆕 범용 폼 모달 컴포넌트
|
||||||
|
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,18 @@ export function ModalRepeaterTableComponent({
|
||||||
|
|
||||||
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
||||||
const columnName = component?.columnName;
|
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 호출 + 납기일 일괄 적용)
|
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
|
||||||
const handleChange = (newData: any[]) => {
|
const handleChange = (newData: any[]) => {
|
||||||
|
|
@ -249,6 +260,9 @@ export function ModalRepeaterTableComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 내부 상태 즉시 업데이트 (UI 즉시 반영) - 일괄 적용된 데이터로 업데이트
|
||||||
|
setLocalValue(processedData);
|
||||||
|
|
||||||
// 기존 onChange 콜백 호출 (호환성)
|
// 기존 onChange 콜백 호출 (호환성)
|
||||||
const externalOnChange = componentConfig?.onChange || propOnChange;
|
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||||
if (externalOnChange) {
|
if (externalOnChange) {
|
||||||
|
|
@ -321,7 +335,7 @@ export function ModalRepeaterTableComponent({
|
||||||
const handleSaveRequest = async (event: Event) => {
|
const handleSaveRequest = async (event: Event) => {
|
||||||
const componentKey = columnName || component?.id || "modal_repeater_data";
|
const componentKey = columnName || component?.id || "modal_repeater_data";
|
||||||
|
|
||||||
if (value.length === 0) {
|
if (localValue.length === 0) {
|
||||||
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
|
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +346,7 @@ export function ModalRepeaterTableComponent({
|
||||||
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
|
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
|
||||||
.map(col => col.field);
|
.map(col => col.field);
|
||||||
|
|
||||||
const filteredData = value.map((item: any) => {
|
const filteredData = localValue.map((item: any) => {
|
||||||
const filtered: Record<string, any> = {};
|
const filtered: Record<string, any> = {};
|
||||||
|
|
||||||
Object.keys(item).forEach((key) => {
|
Object.keys(item).forEach((key) => {
|
||||||
|
|
@ -389,16 +403,16 @@ export function ModalRepeaterTableComponent({
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||||
};
|
};
|
||||||
}, [value, columnName, component?.id, onFormDataChange, targetTable]);
|
}, [localValue, columnName, component?.id, onFormDataChange, targetTable]);
|
||||||
|
|
||||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||||
|
|
||||||
// 초기 데이터에 계산 필드 적용
|
// 초기 데이터에 계산 필드 적용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value.length > 0 && calculationRules.length > 0) {
|
if (localValue.length > 0 && calculationRules.length > 0) {
|
||||||
const calculated = calculateAll(value);
|
const calculated = calculateAll(localValue);
|
||||||
// 값이 실제로 변경된 경우만 업데이트
|
// 값이 실제로 변경된 경우만 업데이트
|
||||||
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
|
if (JSON.stringify(calculated) !== JSON.stringify(localValue)) {
|
||||||
handleChange(calculated);
|
handleChange(calculated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -506,7 +520,7 @@ export function ModalRepeaterTableComponent({
|
||||||
const calculatedItems = calculateAll(mappedItems);
|
const calculatedItems = calculateAll(mappedItems);
|
||||||
|
|
||||||
// 기존 데이터에 추가
|
// 기존 데이터에 추가
|
||||||
const newData = [...value, ...calculatedItems];
|
const newData = [...localValue, ...calculatedItems];
|
||||||
console.log("✅ 최종 데이터:", newData.length, "개 항목");
|
console.log("✅ 최종 데이터:", newData.length, "개 항목");
|
||||||
|
|
||||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||||
|
|
@ -518,7 +532,7 @@ export function ModalRepeaterTableComponent({
|
||||||
const calculatedRow = calculateRow(newRow);
|
const calculatedRow = calculateRow(newRow);
|
||||||
|
|
||||||
// 데이터 업데이트
|
// 데이터 업데이트
|
||||||
const newData = [...value];
|
const newData = [...localValue];
|
||||||
newData[index] = calculatedRow;
|
newData[index] = calculatedRow;
|
||||||
|
|
||||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||||
|
|
@ -526,7 +540,7 @@ export function ModalRepeaterTableComponent({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowDelete = (index: number) => {
|
const handleRowDelete = (index: number) => {
|
||||||
const newData = value.filter((_, i) => i !== index);
|
const newData = localValue.filter((_, i) => i !== index);
|
||||||
|
|
||||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||||
handleChange(newData);
|
handleChange(newData);
|
||||||
|
|
@ -543,7 +557,7 @@ export function ModalRepeaterTableComponent({
|
||||||
{/* 추가 버튼 */}
|
{/* 추가 버튼 */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{value.length > 0 && `${value.length}개 항목`}
|
{localValue.length > 0 && `${localValue.length}개 항목`}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setModalOpen(true)}
|
onClick={() => setModalOpen(true)}
|
||||||
|
|
@ -557,7 +571,7 @@ export function ModalRepeaterTableComponent({
|
||||||
{/* Repeater 테이블 */}
|
{/* Repeater 테이블 */}
|
||||||
<RepeaterTable
|
<RepeaterTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={value}
|
data={localValue}
|
||||||
onDataChange={handleChange}
|
onDataChange={handleChange}
|
||||||
onRowChange={handleRowChange}
|
onRowChange={handleRowChange}
|
||||||
onRowDelete={handleRowDelete}
|
onRowDelete={handleRowDelete}
|
||||||
|
|
@ -573,7 +587,7 @@ export function ModalRepeaterTableComponent({
|
||||||
multiSelect={multiSelect}
|
multiSelect={multiSelect}
|
||||||
filterCondition={filterCondition}
|
filterCondition={filterCondition}
|
||||||
modalTitle={modalTitle}
|
modalTitle={modalTitle}
|
||||||
alreadySelected={value}
|
alreadySelected={localValue}
|
||||||
uniqueField={uniqueField}
|
uniqueField={uniqueField}
|
||||||
onSelect={handleAddItems}
|
onSelect={handleAddItems}
|
||||||
columnLabels={columnLabels}
|
columnLabels={columnLabels}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
menuObjid, // 🆕 메뉴 OBJID
|
menuObjid, // 🆕 메뉴 OBJID
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
// 🆕 읽기전용/비활성화 상태 확인
|
||||||
|
const isReadonly = (component as any).readonly || (props as any).readonly || componentConfig?.readonly || false;
|
||||||
|
const isDisabled = (component as any).disabled || (props as any).disabled || componentConfig?.disabled || false;
|
||||||
|
const isFieldDisabled = isDesignMode || isReadonly || isDisabled;
|
||||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||||
const screenContext = useScreenContextOptional();
|
const screenContext = useScreenContextOptional();
|
||||||
|
|
||||||
|
|
@ -327,7 +331,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (isDesignMode) return;
|
if (isFieldDisabled) return; // 🆕 읽기전용/비활성화 상태에서는 토글 불가
|
||||||
|
|
||||||
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
|
|
@ -425,7 +429,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
value={option.value}
|
value={option.value}
|
||||||
checked={selectedValue === option.value}
|
checked={selectedValue === option.value}
|
||||||
onChange={() => handleOptionSelect(option.value, option.label)}
|
onChange={() => handleOptionSelect(option.value, option.label)}
|
||||||
disabled={isDesignMode}
|
disabled={isFieldDisabled}
|
||||||
className="border-input text-primary focus:ring-ring h-4 w-4"
|
className="border-input text-primary focus:ring-ring h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">{option.label}</span>
|
<span className="text-sm">{option.label}</span>
|
||||||
|
|
@ -456,12 +460,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
placeholder="코드 또는 코드명 입력..."
|
placeholder="코드 또는 코드명 입력..."
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||||
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
readOnly={isDesignMode}
|
readOnly={isFieldDisabled}
|
||||||
|
disabled={isFieldDisabled}
|
||||||
/>
|
/>
|
||||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||||
{filteredOptions.map((option, index) => (
|
{filteredOptions.map((option, index) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -490,13 +496,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||||
!isDesignMode && "hover:border-orange-400",
|
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
isOpen && "border-orange-500",
|
isOpen && "border-orange-500",
|
||||||
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
||||||
>
|
>
|
||||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -508,7 +515,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{isOpen && !isFieldDisabled && (
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||||
{isLoadingCodes ? (
|
{isLoadingCodes ? (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
|
|
@ -538,8 +545,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||||
!isDesignMode && "hover:border-orange-400",
|
!isFieldDisabled && "hover:border-orange-400",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedValues.map((val, idx) => {
|
{selectedValues.map((val, idx) => {
|
||||||
|
|
@ -567,8 +575,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={selectedValues.length > 0 ? "" : placeholder}
|
placeholder={selectedValues.length > 0 ? "" : placeholder}
|
||||||
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
|
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => !isFieldDisabled && setIsOpen(true)}
|
||||||
readOnly={isDesignMode}
|
readOnly={isFieldDisabled}
|
||||||
|
disabled={isFieldDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -589,19 +598,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
if (isFieldDisabled) return;
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => !isFieldDisabled && setIsOpen(true)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||||
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
readOnly={isDesignMode}
|
readOnly={isFieldDisabled}
|
||||||
|
disabled={isFieldDisabled}
|
||||||
/>
|
/>
|
||||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||||
{filteredOptions.map((option, index) => (
|
{filteredOptions.map((option, index) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -632,13 +644,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||||
!isDesignMode && "hover:border-orange-400",
|
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
isOpen && "border-orange-500",
|
isOpen && "border-orange-500",
|
||||||
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
||||||
>
|
>
|
||||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -650,7 +663,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{isOpen && !isFieldDisabled && (
|
||||||
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
|
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -690,12 +703,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||||
!isDesignMode && "hover:border-orange-400",
|
!isFieldDisabled && "hover:border-orange-400",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onClick={() => !isDesignMode && setIsOpen(true)}
|
onClick={() => !isFieldDisabled && setIsOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: isDesignMode ? "none" : "auto",
|
pointerEvents: isFieldDisabled ? "none" : "auto",
|
||||||
height: "100%"
|
height: "100%"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -726,7 +740,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<span className="text-gray-500">{placeholder}</span>
|
<span className="text-gray-500">{placeholder}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{isOpen && !isFieldDisabled && (
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||||
{(isLoadingCodes || isLoadingCategories) ? (
|
{(isLoadingCodes || isLoadingCategories) ? (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
|
|
@ -789,13 +803,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||||
!isDesignMode && "hover:border-orange-400",
|
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
isOpen && "border-orange-500",
|
isOpen && "border-orange-500",
|
||||||
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
||||||
>
|
>
|
||||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -807,7 +822,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{isOpen && !isFieldDisabled && (
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||||
{isLoadingCodes ? (
|
{isLoadingCodes ? (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
|
|
|
||||||
|
|
@ -293,8 +293,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) => {
|
) => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
// 카테고리 매핑이 있는지 확인
|
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
|
||||||
const mapping = categoryMappings[columnName];
|
// 1. 전체 컬럼명 (예: "item_info.material")
|
||||||
|
// 2. 컬럼명만 (예: "material")
|
||||||
|
let mapping = categoryMappings[columnName];
|
||||||
|
|
||||||
|
if (!mapping && columnName.includes(".")) {
|
||||||
|
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도
|
||||||
|
const simpleColumnName = columnName.split(".").pop() || columnName;
|
||||||
|
mapping = categoryMappings[simpleColumnName];
|
||||||
|
}
|
||||||
|
|
||||||
if (mapping && mapping[String(value)]) {
|
if (mapping && mapping[String(value)]) {
|
||||||
const categoryData = mapping[String(value)];
|
const categoryData = mapping[String(value)];
|
||||||
const displayLabel = categoryData.label || String(value);
|
const displayLabel = categoryData.label || String(value);
|
||||||
|
|
@ -690,43 +699,69 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
loadLeftCategoryMappings();
|
loadLeftCategoryMappings();
|
||||||
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
|
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
|
||||||
|
|
||||||
// 우측 테이블 카테고리 매핑 로드
|
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadRightCategoryMappings = async () => {
|
const loadRightCategoryMappings = async () => {
|
||||||
const rightTableName = componentConfig.rightPanel?.tableName;
|
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||||
if (!rightTableName || isDesignMode) return;
|
if (!rightTableName || isDesignMode) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 컬럼 메타 정보 조회
|
|
||||||
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
|
||||||
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
|
||||||
|
|
||||||
if (categoryColumns.length === 0) {
|
|
||||||
setRightCategoryMappings({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 각 카테고리 컬럼에 대한 값 조회
|
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||||
|
|
||||||
for (const col of categoryColumns) {
|
// 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출
|
||||||
const columnName = col.columnName || col.column_name;
|
const rightColumns = componentConfig.rightPanel?.columns || [];
|
||||||
try {
|
const tablesToLoad = new Set<string>([rightTableName]);
|
||||||
const response = await apiClient.get(`/table-categories/${rightTableName}/${columnName}/values`);
|
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
// 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info")
|
||||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
rightColumns.forEach((col: any) => {
|
||||||
response.data.data.forEach((item: any) => {
|
const colName = col.name || col.columnName;
|
||||||
valueMap[item.value_code || item.valueCode] = {
|
if (colName && colName.includes(".")) {
|
||||||
label: item.value_label || item.valueLabel,
|
const joinTableName = colName.split(".")[0];
|
||||||
color: item.color,
|
tablesToLoad.add(joinTableName);
|
||||||
};
|
}
|
||||||
});
|
});
|
||||||
mappings[columnName] = valueMap;
|
|
||||||
console.log(`✅ 우측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
|
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
|
||||||
|
|
||||||
|
// 각 테이블에 대해 카테고리 매핑 로드
|
||||||
|
for (const tableName of tablesToLoad) {
|
||||||
|
try {
|
||||||
|
// 1. 컬럼 메타 정보 조회
|
||||||
|
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||||
|
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
||||||
|
|
||||||
|
// 2. 각 카테고리 컬럼에 대한 값 조회
|
||||||
|
for (const col of categoryColumns) {
|
||||||
|
const columnName = col.columnName || col.column_name;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||||
|
response.data.data.forEach((item: any) => {
|
||||||
|
valueMap[item.value_code || item.valueCode] = {
|
||||||
|
label: item.value_label || item.valueLabel,
|
||||||
|
color: item.color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
|
||||||
|
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
|
||||||
|
mappings[mappingKey] = valueMap;
|
||||||
|
|
||||||
|
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
|
||||||
|
// 기존 매핑이 있으면 병합, 없으면 새로 생성
|
||||||
|
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
|
||||||
|
|
||||||
|
console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap);
|
||||||
|
console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`우측 카테고리 값 조회 실패 [${columnName}]:`, error);
|
console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -737,7 +772,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRightCategoryMappings();
|
loadRightCategoryMappings();
|
||||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
|
||||||
|
|
||||||
// 항목 펼치기/접기 토글
|
// 항목 펼치기/접기 토글
|
||||||
const toggleExpand = useCallback((itemId: any) => {
|
const toggleExpand = useCallback((itemId: any) => {
|
||||||
|
|
@ -2149,9 +2184,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const format = colConfig?.format;
|
const format = colConfig?.format;
|
||||||
const boldValue = colConfig?.bold ?? false;
|
const boldValue = colConfig?.bold ?? false;
|
||||||
|
|
||||||
// 숫자 포맷 적용
|
// 🆕 카테고리 매핑 적용
|
||||||
let displayValue = String(value || "-");
|
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
|
||||||
if (value !== null && value !== undefined && value !== "" && format) {
|
|
||||||
|
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
|
||||||
|
let displayValue: React.ReactNode = formattedValue;
|
||||||
|
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
|
||||||
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
displayValue = numValue.toLocaleString('ko-KR', {
|
displayValue = numValue.toLocaleString('ko-KR', {
|
||||||
|
|
@ -2175,7 +2213,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`}
|
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`}
|
||||||
title={displayValue}
|
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -2240,9 +2277,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const colConfig = rightColumns?.find(c => c.name === key);
|
const colConfig = rightColumns?.find(c => c.name === key);
|
||||||
const format = colConfig?.format;
|
const format = colConfig?.format;
|
||||||
|
|
||||||
// 숫자 포맷 적용
|
// 🆕 카테고리 매핑 적용
|
||||||
let displayValue = String(value);
|
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
|
||||||
if (value !== null && value !== undefined && value !== "" && format) {
|
|
||||||
|
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
|
||||||
|
let displayValue: React.ReactNode = formattedValue;
|
||||||
|
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
|
||||||
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
displayValue = numValue.toLocaleString('ko-KR', {
|
displayValue = numValue.toLocaleString('ko-KR', {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,30 @@ import {
|
||||||
SplitPanelLayout2Config,
|
SplitPanelLayout2Config,
|
||||||
ColumnConfig,
|
ColumnConfig,
|
||||||
DataTransferField,
|
DataTransferField,
|
||||||
|
ActionButtonConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { defaultConfig } from "./config";
|
import { defaultConfig } from "./config";
|
||||||
import { cn } from "@/lib/utils";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
|
||||||
const [rightColumnLabels, setRightColumnLabels] = 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 () => {
|
const loadLeftData = useCallback(async () => {
|
||||||
|
|
@ -233,6 +261,178 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
|
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
|
||||||
}, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]);
|
}, [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) => {
|
const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
|
||||||
if (!tableName) return;
|
if (!tableName) return;
|
||||||
|
|
@ -366,6 +566,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
});
|
});
|
||||||
}, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]);
|
}, [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) => {
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
if (!config.resizable) return;
|
if (!config.resizable) return;
|
||||||
|
|
@ -564,6 +775,10 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
// 우측 패널 카드 렌더링
|
// 우측 패널 카드 렌더링
|
||||||
const renderRightCard = (item: any, index: number) => {
|
const renderRightCard = (item: any, index: number) => {
|
||||||
const displayColumns = config.rightPanel?.displayColumns || [];
|
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 설정에 따라 컬럼 분류
|
||||||
// displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info)
|
// displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info)
|
||||||
|
|
@ -577,72 +792,113 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
return (
|
return (
|
||||||
<Card key={index} className="mb-2 py-0 hover:shadow-md transition-shadow">
|
<Card key={index} className="mb-2 py-0 hover:shadow-md transition-shadow">
|
||||||
<CardContent className="px-4 py-2">
|
<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">
|
<div className="flex-1">
|
||||||
{/* 이름 행 (Name Row) */}
|
{/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */}
|
||||||
{nameRowColumns.length > 0 && (
|
{showLabels ? (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="space-y-1">
|
||||||
{nameRowColumns.map((col, idx) => {
|
{/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
|
||||||
const value = item[col.name];
|
{nameRowColumns.length > 0 && (
|
||||||
if (!value && idx > 0) return null;
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
{nameRowColumns.map((col, idx) => {
|
||||||
// 첫 번째 컬럼은 굵게 표시
|
const value = item[col.name];
|
||||||
if (idx === 0) {
|
if (value === null || value === undefined) return null;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="font-semibold text-lg">
|
<span key={idx} className="flex items-center gap-1">
|
||||||
{formatValue(value, col.format) || "이름 없음"}
|
<span className="text-sm text-muted-foreground">{col.label || col.name}:</span>
|
||||||
</span>
|
<span className="text-sm font-semibold">{formatValue(value, col.format)}</span>
|
||||||
);
|
</span>
|
||||||
}
|
);
|
||||||
// 나머지는 배지 스타일
|
})}
|
||||||
return (
|
</div>
|
||||||
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
|
)}
|
||||||
{formatValue(value, col.format)}
|
{/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
|
||||||
</span>
|
{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>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
// showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만)
|
||||||
{/* 정보 행 (Info Row) */}
|
<div className="space-y-1">
|
||||||
{infoRowColumns.length > 0 && (
|
{/* 이름 행 */}
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-base text-muted-foreground">
|
{nameRowColumns.length > 0 && (
|
||||||
{infoRowColumns.map((col, idx) => {
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
const value = item[col.name];
|
{nameRowColumns.map((col, idx) => {
|
||||||
if (!value) return null;
|
const value = item[col.name];
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
// 아이콘 결정
|
if (idx === 0) {
|
||||||
let icon = null;
|
return (
|
||||||
const colName = col.name.toLowerCase();
|
<span key={idx} className="font-semibold text-base">
|
||||||
if (colName.includes("tel") || colName.includes("phone")) {
|
{formatValue(value, col.format)}
|
||||||
icon = <span className="text-sm">tel</span>;
|
</span>
|
||||||
} else if (colName.includes("email")) {
|
);
|
||||||
icon = <span className="text-sm">@</span>;
|
}
|
||||||
} else if (colName.includes("sabun") || colName.includes("id")) {
|
return (
|
||||||
icon = <span className="text-sm">ID</span>;
|
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
|
||||||
}
|
{formatValue(value, col.format)}
|
||||||
|
</span>
|
||||||
return (
|
);
|
||||||
<span key={idx} className="flex items-center gap-1">
|
})}
|
||||||
{icon}
|
</div>
|
||||||
{formatValue(value, col.format)}
|
)}
|
||||||
</span>
|
{/* 정보 행 */}
|
||||||
);
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
{/* 액션 버튼 (개별 수정/삭제) */}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{config.rightPanel?.showEditButton && (
|
{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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{config.rightPanel?.showDeleteButton && (
|
{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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) {
|
if (isDesignMode) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="p-4 border-b bg-muted/30">
|
<div className="p-4 border-b bg-muted/30">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="font-semibold text-base">
|
<div className="flex items-center gap-3">
|
||||||
{selectedLeftItem
|
<h3 className="font-semibold text-base">
|
||||||
? config.leftPanel?.displayColumns?.[0]
|
{selectedLeftItem
|
||||||
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
|
? config.leftPanel?.displayColumns?.[0]
|
||||||
: config.rightPanel?.title || "상세"
|
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
|
||||||
: config.rightPanel?.title || "상세"}
|
: config.rightPanel?.title || "상세"
|
||||||
</h3>
|
: config.rightPanel?.title || "상세"}
|
||||||
<div className="flex items-center gap-2">
|
</h3>
|
||||||
{selectedLeftItem && (
|
{selectedLeftItem && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{rightData.length}명
|
({rightData.length}건)
|
||||||
</span>
|
</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}>
|
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
{config.rightPanel?.addButtonLabel || "추가"}
|
{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 className="flex items-center justify-center h-full text-muted-foreground text-base">
|
||||||
로딩 중...
|
로딩 중...
|
||||||
</div>
|
</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))}
|
{/* displayMode에 따라 카드 또는 테이블 렌더링 */}
|
||||||
</div>
|
{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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onValueChange={(value) => updateDisplayColumn("left", index, "name", value)}
|
onValueChange={(value) => updateDisplayColumn("left", index, "name", value)}
|
||||||
placeholder="컬럼 선택"
|
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>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">표시 위치</Label>
|
<Label className="text-xs text-muted-foreground">표시 위치</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -707,6 +716,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
||||||
placeholder="컬럼 선택"
|
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>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">표시 위치</Label>
|
<Label className="text-xs text-muted-foreground">표시 위치</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -826,6 +844,254 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; // 검색 대상 컬럼 (단일, 하위 호환성)
|
searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성)
|
||||||
searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수)
|
searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수)
|
||||||
showSearch?: boolean; // 검색 표시 여부
|
showSearch?: boolean; // 검색 표시 여부
|
||||||
showAddButton?: boolean; // 추가 버튼 표시
|
showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성)
|
||||||
addButtonLabel?: string; // 추가 버튼 라벨
|
addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성)
|
||||||
addModalScreenId?: number; // 추가 모달 화면 ID
|
addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성)
|
||||||
showEditButton?: boolean; // 수정 버튼 표시
|
showEditButton?: boolean; // 수정 버튼 표시 (하위 호환성)
|
||||||
showDeleteButton?: boolean; // 삭제 버튼 표시
|
showDeleteButton?: boolean; // 삭제 버튼 표시 (하위 호환성)
|
||||||
displayMode?: "card" | "list"; // 표시 모드
|
editModalScreenId?: number; // 수정 모달 화면 ID
|
||||||
|
displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형)
|
||||||
|
showLabels?: boolean; // 카드 모드에서 라벨 표시 여부 (라벨: 값 형식)
|
||||||
|
showCheckbox?: boolean; // 체크박스 표시 여부 (테이블 모드에서 일괄 선택용)
|
||||||
|
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
|
||||||
|
primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id)
|
||||||
emptyMessage?: string; // 데이터 없을 때 메시지
|
emptyMessage?: string; // 데이터 없을 때 메시지
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,4 +127,3 @@ export interface SplitPanelLayout2Config {
|
||||||
// 동작 설정
|
// 동작 설정
|
||||||
autoLoad?: boolean; // 자동 데이터 로드
|
autoLoad?: boolean; // 자동 데이터 로드
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
config,
|
config,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
formData: propFormData, // 🆕 부모에서 전달받은 formData
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
componentConfig,
|
componentConfig,
|
||||||
onSelectedRowsChange,
|
onSelectedRowsChange,
|
||||||
|
|
@ -1183,13 +1184,74 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// console.log("🔍 [TableList] API 호출 시작", {
|
// 🎯 화면별 엔티티 표시 설정 수집
|
||||||
// tableName: tableConfig.selectedTable,
|
const screenEntityConfigs: Record<string, any> = {};
|
||||||
// page,
|
(tableConfig.columns || [])
|
||||||
// pageSize,
|
.filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
|
||||||
// sortBy,
|
.forEach((col) => {
|
||||||
// sortOrder,
|
screenEntityConfigs[col.columnName] = {
|
||||||
// });
|
displayColumns: col.entityDisplayConfig!.displayColumns,
|
||||||
|
separator: col.entityDisplayConfig!.separator || " - ",
|
||||||
|
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
|
||||||
|
joinTable: col.entityDisplayConfig!.joinTable,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
|
||||||
|
|
||||||
|
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
let excludeFilterParam: any = undefined;
|
||||||
|
if (tableConfig.excludeFilter?.enabled) {
|
||||||
|
const excludeConfig = tableConfig.excludeFilter;
|
||||||
|
let filterValue: any = undefined;
|
||||||
|
|
||||||
|
// 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널)
|
||||||
|
if (excludeConfig.filterColumn && excludeConfig.filterValueField) {
|
||||||
|
const fieldName = excludeConfig.filterValueField;
|
||||||
|
|
||||||
|
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
|
||||||
|
if (propFormData && propFormData[fieldName]) {
|
||||||
|
filterValue = propFormData[fieldName];
|
||||||
|
console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
|
||||||
|
field: fieldName,
|
||||||
|
value: filterValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 2순위: URL 파라미터에서 값 가져오기
|
||||||
|
else if (typeof window !== "undefined") {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
filterValue = urlParams.get(fieldName);
|
||||||
|
if (filterValue) {
|
||||||
|
console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
|
||||||
|
field: fieldName,
|
||||||
|
value: filterValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3순위: 분할 패널 부모 데이터에서 값 가져오기
|
||||||
|
if (!filterValue && splitPanelContext?.selectedLeftData) {
|
||||||
|
filterValue = splitPanelContext.selectedLeftData[fieldName];
|
||||||
|
if (filterValue) {
|
||||||
|
console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
|
||||||
|
field: fieldName,
|
||||||
|
value: filterValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterValue || !excludeConfig.filterColumn) {
|
||||||
|
excludeFilterParam = {
|
||||||
|
enabled: true,
|
||||||
|
referenceTable: excludeConfig.referenceTable,
|
||||||
|
referenceColumn: excludeConfig.referenceColumn,
|
||||||
|
sourceColumn: excludeConfig.sourceColumn,
|
||||||
|
filterColumn: excludeConfig.filterColumn,
|
||||||
|
filterValue: filterValue,
|
||||||
|
};
|
||||||
|
console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||||
|
|
@ -1200,7 +1262,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
search: hasFilters ? filters : undefined,
|
search: hasFilters ? filters : undefined,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||||
|
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
|
||||||
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||||||
|
excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
// 실제 데이터의 item_number만 추출하여 중복 확인
|
// 실제 데이터의 item_number만 추출하여 중복 확인
|
||||||
|
|
@ -1756,33 +1820,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
const formatCellValue = useCallback(
|
const formatCellValue = useCallback(
|
||||||
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
||||||
if (value === null || value === undefined) return "-";
|
// 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
|
||||||
|
// 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
|
||||||
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
|
||||||
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
|
||||||
return rowData.writer_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
|
|
||||||
if (column.entityDisplayConfig && rowData) {
|
if (column.entityDisplayConfig && rowData) {
|
||||||
// displayColumns 또는 selectedColumns 둘 다 체크
|
const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
|
||||||
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
|
|
||||||
const separator = column.entityDisplayConfig.separator;
|
const separator = column.entityDisplayConfig.separator;
|
||||||
|
|
||||||
if (displayColumns && displayColumns.length > 0) {
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
// 선택된 컬럼들의 값을 구분자로 조합
|
// 선택된 컬럼들의 값을 구분자로 조합
|
||||||
const values = displayColumns
|
const values = displayColumns
|
||||||
.map((colName) => {
|
.map((colName: string) => {
|
||||||
const cellValue = rowData[colName];
|
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
||||||
|
let cellValue = rowData[colName];
|
||||||
|
|
||||||
|
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
|
||||||
|
if (cellValue === null || cellValue === undefined) {
|
||||||
|
const joinedKey = `${column.columnName}_${colName}`;
|
||||||
|
cellValue = rowData[joinedKey];
|
||||||
|
}
|
||||||
|
|
||||||
if (cellValue === null || cellValue === undefined) return "";
|
if (cellValue === null || cellValue === undefined) return "";
|
||||||
return String(cellValue);
|
return String(cellValue);
|
||||||
})
|
})
|
||||||
.filter((v) => v !== ""); // 빈 값 제외
|
.filter((v: string) => v !== ""); // 빈 값 제외
|
||||||
|
|
||||||
return values.join(separator || " - ");
|
const result = values.join(separator || " - ");
|
||||||
|
if (result) {
|
||||||
|
return result; // 결과가 있으면 반환
|
||||||
|
}
|
||||||
|
// 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// value가 null/undefined면 "-" 반환
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
||||||
|
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
||||||
|
return rowData.writer_name;
|
||||||
|
}
|
||||||
|
|
||||||
const meta = columnMeta[column.columnName];
|
const meta = columnMeta[column.columnName];
|
||||||
|
|
||||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||||
|
|
@ -1906,12 +1983,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 숫자 타입 포맷팅
|
// 숫자 타입 포맷팅 (천단위 구분자 설정 확인)
|
||||||
if (inputType === "number" || inputType === "decimal") {
|
if (inputType === "number" || inputType === "decimal") {
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
return numValue.toLocaleString("ko-KR");
|
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||||||
|
if (column.thousandSeparator !== false) {
|
||||||
|
return numValue.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
return String(numValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|
@ -1922,7 +2003,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
return numValue.toLocaleString("ko-KR");
|
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||||||
|
if (column.thousandSeparator !== false) {
|
||||||
|
return numValue.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
return String(numValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|
@ -1939,10 +2024,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "-";
|
return "-";
|
||||||
case "number":
|
|
||||||
return typeof value === "number" ? value.toLocaleString() : value;
|
|
||||||
case "currency":
|
case "currency":
|
||||||
return typeof value === "number" ? `₩${value.toLocaleString()}` : value;
|
if (typeof value === "number") {
|
||||||
|
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||||||
|
if (column.thousandSeparator !== false) {
|
||||||
|
return `₩${value.toLocaleString()}`;
|
||||||
|
}
|
||||||
|
return `₩${value}`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return value ? "예" : "아니오";
|
return value ? "예" : "아니오";
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { TableListConfig, ColumnConfig } from "./types";
|
import { TableListConfig, ColumnConfig } from "./types";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react";
|
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
|
@ -73,6 +74,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
|
|
||||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 제외 필터용 참조 테이블 컬럼 목록
|
||||||
|
const [referenceTableColumns, setReferenceTableColumns] = useState<
|
||||||
|
Array<{ columnName: string; dataType: string; label?: string }>
|
||||||
|
>([]);
|
||||||
|
const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false);
|
||||||
|
|
||||||
// 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지)
|
// 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", {
|
// console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", {
|
||||||
|
|
@ -237,6 +244,42 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
fetchEntityJoinColumns();
|
fetchEntityJoinColumns();
|
||||||
}, [config.selectedTable, screenTableName]);
|
}, [config.selectedTable, screenTableName]);
|
||||||
|
|
||||||
|
// 🆕 제외 필터용 참조 테이블 컬럼 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchReferenceColumns = async () => {
|
||||||
|
const refTable = config.excludeFilter?.referenceTable;
|
||||||
|
if (!refTable) {
|
||||||
|
setReferenceTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingReferenceColumns(true);
|
||||||
|
try {
|
||||||
|
console.log("🔗 참조 테이블 컬럼 정보 가져오기:", refTable);
|
||||||
|
const result = await tableManagementApi.getColumnList(refTable);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
// result.data는 { columns: [], total, page, size, totalPages } 형태
|
||||||
|
const columns = result.data.columns || [];
|
||||||
|
setReferenceTableColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name,
|
||||||
|
dataType: col.dataType || col.data_type || "text",
|
||||||
|
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 참조 테이블 컬럼 조회 오류:", error);
|
||||||
|
setReferenceTableColumns([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingReferenceColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReferenceColumns();
|
||||||
|
}, [config.excludeFilter?.referenceTable]);
|
||||||
|
|
||||||
// 🎯 엔티티 컬럼 자동 로드
|
// 🎯 엔티티 컬럼 자동 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig);
|
const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig);
|
||||||
|
|
@ -467,42 +510,22 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
|
|
||||||
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
|
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
|
||||||
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
|
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
|
||||||
if (!column.isEntityJoin || !column.entityJoinInfo) {
|
const configKey = `${column.columnName}`;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// entityDisplayConfig가 없으면 초기화
|
// 이미 로드된 경우 스킵
|
||||||
if (!column.entityDisplayConfig) {
|
if (entityDisplayConfigs[configKey]) return;
|
||||||
// sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서
|
|
||||||
const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName;
|
|
||||||
|
|
||||||
if (!initialSourceTable) {
|
if (!column.isEntityJoin) {
|
||||||
return;
|
// 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제
|
||||||
}
|
setEntityDisplayConfigs((prev) => ({
|
||||||
|
...prev,
|
||||||
const updatedColumns = config.columns?.map((col) => {
|
[configKey]: {
|
||||||
if (col.columnName === column.columnName) {
|
sourceColumns: [],
|
||||||
return {
|
joinColumns: [],
|
||||||
...col,
|
selectedColumns: [],
|
||||||
entityDisplayConfig: {
|
separator: " - ",
|
||||||
displayColumns: [],
|
},
|
||||||
separator: " - ",
|
}));
|
||||||
sourceTable: initialSourceTable,
|
|
||||||
joinTable: "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return col;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (updatedColumns) {
|
|
||||||
handleChange("columns", updatedColumns);
|
|
||||||
// 업데이트된 컬럼으로 다시 시도
|
|
||||||
const updatedColumn = updatedColumns.find((col) => col.columnName === column.columnName);
|
|
||||||
if (updatedColumn) {
|
|
||||||
return loadEntityDisplayConfig(updatedColumn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,32 +535,56 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
// 3. config.selectedTable
|
// 3. config.selectedTable
|
||||||
// 4. screenTableName
|
// 4. screenTableName
|
||||||
const sourceTable =
|
const sourceTable =
|
||||||
column.entityDisplayConfig.sourceTable ||
|
column.entityDisplayConfig?.sourceTable ||
|
||||||
column.entityJoinInfo?.sourceTable ||
|
column.entityJoinInfo?.sourceTable ||
|
||||||
config.selectedTable ||
|
config.selectedTable ||
|
||||||
screenTableName;
|
screenTableName;
|
||||||
|
|
||||||
let joinTable = column.entityDisplayConfig.joinTable;
|
// sourceTable이 비어있으면 빈 상태로 설정
|
||||||
|
|
||||||
// sourceTable이 여전히 비어있으면 에러
|
|
||||||
if (!sourceTable) {
|
if (!sourceTable) {
|
||||||
|
console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName);
|
||||||
|
setEntityDisplayConfigs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[configKey]: {
|
||||||
|
sourceColumns: [],
|
||||||
|
joinColumns: [],
|
||||||
|
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
|
||||||
|
separator: column.entityDisplayConfig?.separator || " - ",
|
||||||
|
},
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!joinTable && sourceTable) {
|
let joinTable = column.entityDisplayConfig?.joinTable;
|
||||||
// joinTable이 없으면 tableTypeApi로 조회해서 설정
|
|
||||||
|
// joinTable이 없으면 tableTypeApi로 조회해서 설정
|
||||||
|
if (!joinTable) {
|
||||||
try {
|
try {
|
||||||
|
console.log("🔍 tableTypeApi로 컬럼 정보 조회:", {
|
||||||
|
tableName: sourceTable,
|
||||||
|
columnName: column.columnName,
|
||||||
|
});
|
||||||
|
|
||||||
const columnList = await tableTypeApi.getColumns(sourceTable);
|
const columnList = await tableTypeApi.getColumns(sourceTable);
|
||||||
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
|
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
|
||||||
|
|
||||||
|
console.log("🔍 컬럼 정보 조회 결과:", {
|
||||||
|
columnInfo: columnInfo,
|
||||||
|
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
|
||||||
|
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
|
||||||
|
});
|
||||||
|
|
||||||
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
|
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
|
||||||
joinTable = columnInfo.reference_table || columnInfo.referenceTable;
|
joinTable = columnInfo.reference_table || columnInfo.referenceTable;
|
||||||
|
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", joinTable);
|
||||||
|
|
||||||
// entityDisplayConfig 업데이트
|
// entityDisplayConfig 업데이트
|
||||||
const updatedConfig = {
|
const updatedConfig = {
|
||||||
...column.entityDisplayConfig,
|
...column.entityDisplayConfig,
|
||||||
sourceTable: sourceTable,
|
sourceTable: sourceTable,
|
||||||
joinTable: joinTable,
|
joinTable: joinTable,
|
||||||
|
displayColumns: column.entityDisplayConfig?.displayColumns || [],
|
||||||
|
separator: column.entityDisplayConfig?.separator || " - ",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 설정 업데이트
|
// 컬럼 설정 업데이트
|
||||||
|
|
@ -553,74 +600,27 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
|
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
|
||||||
console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName });
|
|
||||||
}
|
}
|
||||||
} else if (!joinTable) {
|
|
||||||
console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
|
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
|
||||||
const configKey = `${column.columnName}`;
|
|
||||||
|
|
||||||
// 이미 로드된 경우 스킵
|
|
||||||
if (entityDisplayConfigs[configKey]) return;
|
|
||||||
|
|
||||||
// joinTable이 비어있으면 tableTypeApi로 컬럼 정보를 다시 가져와서 referenceTable 정보를 찾기
|
|
||||||
let actualJoinTable = joinTable;
|
|
||||||
if (!actualJoinTable && sourceTable) {
|
|
||||||
try {
|
|
||||||
console.log("🔍 tableTypeApi로 컬럼 정보 다시 조회:", {
|
|
||||||
tableName: sourceTable,
|
|
||||||
columnName: column.columnName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columnList = await tableTypeApi.getColumns(sourceTable);
|
|
||||||
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
|
|
||||||
|
|
||||||
console.log("🔍 컬럼 정보 조회 결과:", {
|
|
||||||
columnInfo: columnInfo,
|
|
||||||
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
|
|
||||||
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
|
|
||||||
actualJoinTable = columnInfo.reference_table || columnInfo.referenceTable;
|
|
||||||
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", actualJoinTable);
|
|
||||||
|
|
||||||
// entityDisplayConfig 업데이트
|
|
||||||
const updatedConfig = {
|
|
||||||
...column.entityDisplayConfig,
|
|
||||||
joinTable: actualJoinTable,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 설정 업데이트
|
|
||||||
const updatedColumns = config.columns?.map((col) =>
|
|
||||||
col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updatedColumns) {
|
|
||||||
handleChange("columns", updatedColumns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceTable과 joinTable이 모두 있어야 로드
|
|
||||||
if (!sourceTable || !actualJoinTable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 기본 테이블과 조인 테이블의 컬럼 정보를 병렬로 로드
|
// 기본 테이블 컬럼 정보는 항상 로드
|
||||||
const [sourceResult, joinResult] = await Promise.all([
|
const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable);
|
||||||
entityJoinApi.getReferenceTableColumns(sourceTable),
|
|
||||||
entityJoinApi.getReferenceTableColumns(actualJoinTable),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sourceColumns = sourceResult.columns || [];
|
const sourceColumns = sourceResult.columns || [];
|
||||||
const joinColumns = joinResult.columns || [];
|
|
||||||
|
// joinTable이 있으면 조인 테이블 컬럼도 로드
|
||||||
|
let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = [];
|
||||||
|
if (joinTable) {
|
||||||
|
try {
|
||||||
|
const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable);
|
||||||
|
joinColumns = joinResult.columns || [];
|
||||||
|
} catch (joinError) {
|
||||||
|
console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError);
|
||||||
|
// 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setEntityDisplayConfigs((prev) => ({
|
setEntityDisplayConfigs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -633,6 +633,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("엔티티 표시 컬럼 정보 로드 실패:", error);
|
console.error("엔티티 표시 컬럼 정보 로드 실패:", error);
|
||||||
|
// 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제
|
||||||
|
setEntityDisplayConfigs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[configKey]: {
|
||||||
|
sourceColumns: [],
|
||||||
|
joinColumns: [],
|
||||||
|
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
|
||||||
|
separator: column.entityDisplayConfig?.separator || " - ",
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -873,76 +883,95 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
{/* 표시 컬럼 선택 (다중 선택) */}
|
{/* 표시 컬럼 선택 (다중 선택) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">표시할 컬럼 선택</Label>
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||||
<Popover>
|
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
|
||||||
<PopoverTrigger asChild>
|
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
|
||||||
<Button
|
<div className="py-2 text-center text-xs text-gray-400">
|
||||||
variant="outline"
|
표시 가능한 컬럼이 없습니다.
|
||||||
className="h-6 w-full justify-between text-xs"
|
{!column.entityDisplayConfig?.joinTable && (
|
||||||
style={{ fontSize: "12px" }}
|
<p className="mt-1 text-[10px]">
|
||||||
>
|
테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다.
|
||||||
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
</p>
|
||||||
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
)}
|
||||||
: "컬럼 선택"}
|
</div>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
) : (
|
||||||
</Button>
|
<Popover>
|
||||||
</PopoverTrigger>
|
<PopoverTrigger asChild>
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
<Button
|
||||||
<Command>
|
variant="outline"
|
||||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
className="h-6 w-full justify-between text-xs"
|
||||||
<CommandList>
|
style={{ fontSize: "12px" }}
|
||||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
>
|
||||||
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
||||||
<CommandGroup heading={`기본: ${column.entityDisplayConfig?.sourceTable}`}>
|
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
||||||
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
: "컬럼 선택"}
|
||||||
<CommandItem
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
key={`source-${col.columnName}`}
|
</Button>
|
||||||
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
</PopoverTrigger>
|
||||||
className="text-xs"
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
>
|
<Command>
|
||||||
<Check
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
className={cn(
|
<CommandList>
|
||||||
"mr-2 h-4 w-4",
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||||
col.columnName,
|
<CommandGroup heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}>
|
||||||
)
|
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
||||||
? "opacity-100"
|
<CommandItem
|
||||||
: "opacity-0",
|
key={`source-${col.columnName}`}
|
||||||
)}
|
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||||
/>
|
className="text-xs"
|
||||||
{col.displayName}
|
>
|
||||||
</CommandItem>
|
<Check
|
||||||
))}
|
className={cn(
|
||||||
</CommandGroup>
|
"mr-2 h-4 w-4",
|
||||||
)}
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
||||||
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
col.columnName,
|
||||||
<CommandGroup heading={`조인: ${column.entityDisplayConfig?.joinTable}`}>
|
)
|
||||||
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
? "opacity-100"
|
||||||
<CommandItem
|
: "opacity-0",
|
||||||
key={`join-${col.columnName}`}
|
)}
|
||||||
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
/>
|
||||||
className="text-xs"
|
{col.displayName}
|
||||||
>
|
</CommandItem>
|
||||||
<Check
|
))}
|
||||||
className={cn(
|
</CommandGroup>
|
||||||
"mr-2 h-4 w-4",
|
)}
|
||||||
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
||||||
col.columnName,
|
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
|
||||||
)
|
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
||||||
? "opacity-100"
|
<CommandItem
|
||||||
: "opacity-0",
|
key={`join-${col.columnName}`}
|
||||||
)}
|
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||||
/>
|
className="text-xs"
|
||||||
{col.displayName}
|
>
|
||||||
</CommandItem>
|
<Check
|
||||||
))}
|
className={cn(
|
||||||
</CommandGroup>
|
"mr-2 h-4 w-4",
|
||||||
)}
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(
|
||||||
</CommandList>
|
col.columnName,
|
||||||
</Command>
|
)
|
||||||
</PopoverContent>
|
? "opacity-100"
|
||||||
</Popover>
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{col.displayName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 참조 테이블 미설정 안내 */}
|
||||||
|
{!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||||
|
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
|
||||||
|
현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 테이블의 컬럼도 선택할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 선택된 컬럼 미리보기 */}
|
{/* 선택된 컬럼 미리보기 */}
|
||||||
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -1074,86 +1103,111 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
|
|
||||||
{/* 간결한 리스트 형식 컬럼 설정 */}
|
{/* 간결한 리스트 형식 컬럼 설정 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{config.columns?.map((column, index) => (
|
{config.columns?.map((column, index) => {
|
||||||
<div
|
// 해당 컬럼의 input_type 확인
|
||||||
key={column.columnName}
|
const columnInfo = availableColumns.find((col) => col.columnName === column.columnName);
|
||||||
className="hover:bg-muted/30 flex h-6 items-center justify-between rounded border px-2"
|
const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal";
|
||||||
>
|
|
||||||
{/* 컬럼명 */}
|
|
||||||
<span className="flex-1 truncate text-xs" style={{ fontSize: "12px" }}>
|
|
||||||
{availableColumns.find((col) => col.columnName === column.columnName)?.label ||
|
|
||||||
column.displayName ||
|
|
||||||
column.columnName}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div
|
||||||
<Checkbox
|
key={column.columnName}
|
||||||
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
|
className="hover:bg-muted/30 flex items-center justify-between rounded border px-2 py-1"
|
||||||
onCheckedChange={(checked) => {
|
style={{ minHeight: "28px" }}
|
||||||
const currentFilters = config.filter?.filters || [];
|
>
|
||||||
const columnLabel =
|
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
|
||||||
availableColumns.find((col) => col.columnName === column.columnName)?.label ||
|
{/* 컬럼명 */}
|
||||||
column.displayName ||
|
<span className="truncate text-xs" style={{ fontSize: "12px" }}>
|
||||||
column.columnName;
|
{columnInfo?.label || column.displayName || column.columnName}
|
||||||
|
</span>
|
||||||
|
|
||||||
if (checked) {
|
{/* 숫자 타입인 경우 천단위 구분자 설정 */}
|
||||||
// 필터 추가
|
{isNumberType && (
|
||||||
handleChange("filter", {
|
<div className="flex items-center gap-1">
|
||||||
...config.filter,
|
<Checkbox
|
||||||
enabled: true,
|
id={`thousand-sep-${column.columnName}`}
|
||||||
filters: [
|
checked={column.thousandSeparator !== false}
|
||||||
...currentFilters,
|
onCheckedChange={(checked) => {
|
||||||
{
|
updateColumn(column.columnName, { thousandSeparator: checked as boolean });
|
||||||
columnName: column.columnName,
|
}}
|
||||||
label: columnLabel,
|
className="h-3 w-3"
|
||||||
type: "text",
|
/>
|
||||||
},
|
<Label
|
||||||
],
|
htmlFor={`thousand-sep-${column.columnName}`}
|
||||||
});
|
className="text-[10px] text-muted-foreground cursor-pointer"
|
||||||
} else {
|
>
|
||||||
// 필터 제거
|
천단위 구분자
|
||||||
handleChange("filter", {
|
</Label>
|
||||||
...config.filter,
|
</div>
|
||||||
filters: currentFilters.filter((f) => f.columnName !== column.columnName),
|
)}
|
||||||
});
|
</div>
|
||||||
}
|
|
||||||
}}
|
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
|
||||||
className="h-3 w-3"
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
/>
|
<Checkbox
|
||||||
|
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const currentFilters = config.filter?.filters || [];
|
||||||
|
const columnLabel =
|
||||||
|
columnInfo?.label || column.displayName || column.columnName;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 필터 추가
|
||||||
|
handleChange("filter", {
|
||||||
|
...config.filter,
|
||||||
|
enabled: true,
|
||||||
|
filters: [
|
||||||
|
...currentFilters,
|
||||||
|
{
|
||||||
|
columnName: column.columnName,
|
||||||
|
label: columnLabel,
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 필터 제거
|
||||||
|
handleChange("filter", {
|
||||||
|
...config.filter,
|
||||||
|
filters: currentFilters.filter((f) => f.columnName !== column.columnName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 순서 변경 + 삭제 버튼 */}
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => moveColumn(column.columnName, "up")}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => moveColumn(column.columnName, "down")}
|
||||||
|
disabled={index === (config.columns?.length || 0) - 1}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeColumn(column.columnName)}
|
||||||
|
className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{/* 순서 변경 + 삭제 버튼 */}
|
})}
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => moveColumn(column.columnName, "up")}
|
|
||||||
disabled={index === 0}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => moveColumn(column.columnName, "down")}
|
|
||||||
disabled={index === (config.columns?.length || 0) - 1}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<ArrowDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeColumn(column.columnName)}
|
|
||||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1322,6 +1376,298 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 제외 필터 설정 (다른 테이블에 이미 존재하는 데이터 제외) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">제외 필터</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
다른 테이블에 이미 존재하는 데이터를 목록에서 제외합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* 제외 필터 활성화 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="excludeFilter-enabled"
|
||||||
|
checked={config.excludeFilter?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleChange("excludeFilter", {
|
||||||
|
...config.excludeFilter,
|
||||||
|
enabled: checked as boolean,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="excludeFilter-enabled" className="text-xs">
|
||||||
|
제외 필터 활성화
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.excludeFilter?.enabled && (
|
||||||
|
<div className="space-y-3 rounded border p-3">
|
||||||
|
{/* 참조 테이블 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">참조 테이블 (매핑 테이블)</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{config.excludeFilter?.referenceTable || "테이블 선택..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs py-2">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={table.tableName}
|
||||||
|
onSelect={() => {
|
||||||
|
handleChange("excludeFilter", {
|
||||||
|
...config.excludeFilter,
|
||||||
|
referenceTable: table.tableName,
|
||||||
|
referenceColumn: undefined,
|
||||||
|
sourceColumn: undefined,
|
||||||
|
filterColumn: undefined,
|
||||||
|
filterValueField: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.excludeFilter?.referenceTable === table.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.excludeFilter?.referenceTable && (
|
||||||
|
<>
|
||||||
|
{/* 비교 컬럼 설정 - 한 줄에 두 개 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* 참조 컬럼 (매핑 테이블) */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">비교 컬럼 (매핑)</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={loadingReferenceColumns}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{loadingReferenceColumns
|
||||||
|
? "..."
|
||||||
|
: config.excludeFilter?.referenceColumn || "선택"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs py-2">없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{referenceTableColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={col.columnName}
|
||||||
|
onSelect={() => {
|
||||||
|
handleChange("excludeFilter", {
|
||||||
|
...config.excludeFilter,
|
||||||
|
referenceColumn: col.columnName,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.excludeFilter?.referenceColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{col.label || col.columnName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 컬럼 (현재 테이블) */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">비교 컬럼 (현재)</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{config.excludeFilter?.sourceColumn || "선택"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs py-2">없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={col.columnName}
|
||||||
|
onSelect={() => {
|
||||||
|
handleChange("excludeFilter", {
|
||||||
|
...config.excludeFilter,
|
||||||
|
sourceColumn: col.columnName,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.excludeFilter?.sourceColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{col.label || col.columnName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건 필터 - 특정 조건의 데이터만 제외 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">조건 필터 (선택사항)</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-1">
|
||||||
|
특정 조건의 데이터만 제외하려면 설정하세요 (예: 특정 거래처의 품목만)
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* 필터 컬럼 (매핑 테이블) */}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={loadingReferenceColumns}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{loadingReferenceColumns
|
||||||
|
? "..."
|
||||||
|
: config.excludeFilter?.filterColumn
|
||||||
|
? `매핑: ${config.excludeFilter.filterColumn}`
|
||||||
|
: "매핑 테이블 컬럼"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs py-2">없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value=""
|
||||||
|
onSelect={() => {
|
||||||
|
handleChange("excludeFilter", {
|
||||||
|
...config.excludeFilter,
|
||||||
|
filterColumn: undefined,
|
||||||
|
filterValueField: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", !config.excludeFilter?.filterColumn ? "opacity-100" : "opacity-0")} />
|
||||||
|
사용 안함
|
||||||
|
</CommandItem>
|
||||||
|
{referenceTableColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={col.columnName}
|
||||||
|
onSelect={() => {
|
||||||
|
// 필터 컬럼 선택 시 같은 이름의 필드를 자동으로 설정
|
||||||
|
handleChange("excludeFilter", {
|
||||||
|
...config.excludeFilter,
|
||||||
|
filterColumn: col.columnName,
|
||||||
|
filterValueField: col.columnName, // 같은 이름으로 자동 설정
|
||||||
|
filterValueSource: "url",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.excludeFilter?.filterColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{col.label || col.columnName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */}
|
||||||
|
<Input
|
||||||
|
placeholder="예: customer_code"
|
||||||
|
value={config.excludeFilter?.filterValueField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleChange("excludeFilter", {
|
||||||
|
...config.excludeFilter,
|
||||||
|
filterValueField: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!config.excludeFilter?.filterColumn}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 설정 요약 */}
|
||||||
|
{config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && (
|
||||||
|
<div className="rounded bg-muted/50 p-2 text-[10px] text-muted-foreground">
|
||||||
|
<strong>설정 요약:</strong> {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn} 가
|
||||||
|
{" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn} 에
|
||||||
|
{config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && (
|
||||||
|
<> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때)</>
|
||||||
|
)}
|
||||||
|
{" "}이미 있으면 제외
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ export interface ColumnConfig {
|
||||||
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
|
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
|
||||||
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
|
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
|
||||||
|
|
||||||
|
// 숫자 포맷팅 설정
|
||||||
|
thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true)
|
||||||
|
|
||||||
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
|
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
|
||||||
entityDisplayConfig?: {
|
entityDisplayConfig?: {
|
||||||
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
|
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
|
||||||
|
|
@ -182,6 +185,21 @@ export interface LinkedFilterConfig {
|
||||||
enabled?: boolean; // 활성화 여부 (기본: true)
|
enabled?: boolean; // 활성화 여부 (기본: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제외 필터 설정
|
||||||
|
* 다른 테이블에 이미 존재하는 데이터를 제외하고 표시
|
||||||
|
* 예: 거래처에 이미 등록된 품목을 품목 선택 모달에서 제외
|
||||||
|
*/
|
||||||
|
export interface ExcludeFilterConfig {
|
||||||
|
enabled: boolean; // 제외 필터 활성화 여부
|
||||||
|
referenceTable: string; // 참조 테이블 (예: customer_item_mapping)
|
||||||
|
referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id)
|
||||||
|
sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number)
|
||||||
|
filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id)
|
||||||
|
filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url)
|
||||||
|
filterValueField?: string; // 필터 값 필드명 (예: customer_code)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TableList 컴포넌트 설정 타입
|
* TableList 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
|
|
@ -246,6 +264,9 @@ export interface TableListConfig extends ComponentConfig {
|
||||||
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
|
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
|
||||||
linkedFilters?: LinkedFilterConfig[];
|
linkedFilters?: LinkedFilterConfig[];
|
||||||
|
|
||||||
|
// 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
excludeFilter?: ExcludeFilterConfig;
|
||||||
|
|
||||||
// 이벤트 핸들러
|
// 이벤트 핸들러
|
||||||
onRowClick?: (row: any) => void;
|
onRowClick?: (row: any) => void;
|
||||||
onRowDoubleClick?: (row: any) => void;
|
onRowDoubleClick?: (row: any) => void;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||||
|
|
@ -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");
|
||||||
|
};
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1236,8 +1236,13 @@ export class ButtonActionExecutor {
|
||||||
} else {
|
} else {
|
||||||
console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출");
|
console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출");
|
||||||
context.onRefresh?.(); // 테이블 새로고침
|
context.onRefresh?.(); // 테이블 새로고침
|
||||||
|
|
||||||
|
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
console.log("🔄 refreshTable 전역 이벤트 발생");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success(config.successMessage || `${dataToDelete.length}개 항목이 삭제되었습니다.`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1258,6 +1263,12 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
context.onRefresh?.();
|
context.onRefresh?.();
|
||||||
|
|
||||||
|
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)");
|
||||||
|
|
||||||
|
toast.success(config.successMessage || "삭제되었습니다.");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("삭제 오류:", error);
|
console.error("삭제 오류:", error);
|
||||||
|
|
@ -1536,6 +1547,13 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용)
|
||||||
|
const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {};
|
||||||
|
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
|
||||||
|
dataSourceId,
|
||||||
|
parentData,
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
|
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
|
||||||
const modalEvent = new CustomEvent("openScreenModal", {
|
const modalEvent = new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -1544,6 +1562,7 @@ export class ButtonActionExecutor {
|
||||||
description: description,
|
description: description,
|
||||||
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
||||||
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
||||||
|
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3594,6 +3613,112 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed");
|
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 테이블 등)
|
// 상태 변경 (vehicles 테이블 등)
|
||||||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
||||||
const effectiveContext = context.userId ? context : this.trackingContext;
|
const effectiveContext = context.userId ? context : this.trackingContext;
|
||||||
|
|
@ -3643,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도 함께 업데이트
|
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
|
||||||
|
|
@ -4198,6 +4421,28 @@ export class ButtonActionExecutor {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
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) {
|
if (this.emptyVehicleWatchId !== null) {
|
||||||
this.stopEmptyVehicleTracking();
|
this.stopEmptyVehicleTracking();
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"next": "15.4.4",
|
"next": "^15.4.8",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
|
|
@ -1145,9 +1145,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz",
|
||||||
"integrity": "sha512-SJKOOkULKENyHSYXE5+KiFU6itcIb6wSBjgM92meK0HVKpo94dNOLZVdLLuS7/BxImROkGoPsjR4EnuDucqiiA==",
|
"integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
|
|
@ -1161,9 +1161,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.8.tgz",
|
||||||
"integrity": "sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==",
|
"integrity": "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1177,9 +1177,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.8.tgz",
|
||||||
"integrity": "sha512-zqG+/8apsu49CltEj4NAmCGZvHcZbOOOsNoTVeIXphYWIbE4l6A/vuQHyqll0flU2o3dmYCXsBW5FmbrGDgljQ==",
|
"integrity": "sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1193,9 +1193,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.8.tgz",
|
||||||
"integrity": "sha512-LRD4l2lq4R+2QCHBQVC0wjxxkLlALGJCwigaJ5FSRSqnje+MRKHljQNZgDCaKUZQzO/TXxlmUdkZP/X3KNGZaw==",
|
"integrity": "sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1209,9 +1209,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.8.tgz",
|
||||||
"integrity": "sha512-LsGUCTvuZ0690fFWerA4lnQvjkYg9gHo12A3wiPUR4kCxbx/d+SlwmonuTH2SWZI+RVGA9VL3N0S03WTYv6bYg==",
|
"integrity": "sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1225,9 +1225,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.8.tgz",
|
||||||
"integrity": "sha512-aOy5yNRpLL3wNiJVkFYl6w22hdREERNjvegE6vvtix8LHRdsTHhWTpgvcYdCK7AIDCQW5ATmzr9XkPHvSoAnvg==",
|
"integrity": "sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1241,9 +1241,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.8.tgz",
|
||||||
"integrity": "sha512-FL7OAn4UkR8hKQRGBmlHiHinzOb07tsfARdGh7v0Z0jEJ3sz8/7L5bR23ble9E6DZMabSStqlATHlSxv1fuzAg==",
|
"integrity": "sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1257,9 +1257,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.8.tgz",
|
||||||
"integrity": "sha512-eEdNW/TXwjYhOulQh0pffTMMItWVwKCQpbziSBmgBNFZIIRn2GTXrhrewevs8wP8KXWYMx8Z+mNU0X+AfvtrRg==",
|
"integrity": "sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1273,9 +1273,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.8.tgz",
|
||||||
"integrity": "sha512-SE5pYNbn/xZKMy1RE3pAs+4xD32OI4rY6mzJa4XUkp/ItZY+OMjIgilskmErt8ls/fVJ+Ihopi2QIeW6O3TrMw==",
|
"integrity": "sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -10876,12 +10876,12 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.4.4",
|
"version": "15.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz",
|
||||||
"integrity": "sha512-kNcubvJjOL9yUOfwtZF3HfDhuhp+kVD+FM2A6Tyua1eI/xfmY4r/8ZS913MMz+oWKDlbps/dQOWdDricuIkXLw==",
|
"integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.4.4",
|
"@next/env": "15.4.8",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
|
|
@ -10894,14 +10894,14 @@
|
||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.4.4",
|
"@next/swc-darwin-arm64": "15.4.8",
|
||||||
"@next/swc-darwin-x64": "15.4.4",
|
"@next/swc-darwin-x64": "15.4.8",
|
||||||
"@next/swc-linux-arm64-gnu": "15.4.4",
|
"@next/swc-linux-arm64-gnu": "15.4.8",
|
||||||
"@next/swc-linux-arm64-musl": "15.4.4",
|
"@next/swc-linux-arm64-musl": "15.4.8",
|
||||||
"@next/swc-linux-x64-gnu": "15.4.4",
|
"@next/swc-linux-x64-gnu": "15.4.8",
|
||||||
"@next/swc-linux-x64-musl": "15.4.4",
|
"@next/swc-linux-x64-musl": "15.4.8",
|
||||||
"@next/swc-win32-arm64-msvc": "15.4.4",
|
"@next/swc-win32-arm64-msvc": "15.4.8",
|
||||||
"@next/swc-win32-x64-msvc": "15.4.4",
|
"@next/swc-win32-x64-msvc": "15.4.8",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"next": "15.4.4",
|
"next": "^15.4.8",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
|
|
|
||||||
|
|
@ -123,3 +123,24 @@ export const RESET_PERIOD_OPTIONS: Array<{
|
||||||
{ value: "monthly", label: "월별 초기화" },
|
{ value: "monthly", label: "월별 초기화" },
|
||||||
{ value: "yearly", 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: "" },
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -1679,3 +1679,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
|
|
||||||
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -526,3 +526,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -513,3 +513,4 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue