Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
6f62435aa2
|
|
@ -42,6 +42,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/bwip-js": "^3.2.3",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
@ -3214,6 +3215,16 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bwip-js": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/compression": {
|
"node_modules/@types/compression": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/bwip-js": "^3.2.3",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ export const deleteFormData = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName } = req.body;
|
const { tableName, screenId } = req.body;
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -240,7 +240,16 @@ export const deleteFormData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
||||||
|
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
||||||
|
|
||||||
|
await dynamicFormService.deleteFormData(
|
||||||
|
id,
|
||||||
|
tableName,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
parsedScreenId // screenId 추가 (제어관리 실행용)
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export class EntityJoinController {
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||||
|
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -139,6 +140,24 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 중복 제거 설정 처리
|
||||||
|
let parsedDeduplication: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
} | undefined = undefined;
|
||||||
|
if (deduplication) {
|
||||||
|
try {
|
||||||
|
parsedDeduplication =
|
||||||
|
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
|
||||||
|
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("중복 제거 설정 파싱 오류:", error);
|
||||||
|
parsedDeduplication = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -156,13 +175,26 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||||
|
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 중복 제거 처리 (결과 데이터에 적용)
|
||||||
|
let finalData = result;
|
||||||
|
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
|
||||||
|
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
|
||||||
|
const originalCount = result.data.length;
|
||||||
|
finalData = {
|
||||||
|
...result,
|
||||||
|
data: this.deduplicateData(result.data, parsedDeduplication),
|
||||||
|
};
|
||||||
|
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Entity 조인 데이터 조회 성공",
|
message: "Entity 조인 데이터 조회 성공",
|
||||||
data: result,
|
data: finalData,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Entity 조인 데이터 조회 실패", error);
|
logger.error("Entity 조인 데이터 조회 실패", error);
|
||||||
|
|
@ -537,6 +569,98 @@ export class EntityJoinController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 데이터 제거 (메모리 내 처리)
|
||||||
|
*/
|
||||||
|
private deduplicateData(
|
||||||
|
data: any[],
|
||||||
|
config: {
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
|
): any[] {
|
||||||
|
if (!data || data.length === 0) return data;
|
||||||
|
|
||||||
|
// 그룹별로 데이터 분류
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const groupKey = row[config.groupByColumn];
|
||||||
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = [];
|
||||||
|
}
|
||||||
|
groups[groupKey].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 그룹에서 하나의 행만 선택
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
|
let selectedRow: any;
|
||||||
|
|
||||||
|
switch (config.keepStrategy) {
|
||||||
|
case "latest":
|
||||||
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal > bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "earliest":
|
||||||
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "base_price":
|
||||||
|
// base_price가 true인 행 선택
|
||||||
|
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "current_date":
|
||||||
|
// 오늘 날짜 기준 유효 기간 내 행 선택
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
selectedRow = rows.find((r) => {
|
||||||
|
const startDate = r.start_date;
|
||||||
|
const endDate = r.end_date;
|
||||||
|
if (!startDate) return true;
|
||||||
|
if (startDate <= today && (!endDate || endDate >= today)) return true;
|
||||||
|
return false;
|
||||||
|
}) || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
selectedRow = rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRow) {
|
||||||
|
result.push(selectedRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entityJoinController = new EntityJoinController();
|
export const entityJoinController = new EntityJoinController();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,262 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { dataService } from "../services/dataService";
|
import { dataService } from "../services/dataService";
|
||||||
|
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 마스터-디테일 엑셀 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조회
|
||||||
|
* GET /api/data/master-detail/relation/:screenId
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/master-detail/relation/:screenId",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
|
||||||
|
if (!screenId || isNaN(parseInt(screenId))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효한 screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
||||||
|
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
||||||
|
masterTable: relation.masterTable,
|
||||||
|
detailTable: relation.detailTable,
|
||||||
|
joinKey: relation.masterKeyColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: relation,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 관계 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||||
|
* POST /api/data/master-detail/download
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/download",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, filters } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!screenId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JOIN 데이터 조회
|
||||||
|
const data = await masterDetailExcelService.getJoinedData(
|
||||||
|
relation,
|
||||||
|
companyCode,
|
||||||
|
filters
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 다운로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 업로드
|
||||||
|
* POST /api/data/master-detail/upload
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/upload",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, data } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!screenId || !data || !Array.isArray(data)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId와 data 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 데이터 업로드
|
||||||
|
const result = await masterDetailExcelService.uploadJoinedData(
|
||||||
|
relation,
|
||||||
|
data,
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
masterUpdated: result.masterUpdated,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
message: result.success
|
||||||
|
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||||
|
: "업로드 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 업로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 엑셀 업로드
|
||||||
|
* - 마스터 정보는 UI에서 선택
|
||||||
|
* - 디테일 정보만 엑셀에서 업로드
|
||||||
|
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
*
|
||||||
|
* POST /api/data/master-detail/upload-simple
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/upload-simple",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
if (!screenId || !detailData || !Array.isArray(detailData)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId와 detailData 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
||||||
|
console.log(` 마스터 필드 값:`, masterFieldValues);
|
||||||
|
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
||||||
|
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
||||||
|
|
||||||
|
// 업로드 실행
|
||||||
|
const result = await masterDetailExcelService.uploadSimple(
|
||||||
|
parseInt(screenId),
|
||||||
|
detailData,
|
||||||
|
masterFieldValues || {},
|
||||||
|
numberingRuleId,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
||||||
|
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
generatedKey: result.generatedKey,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
message: result.success
|
||||||
|
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||||
|
: "업로드 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 기존 데이터 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||||
|
|
|
||||||
|
|
@ -1192,12 +1192,18 @@ export class DynamicFormService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||||
|
* @param id 삭제할 레코드 ID
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param companyCode 회사 코드
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
||||||
*/
|
*/
|
||||||
async deleteFormData(
|
async deleteFormData(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
companyCode?: string,
|
companyCode?: string,
|
||||||
userId?: string
|
userId?: string,
|
||||||
|
screenId?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||||
|
|
@ -1310,14 +1316,19 @@ export class DynamicFormService {
|
||||||
const recordCompanyCode =
|
const recordCompanyCode =
|
||||||
deletedRecord?.company_code || companyCode || "*";
|
deletedRecord?.company_code || companyCode || "*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
||||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
if (screenId && screenId > 0) {
|
||||||
tableName,
|
await this.executeDataflowControlIfConfigured(
|
||||||
deletedRecord,
|
screenId,
|
||||||
"delete",
|
tableName,
|
||||||
userId || "system",
|
deletedRecord,
|
||||||
recordCompanyCode
|
"delete",
|
||||||
);
|
userId || "system",
|
||||||
|
recordCompanyCode
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1662,10 +1673,16 @@ export class DynamicFormService {
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
||||||
|
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
||||||
|
const buttonActionType = properties?.componentConfig?.action?.type;
|
||||||
|
const isMatchingAction =
|
||||||
|
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||||
|
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
properties?.componentType === "button-primary" &&
|
||||||
properties?.componentConfig?.action?.type === "save" &&
|
isMatchingAction &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,868 @@
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 처리 서비스
|
||||||
|
*
|
||||||
|
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||||||
|
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 인터페이스 정의
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보
|
||||||
|
*/
|
||||||
|
export interface MasterDetailRelation {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
||||||
|
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
||||||
|
masterColumns: ColumnInfo[];
|
||||||
|
detailColumns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 정보
|
||||||
|
*/
|
||||||
|
export interface ColumnInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
inputType: string;
|
||||||
|
isFromMaster: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 설정
|
||||||
|
*/
|
||||||
|
export interface SplitPanelConfig {
|
||||||
|
leftPanel: {
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||||||
|
};
|
||||||
|
rightPanel: {
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||||||
|
relation?: {
|
||||||
|
type: string;
|
||||||
|
foreignKey: string;
|
||||||
|
leftColumn: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 다운로드 결과
|
||||||
|
*/
|
||||||
|
export interface ExcelDownloadData {
|
||||||
|
headers: string[]; // 컬럼 라벨들
|
||||||
|
columns: string[]; // 컬럼명들
|
||||||
|
data: Record<string, any>[];
|
||||||
|
masterColumns: string[]; // 마스터 컬럼 목록
|
||||||
|
detailColumns: string[]; // 디테일 컬럼 목록
|
||||||
|
joinKey: string; // 조인 키
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 업로드 결과
|
||||||
|
*/
|
||||||
|
export interface ExcelUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
masterUpdated: number;
|
||||||
|
detailInserted: number;
|
||||||
|
detailDeleted: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 서비스 클래스
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
class MasterDetailExcelService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 ID로 분할 패널 설정 조회
|
||||||
|
*/
|
||||||
|
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
||||||
|
try {
|
||||||
|
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
||||||
|
const result = await queryOne<any>(
|
||||||
|
`SELECT properties->>'componentConfig' as config
|
||||||
|
FROM screen_layouts
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND component_type = 'component'
|
||||||
|
AND properties->>'componentType' = 'split-panel-layout'
|
||||||
|
LIMIT 1`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result || !result.config) {
|
||||||
|
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = typeof result.config === "string"
|
||||||
|
? JSON.parse(result.config)
|
||||||
|
: result.config;
|
||||||
|
|
||||||
|
logger.info(`분할 패널 설정 발견:`, {
|
||||||
|
leftTable: config.leftPanel?.tableName,
|
||||||
|
rightTable: config.rightPanel?.tableName,
|
||||||
|
relation: config.rightPanel?.relation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftPanel: config.leftPanel,
|
||||||
|
rightPanel: config.rightPanel,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* column_labels에서 Entity 관계 정보 조회
|
||||||
|
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
||||||
|
*/
|
||||||
|
async getEntityRelation(
|
||||||
|
detailTable: string,
|
||||||
|
masterTable: string
|
||||||
|
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
||||||
|
try {
|
||||||
|
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
||||||
|
|
||||||
|
const result = await queryOne<any>(
|
||||||
|
`SELECT column_name, reference_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type = 'entity'
|
||||||
|
AND reference_table = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[detailTable, masterTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailFkColumn: result.column_name,
|
||||||
|
masterKeyColumn: result.reference_column,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블의 컬럼 라벨 정보 조회
|
||||||
|
*/
|
||||||
|
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
||||||
|
try {
|
||||||
|
const result = await query<any>(
|
||||||
|
`SELECT column_name, column_label
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelMap = new Map<string, string>();
|
||||||
|
for (const row of result) {
|
||||||
|
labelMap.set(row.column_name, row.column_label || row.column_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelMap;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조합
|
||||||
|
*/
|
||||||
|
async getMasterDetailRelation(
|
||||||
|
screenId: number
|
||||||
|
): Promise<MasterDetailRelation | null> {
|
||||||
|
try {
|
||||||
|
// 1. 분할 패널 설정 조회
|
||||||
|
const splitPanel = await this.getSplitPanelConfig(screenId);
|
||||||
|
if (!splitPanel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterTable = splitPanel.leftPanel.tableName;
|
||||||
|
const detailTable = splitPanel.rightPanel.tableName;
|
||||||
|
|
||||||
|
if (!masterTable || !detailTable) {
|
||||||
|
logger.warn("마스터 또는 디테일 테이블명 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
||||||
|
let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||||
|
let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||||||
|
|
||||||
|
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
||||||
|
if (!masterKeyColumn || !detailFkColumn) {
|
||||||
|
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
||||||
|
if (entityRelation) {
|
||||||
|
masterKeyColumn = entityRelation.masterKeyColumn;
|
||||||
|
detailFkColumn = entityRelation.detailFkColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!masterKeyColumn || !detailFkColumn) {
|
||||||
|
logger.warn("조인 키 정보를 찾을 수 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 컬럼 라벨 정보 조회
|
||||||
|
const masterLabels = await this.getColumnLabels(masterTable);
|
||||||
|
const detailLabels = await this.getColumnLabels(detailTable);
|
||||||
|
|
||||||
|
// 5. 마스터 컬럼 정보 구성
|
||||||
|
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
||||||
|
name: col.name,
|
||||||
|
label: masterLabels.get(col.name) || col.label || col.name,
|
||||||
|
inputType: "text",
|
||||||
|
isFromMaster: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
||||||
|
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
||||||
|
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
||||||
|
.map(col => ({
|
||||||
|
name: col.name,
|
||||||
|
label: detailLabels.get(col.name) || col.label || col.name,
|
||||||
|
inputType: "text",
|
||||||
|
isFromMaster: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
||||||
|
masterTable,
|
||||||
|
detailTable,
|
||||||
|
masterKeyColumn,
|
||||||
|
detailFkColumn,
|
||||||
|
masterColumnCount: masterColumns.length,
|
||||||
|
detailColumnCount: detailColumns.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
masterTable,
|
||||||
|
detailTable,
|
||||||
|
masterKeyColumn,
|
||||||
|
detailFkColumn,
|
||||||
|
masterColumns,
|
||||||
|
detailColumns,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
||||||
|
*/
|
||||||
|
async getJoinedData(
|
||||||
|
relation: MasterDetailRelation,
|
||||||
|
companyCode: string,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<ExcelDownloadData> {
|
||||||
|
try {
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||||
|
|
||||||
|
// 조인 컬럼과 일반 컬럼 분리
|
||||||
|
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
||||||
|
const entityJoins: Array<{
|
||||||
|
refTable: string;
|
||||||
|
refColumn: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
alias: string;
|
||||||
|
displayColumn: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// SELECT 절 구성
|
||||||
|
const selectParts: string[] = [];
|
||||||
|
let aliasIndex = 0;
|
||||||
|
|
||||||
|
// 마스터 컬럼 처리
|
||||||
|
for (const col of masterColumns) {
|
||||||
|
if (col.name.includes(".")) {
|
||||||
|
// 조인 컬럼: 테이블명.컬럼명
|
||||||
|
const [refTable, displayColumn] = col.name.split(".");
|
||||||
|
const alias = `ej${aliasIndex++}`;
|
||||||
|
|
||||||
|
// column_labels에서 FK 컬럼 찾기
|
||||||
|
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
||||||
|
if (fkColumn) {
|
||||||
|
entityJoins.push({
|
||||||
|
refTable,
|
||||||
|
refColumn: fkColumn.referenceColumn,
|
||||||
|
sourceColumn: fkColumn.sourceColumn,
|
||||||
|
alias,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||||
|
} else {
|
||||||
|
// FK를 못 찾으면 NULL로 처리
|
||||||
|
selectParts.push(`NULL AS "${col.name}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼
|
||||||
|
selectParts.push(`m."${col.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 컬럼 처리
|
||||||
|
for (const col of detailColumns) {
|
||||||
|
if (col.name.includes(".")) {
|
||||||
|
// 조인 컬럼: 테이블명.컬럼명
|
||||||
|
const [refTable, displayColumn] = col.name.split(".");
|
||||||
|
const alias = `ej${aliasIndex++}`;
|
||||||
|
|
||||||
|
// column_labels에서 FK 컬럼 찾기
|
||||||
|
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
||||||
|
if (fkColumn) {
|
||||||
|
entityJoins.push({
|
||||||
|
refTable,
|
||||||
|
refColumn: fkColumn.referenceColumn,
|
||||||
|
sourceColumn: fkColumn.sourceColumn,
|
||||||
|
alias,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||||
|
} else {
|
||||||
|
selectParts.push(`NULL AS "${col.name}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼
|
||||||
|
selectParts.push(`d."${col.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClause = selectParts.join(", ");
|
||||||
|
|
||||||
|
// 엔티티 조인 절 구성
|
||||||
|
const entityJoinClauses = entityJoins.map(ej =>
|
||||||
|
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||||
|
).join("\n ");
|
||||||
|
|
||||||
|
// WHERE 절 구성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 코드 필터 (최고 관리자 제외)
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
whereConditions.push(`m.company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 필터 적용
|
||||||
|
if (filters) {
|
||||||
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
// 조인 컬럼인지 확인
|
||||||
|
if (key.includes(".")) continue;
|
||||||
|
// 마스터 테이블 컬럼인지 확인
|
||||||
|
const isMasterCol = masterColumns.some(c => c.name === key);
|
||||||
|
const tableAlias = isMasterCol ? "m" : "d";
|
||||||
|
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// JOIN 쿼리 실행
|
||||||
|
const sql = `
|
||||||
|
SELECT ${selectClause}
|
||||||
|
FROM "${masterTable}" m
|
||||||
|
LEFT JOIN "${detailTable}" d
|
||||||
|
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
||||||
|
AND m.company_code = d.company_code
|
||||||
|
${entityJoinClauses}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY m."${masterKeyColumn}", d.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||||
|
|
||||||
|
const data = await query<any>(sql, params);
|
||||||
|
|
||||||
|
// 헤더 및 컬럼 정보 구성
|
||||||
|
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
||||||
|
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
masterColumns: masterColumns.map(c => c.name),
|
||||||
|
detailColumns: detailColumns.map(c => c.name),
|
||||||
|
joinKey: masterKeyColumn,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
||||||
|
*/
|
||||||
|
private async findForeignKeyColumn(
|
||||||
|
sourceTable: string,
|
||||||
|
referenceTable: string
|
||||||
|
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
||||||
|
try {
|
||||||
|
const result = await query<{ column_name: string; reference_column: string }>(
|
||||||
|
`SELECT column_name, reference_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND reference_table = $2
|
||||||
|
AND input_type = 'entity'
|
||||||
|
LIMIT 1`,
|
||||||
|
[sourceTable, referenceTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
return {
|
||||||
|
sourceColumn: result[0].column_name,
|
||||||
|
referenceColumn: result[0].reference_column,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||||
|
*
|
||||||
|
* 처리 로직:
|
||||||
|
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||||
|
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||||
|
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||||
|
* 4. 새 디테일 데이터 INSERT
|
||||||
|
*/
|
||||||
|
async uploadJoinedData(
|
||||||
|
relation: MasterDetailRelation,
|
||||||
|
data: Record<string, any>[],
|
||||||
|
companyCode: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<ExcelUploadResult> {
|
||||||
|
const result: ExcelUploadResult = {
|
||||||
|
success: false,
|
||||||
|
masterInserted: 0,
|
||||||
|
masterUpdated: 0,
|
||||||
|
detailInserted: 0,
|
||||||
|
detailDeleted: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||||
|
|
||||||
|
// 1. 데이터를 마스터 키로 그룹화
|
||||||
|
const groupedData = new Map<string, Record<string, any>[]>();
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const masterKey = row[masterKeyColumn];
|
||||||
|
if (!masterKey) {
|
||||||
|
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupedData.has(masterKey)) {
|
||||||
|
groupedData.set(masterKey, []);
|
||||||
|
}
|
||||||
|
groupedData.get(masterKey)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||||
|
|
||||||
|
// 2. 각 그룹 처리
|
||||||
|
for (const [masterKey, rows] of groupedData.entries()) {
|
||||||
|
try {
|
||||||
|
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||||
|
const masterData: Record<string, any> = {};
|
||||||
|
for (const col of masterColumns) {
|
||||||
|
if (rows[0][col.name] !== undefined) {
|
||||||
|
masterData[col.name] = rows[0][col.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 코드, 작성자 추가
|
||||||
|
masterData.company_code = companyCode;
|
||||||
|
if (userId) {
|
||||||
|
masterData.writer = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. 마스터 UPSERT
|
||||||
|
const existingMaster = await client.query(
|
||||||
|
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||||
|
[masterKey, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingMaster.rows.length > 0) {
|
||||||
|
// UPDATE
|
||||||
|
const updateCols = Object.keys(masterData)
|
||||||
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||||
|
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||||
|
const updateValues = Object.keys(masterData)
|
||||||
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||||
|
.map(k => masterData[k]);
|
||||||
|
|
||||||
|
if (updateCols.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "${masterTable}"
|
||||||
|
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||||
|
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||||
|
[...updateValues, masterKey, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.masterUpdated++;
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const insertCols = Object.keys(masterData);
|
||||||
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const insertValues = insertCols.map(k => masterData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||||
|
insertValues
|
||||||
|
);
|
||||||
|
result.masterInserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. 기존 디테일 삭제
|
||||||
|
const deleteResult = await client.query(
|
||||||
|
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||||
|
[masterKey, companyCode]
|
||||||
|
);
|
||||||
|
result.detailDeleted += deleteResult.rowCount || 0;
|
||||||
|
|
||||||
|
// 2d. 새 디테일 INSERT
|
||||||
|
for (const row of rows) {
|
||||||
|
const detailData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// FK 컬럼 추가
|
||||||
|
detailData[detailFkColumn] = masterKey;
|
||||||
|
detailData.company_code = companyCode;
|
||||||
|
if (userId) {
|
||||||
|
detailData.writer = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 컬럼 데이터 추출
|
||||||
|
for (const col of detailColumns) {
|
||||||
|
if (row[col.name] !== undefined) {
|
||||||
|
detailData[col.name] = row[col.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertCols = Object.keys(detailData);
|
||||||
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const insertValues = insertCols.map(k => detailData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||||
|
insertValues
|
||||||
|
);
|
||||||
|
result.detailInserted++;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
masterUpdated: result.masterUpdated,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
detailDeleted: result.detailDeleted,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 업로드
|
||||||
|
*
|
||||||
|
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
||||||
|
* 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
*
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param detailData 디테일 데이터 배열
|
||||||
|
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
||||||
|
* @param numberingRuleId 채번 규칙 ID (optional)
|
||||||
|
* @param companyCode 회사 코드
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
||||||
|
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
||||||
|
*/
|
||||||
|
async uploadSimple(
|
||||||
|
screenId: number,
|
||||||
|
detailData: Record<string, any>[],
|
||||||
|
masterFieldValues: Record<string, any>,
|
||||||
|
numberingRuleId: string | undefined,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
afterUploadFlowId?: string,
|
||||||
|
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string;
|
||||||
|
errors: string[];
|
||||||
|
controlResult?: any;
|
||||||
|
}> {
|
||||||
|
const result: {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string;
|
||||||
|
errors: string[];
|
||||||
|
controlResult?: any;
|
||||||
|
} = {
|
||||||
|
success: false,
|
||||||
|
masterInserted: 0,
|
||||||
|
detailInserted: 0,
|
||||||
|
generatedKey: "",
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 정보 조회
|
||||||
|
const relation = await this.getMasterDetailRelation(screenId);
|
||||||
|
if (!relation) {
|
||||||
|
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
||||||
|
|
||||||
|
// 2. 채번 처리
|
||||||
|
let generatedKey: string;
|
||||||
|
|
||||||
|
if (numberingRuleId) {
|
||||||
|
// 채번 규칙으로 키 생성
|
||||||
|
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
||||||
|
} else {
|
||||||
|
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
||||||
|
generatedKey = masterFieldValues[masterKeyColumn];
|
||||||
|
if (!generatedKey) {
|
||||||
|
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.generatedKey = generatedKey;
|
||||||
|
logger.info(`채번 결과: ${generatedKey}`);
|
||||||
|
|
||||||
|
// 3. 마스터 레코드 생성
|
||||||
|
const masterData: Record<string, any> = {
|
||||||
|
...masterFieldValues,
|
||||||
|
[masterKeyColumn]: generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
writer: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 마스터 컬럼명 목록 구성
|
||||||
|
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
||||||
|
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const masterValues = masterCols.map(k => masterData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
||||||
|
masterValues
|
||||||
|
);
|
||||||
|
result.masterInserted = 1;
|
||||||
|
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
||||||
|
|
||||||
|
// 4. 디테일 레코드들 생성
|
||||||
|
for (const row of detailData) {
|
||||||
|
try {
|
||||||
|
const detailRowData: Record<string, any> = {
|
||||||
|
...row,
|
||||||
|
[detailFkColumn]: generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
writer: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 빈 값 필터링 및 id 제외
|
||||||
|
const detailCols = Object.keys(detailRowData).filter(k =>
|
||||||
|
k !== "id" &&
|
||||||
|
detailRowData[k] !== undefined &&
|
||||||
|
detailRowData[k] !== null &&
|
||||||
|
detailRowData[k] !== ""
|
||||||
|
);
|
||||||
|
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
||||||
|
const detailValues = detailCols.map(k => detailRowData[k]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${detailPlaceholders.join(", ")}, NOW())`,
|
||||||
|
detailValues
|
||||||
|
);
|
||||||
|
result.detailInserted++;
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
||||||
|
logger.error(`디테일 행 처리 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
||||||
|
|
||||||
|
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
generatedKey: result.generatedKey,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 업로드 후 제어 실행 (단일 또는 다중)
|
||||||
|
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
||||||
|
? afterUploadFlows // 다중 제어
|
||||||
|
: afterUploadFlowId
|
||||||
|
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (flowsToExecute.length > 0 && result.success) {
|
||||||
|
try {
|
||||||
|
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
||||||
|
|
||||||
|
// 마스터 데이터를 제어에 전달
|
||||||
|
const masterData = {
|
||||||
|
...masterFieldValues,
|
||||||
|
[relation!.masterKeyColumn]: result.generatedKey,
|
||||||
|
company_code: companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlResults: any[] = [];
|
||||||
|
|
||||||
|
// 순서대로 제어 실행
|
||||||
|
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
||||||
|
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
||||||
|
|
||||||
|
const controlResult = await NodeFlowExecutionService.executeFlow(
|
||||||
|
parseInt(flow.flowId),
|
||||||
|
{
|
||||||
|
sourceData: [masterData],
|
||||||
|
dataSourceType: "formData",
|
||||||
|
buttonId: "excel-upload-button",
|
||||||
|
screenId: screenId,
|
||||||
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
|
formData: masterData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
controlResults.push({
|
||||||
|
flowId: flow.flowId,
|
||||||
|
order: flow.order,
|
||||||
|
success: controlResult.success,
|
||||||
|
message: controlResult.message,
|
||||||
|
executedNodes: controlResult.nodes?.length || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.controlResult = {
|
||||||
|
success: controlResults.every(r => r.success),
|
||||||
|
executedFlows: controlResults.length,
|
||||||
|
results: controlResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
||||||
|
} catch (controlError: any) {
|
||||||
|
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
||||||
|
result.controlResult = {
|
||||||
|
success: false,
|
||||||
|
message: `제어 실행 실패: ${controlError.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||||
|
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
||||||
|
*/
|
||||||
|
private async generateNumberWithRule(
|
||||||
|
client: any,
|
||||||
|
ruleId: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 기존 numberingRuleService를 사용하여 코드 할당
|
||||||
|
const { numberingRuleService } = await import("./numberingRuleService");
|
||||||
|
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||||
|
|
||||||
|
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
||||||
|
|
||||||
|
return generatedCode;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const masterDetailExcelService = new MasterDetailExcelService();
|
||||||
|
|
||||||
|
|
@ -969,21 +969,56 @@ export class NodeFlowExecutionService {
|
||||||
const insertedData = { ...data };
|
const insertedData = { ...data };
|
||||||
|
|
||||||
console.log("🗺️ 필드 매핑 처리 중...");
|
console.log("🗺️ 필드 매핑 처리 중...");
|
||||||
fieldMappings.forEach((mapping: any) => {
|
|
||||||
|
// 🔥 채번 규칙 서비스 동적 import
|
||||||
|
const { numberingRuleService } = await import("./numberingRuleService");
|
||||||
|
|
||||||
|
for (const mapping of fieldMappings) {
|
||||||
fields.push(mapping.targetField);
|
fields.push(mapping.targetField);
|
||||||
const value =
|
let value: any;
|
||||||
mapping.staticValue !== undefined
|
|
||||||
? mapping.staticValue
|
// 🔥 값 생성 유형에 따른 처리
|
||||||
: data[mapping.sourceField];
|
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
||||||
|
|
||||||
console.log(
|
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
||||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
// 자동 생성 (채번 규칙)
|
||||||
);
|
const companyCode = context.buttonContext?.companyCode || "*";
|
||||||
|
try {
|
||||||
|
value = await numberingRuleService.allocateCode(
|
||||||
|
mapping.numberingRuleId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 규칙 적용 실패: ${error.message}`);
|
||||||
|
console.error(
|
||||||
|
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (valueType === "static" || mapping.staticValue !== undefined) {
|
||||||
|
// 고정값
|
||||||
|
value = mapping.staticValue;
|
||||||
|
console.log(
|
||||||
|
` 📌 고정값: ${mapping.targetField} = ${value}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 소스 필드
|
||||||
|
value = data[mapping.sourceField];
|
||||||
|
console.log(
|
||||||
|
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
values.push(value);
|
values.push(value);
|
||||||
|
|
||||||
// 🔥 삽입된 값을 데이터에 반영
|
// 🔥 삽입된 값을 데이터에 반영
|
||||||
insertedData[mapping.targetField] = value;
|
insertedData[mapping.targetField] = value;
|
||||||
});
|
}
|
||||||
|
|
||||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||||
const hasWriterMapping = fieldMappings.some(
|
const hasWriterMapping = fieldMappings.some(
|
||||||
|
|
@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
whereConditions,
|
let finalWhereConditions: any[];
|
||||||
data,
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
targetTable
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
);
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
|
whereConditions,
|
||||||
|
data,
|
||||||
|
targetTable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
enhancedWhereConditions,
|
finalWhereConditions,
|
||||||
data,
|
data,
|
||||||
paramIndex
|
paramIndex
|
||||||
);
|
);
|
||||||
|
|
@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService {
|
||||||
return deletedDataArray;
|
return deletedDataArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
// 🆕 context-data 모드: 개별 삭제
|
||||||
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
||||||
|
|
||||||
for (const data of dataArray) {
|
for (const data of dataArray) {
|
||||||
console.log("🔍 WHERE 조건 처리 중...");
|
console.log("🔍 WHERE 조건 처리 중...");
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
whereConditions,
|
let finalWhereConditions: any[];
|
||||||
data,
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
targetTable
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
);
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
|
whereConditions,
|
||||||
|
data,
|
||||||
|
targetTable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
enhancedWhereConditions,
|
finalWhereConditions,
|
||||||
data,
|
data,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -2865,10 +2916,11 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
|
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
|
||||||
);
|
);
|
||||||
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
|
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
|
||||||
return operator === "NOT_EXISTS_IN";
|
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2761,33 +2761,64 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const additionalColumn of options.additionalJoinColumns) {
|
for (const additionalColumn of options.additionalJoinColumns) {
|
||||||
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
|
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
|
||||||
const baseJoinConfig = joinConfigs.find(
|
let baseJoinConfig = joinConfigs.find(
|
||||||
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
|
||||||
|
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
|
||||||
|
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
|
||||||
|
baseJoinConfig = joinConfigs.find(
|
||||||
|
(config) => config.referenceTable === (additionalColumn as any).referenceTable
|
||||||
|
);
|
||||||
|
if (baseJoinConfig) {
|
||||||
|
logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (baseJoinConfig) {
|
if (baseJoinConfig) {
|
||||||
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
// joinAlias에서 실제 컬럼명 추출
|
||||||
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
||||||
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
||||||
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
|
||||||
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
||||||
|
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
||||||
|
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
||||||
|
let actualColumnName: string;
|
||||||
|
|
||||||
|
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
||||||
|
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
||||||
|
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
||||||
|
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, "");
|
||||||
|
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
||||||
|
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, "");
|
||||||
|
} else {
|
||||||
|
// 어느 것도 아니면 원본 사용
|
||||||
|
actualColumnName = originalJoinAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
||||||
|
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
||||||
|
|
||||||
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
||||||
sourceColumn,
|
sourceColumn,
|
||||||
joinAlias,
|
frontendSourceColumn,
|
||||||
|
originalJoinAlias,
|
||||||
|
correctedJoinAlias,
|
||||||
actualColumnName,
|
actualColumnName,
|
||||||
referenceTable: additionalColumn.sourceTable,
|
referenceTable: (additionalColumn as any).referenceTable,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
||||||
const isBasicEntityJoin =
|
const isBasicEntityJoin =
|
||||||
additionalColumn.joinAlias ===
|
correctedJoinAlias === `${sourceColumn}_name`;
|
||||||
`${baseJoinConfig.sourceColumn}_name`;
|
|
||||||
|
|
||||||
if (isBasicEntityJoin) {
|
if (isBasicEntityJoin) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
|
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
|
||||||
);
|
);
|
||||||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||||
}
|
}
|
||||||
|
|
@ -2795,14 +2826,14 @@ export class TableManagementService {
|
||||||
// 추가 조인 컬럼 설정 생성
|
// 추가 조인 컬럼 설정 생성
|
||||||
const additionalJoinConfig: EntityJoinConfig = {
|
const additionalJoinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
|
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||||
referenceTable:
|
referenceTable:
|
||||||
(additionalColumn as any).referenceTable ||
|
(additionalColumn as any).referenceTable ||
|
||||||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
|
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
|
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
|
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||||
displayColumn: actualColumnName, // 하위 호환성
|
displayColumn: actualColumnName, // 하위 호환성
|
||||||
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
|
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||||
separator: " - ", // 기본 구분자
|
separator: " - ", // 기본 구분자
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3769,6 +3800,15 @@ export class TableManagementService {
|
||||||
const cacheableJoins: EntityJoinConfig[] = [];
|
const cacheableJoins: EntityJoinConfig[] = [];
|
||||||
const dbJoins: EntityJoinConfig[] = [];
|
const dbJoins: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
||||||
|
const companySpecificTables = [
|
||||||
|
"supplier_mng",
|
||||||
|
"customer_mng",
|
||||||
|
"item_info",
|
||||||
|
"dept_info",
|
||||||
|
// 필요시 추가
|
||||||
|
];
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
for (const config of joinConfigs) {
|
||||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||||
if (config.referenceTable === "table_column_category_values") {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
|
|
@ -3777,6 +3817,13 @@ export class TableManagementService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
|
||||||
|
if (companySpecificTables.includes(config.referenceTable)) {
|
||||||
|
dbJoins.push(config);
|
||||||
|
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 캐시 가능성 확인
|
// 캐시 가능성 확인
|
||||||
const cachedData = await referenceCacheService.getCachedReference(
|
const cachedData = await referenceCacheService.getCachedReference(
|
||||||
config.referenceTable,
|
config.referenceTable,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,35 @@ import { cn } from "@/lib/utils";
|
||||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||||
|
|
||||||
|
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
||||||
|
export interface MasterDetailExcelConfig {
|
||||||
|
// 테이블 정보
|
||||||
|
masterTable?: string;
|
||||||
|
detailTable?: string;
|
||||||
|
masterKeyColumn?: string;
|
||||||
|
detailFkColumn?: string;
|
||||||
|
// 채번
|
||||||
|
numberingRuleId?: string;
|
||||||
|
// 업로드 전 사용자가 선택할 마스터 테이블 필드
|
||||||
|
masterSelectFields?: Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
required: boolean;
|
||||||
|
inputType: "entity" | "date" | "text" | "select";
|
||||||
|
referenceTable?: string;
|
||||||
|
referenceColumn?: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}>;
|
||||||
|
// 엑셀에서 매핑할 디테일 테이블 필드
|
||||||
|
detailExcelFields?: Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
required: boolean;
|
||||||
|
}>;
|
||||||
|
masterDefaults?: Record<string, any>;
|
||||||
|
detailDefaults?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExcelUploadModalProps {
|
export interface ExcelUploadModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -42,6 +71,19 @@ export interface ExcelUploadModalProps {
|
||||||
keyColumn?: string;
|
keyColumn?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
// 마스터-디테일 지원
|
||||||
|
screenId?: number;
|
||||||
|
isMasterDetail?: boolean;
|
||||||
|
masterDetailRelation?: {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string;
|
||||||
|
detailFkColumn: string;
|
||||||
|
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
};
|
||||||
|
// 🆕 마스터-디테일 엑셀 업로드 설정
|
||||||
|
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ColumnMapping {
|
interface ColumnMapping {
|
||||||
|
|
@ -57,6 +99,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
keyColumn,
|
keyColumn,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
userId = "guest",
|
userId = "guest",
|
||||||
|
screenId,
|
||||||
|
isMasterDetail = false,
|
||||||
|
masterDetailRelation,
|
||||||
|
masterDetailExcelConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
|
@ -79,6 +125,116 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 3단계: 확인
|
// 3단계: 확인
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
|
||||||
|
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
|
||||||
|
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
|
||||||
|
const [entitySearchLoading, setEntitySearchLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [entityDisplayColumns, setEntityDisplayColumns] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 🆕 엔티티 참조 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 엔티티 데이터 로드 체크:", {
|
||||||
|
masterSelectFields: masterDetailExcelConfig?.masterSelectFields,
|
||||||
|
open,
|
||||||
|
isMasterDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!masterDetailExcelConfig?.masterSelectFields) return;
|
||||||
|
|
||||||
|
const loadEntityData = async () => {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
|
||||||
|
for (const field of masterDetailExcelConfig.masterSelectFields!) {
|
||||||
|
console.log("🔍 필드 처리:", field);
|
||||||
|
|
||||||
|
if (field.inputType === "entity") {
|
||||||
|
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true }));
|
||||||
|
try {
|
||||||
|
let refTable = field.referenceTable;
|
||||||
|
console.log("🔍 초기 refTable:", refTable);
|
||||||
|
|
||||||
|
let displayCol = field.displayColumn;
|
||||||
|
|
||||||
|
// referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회
|
||||||
|
if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) {
|
||||||
|
console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable);
|
||||||
|
const colResponse = await apiClient.get(
|
||||||
|
`/table-management/tables/${masterDetailExcelConfig.masterTable}/columns`
|
||||||
|
);
|
||||||
|
console.log("🔍 컬럼 조회 응답:", colResponse.data);
|
||||||
|
|
||||||
|
if (colResponse.data?.success && colResponse.data?.data?.columns) {
|
||||||
|
const colInfo = colResponse.data.data.columns.find(
|
||||||
|
(c: any) => (c.columnName || c.column_name) === field.columnName
|
||||||
|
);
|
||||||
|
console.log("🔍 찾은 컬럼 정보:", colInfo);
|
||||||
|
if (colInfo) {
|
||||||
|
if (!refTable) {
|
||||||
|
refTable = colInfo.referenceTable || colInfo.reference_table;
|
||||||
|
console.log("🔍 DB에서 가져온 refTable:", refTable);
|
||||||
|
}
|
||||||
|
if (!displayCol) {
|
||||||
|
displayCol = colInfo.displayColumn || colInfo.display_column;
|
||||||
|
console.log("🔍 DB에서 가져온 displayColumn:", displayCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayColumn 저장 (Select 렌더링 시 사용)
|
||||||
|
if (displayCol) {
|
||||||
|
setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refTable) {
|
||||||
|
console.log("🔍 엔티티 데이터 조회:", refTable);
|
||||||
|
const response = await DynamicFormApi.getTableData(refTable, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1000,
|
||||||
|
});
|
||||||
|
console.log("🔍 엔티티 데이터 응답:", response);
|
||||||
|
// getTableData는 { success, data: [...] } 형식으로 반환
|
||||||
|
const rows = response.data?.rows || response.data;
|
||||||
|
if (response.success && rows && Array.isArray(rows)) {
|
||||||
|
setEntitySearchData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: rows,
|
||||||
|
}));
|
||||||
|
console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error);
|
||||||
|
} finally {
|
||||||
|
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) {
|
||||||
|
loadEntityData();
|
||||||
|
}
|
||||||
|
}, [open, isMasterDetail, masterDetailExcelConfig]);
|
||||||
|
|
||||||
|
// 마스터-디테일 모드에서 마스터 필드 입력 여부 확인
|
||||||
|
const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig;
|
||||||
|
const hasMasterSelectFields = isSimpleMasterDetailMode &&
|
||||||
|
(masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
// 마스터 필드가 모두 입력되었는지 확인
|
||||||
|
const isMasterFieldsValid = () => {
|
||||||
|
if (!hasMasterSelectFields) return true;
|
||||||
|
return masterDetailExcelConfig!.masterSelectFields!.every((field) => {
|
||||||
|
if (!field.required) return true;
|
||||||
|
const value = masterFieldValues[field.columnName];
|
||||||
|
return value !== undefined && value !== null && value !== "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 파일 선택 핸들러
|
// 파일 선택 핸들러
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = e.target.files?.[0];
|
const selectedFile = e.target.files?.[0];
|
||||||
|
|
@ -184,50 +340,138 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
const loadTableSchema = async () => {
|
const loadTableSchema = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
|
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
|
||||||
|
|
||||||
const response = await getTableSchema(tableName);
|
let allColumns: TableColumn[] = [];
|
||||||
|
|
||||||
console.log("📊 테이블 스키마 응답:", response);
|
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택)
|
||||||
|
if (isSimpleMasterDetailMode && masterDetailRelation) {
|
||||||
|
const { detailTable, detailFkColumn } = masterDetailRelation;
|
||||||
|
|
||||||
|
console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
|
||||||
|
|
||||||
if (response.success && response.data) {
|
// 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택)
|
||||||
// 자동 생성 컬럼 제외
|
const detailResponse = await getTableSchema(detailTable);
|
||||||
const filteredColumns = response.data.columns.filter(
|
if (detailResponse.success && detailResponse.data) {
|
||||||
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
// 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체
|
||||||
);
|
const configuredFields = masterDetailExcelConfig?.detailExcelFields;
|
||||||
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
|
|
||||||
setSystemColumns(filteredColumns);
|
const detailCols = detailResponse.data.columns
|
||||||
|
.filter((col) => {
|
||||||
// 기존 매핑 템플릿 조회
|
// 자동 생성 컬럼, FK 컬럼 제외
|
||||||
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
|
||||||
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
|
if (col.name === detailFkColumn) return false;
|
||||||
|
|
||||||
if (mappingResponse.success && mappingResponse.data) {
|
// 설정된 필드가 있으면 해당 필드만
|
||||||
// 저장된 매핑 템플릿이 있으면 자동 적용
|
if (configuredFields && configuredFields.length > 0) {
|
||||||
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
|
return configuredFields.some((f) => f.columnName === col.name);
|
||||||
const savedMappings = mappingResponse.data.columnMappings;
|
}
|
||||||
|
return true;
|
||||||
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
})
|
||||||
excelColumn: col,
|
.map((col) => {
|
||||||
systemColumn: savedMappings[col] || null,
|
// 설정에서 라벨 찾기
|
||||||
}));
|
const configField = configuredFields?.find((f) => f.columnName === col.name);
|
||||||
setColumnMappings(appliedMappings);
|
return {
|
||||||
setIsAutoMappingLoaded(true);
|
...col,
|
||||||
|
label: configField?.columnLabel || col.label || col.name,
|
||||||
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
|
originalName: col.name,
|
||||||
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
|
sourceTable: detailTable,
|
||||||
} else {
|
};
|
||||||
// 매핑 템플릿이 없으면 초기 상태로 설정
|
});
|
||||||
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
allColumns = detailCols;
|
||||||
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
|
||||||
excelColumn: col,
|
|
||||||
systemColumn: null,
|
|
||||||
}));
|
|
||||||
setColumnMappings(initialMappings);
|
|
||||||
setIsAutoMappingLoaded(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length);
|
||||||
|
}
|
||||||
|
// 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기
|
||||||
|
else if (isMasterDetail && masterDetailRelation) {
|
||||||
|
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
|
||||||
|
|
||||||
|
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
|
||||||
|
|
||||||
|
// 마스터 테이블 스키마
|
||||||
|
const masterResponse = await getTableSchema(masterTable);
|
||||||
|
if (masterResponse.success && masterResponse.data) {
|
||||||
|
const masterCols = masterResponse.data.columns
|
||||||
|
.filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
|
||||||
|
.map((col) => ({
|
||||||
|
...col,
|
||||||
|
// 유니크 키를 위해 테이블명 접두사 추가
|
||||||
|
name: `${masterTable}.${col.name}`,
|
||||||
|
label: `[마스터] ${col.label || col.name}`,
|
||||||
|
originalName: col.name,
|
||||||
|
sourceTable: masterTable,
|
||||||
|
}));
|
||||||
|
allColumns = [...allColumns, ...masterCols];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 테이블 스키마 (FK 컬럼 제외)
|
||||||
|
const detailResponse = await getTableSchema(detailTable);
|
||||||
|
if (detailResponse.success && detailResponse.data) {
|
||||||
|
const detailCols = detailResponse.data.columns
|
||||||
|
.filter((col) =>
|
||||||
|
!AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) &&
|
||||||
|
col.name !== detailFkColumn // FK 컬럼 제외
|
||||||
|
)
|
||||||
|
.map((col) => ({
|
||||||
|
...col,
|
||||||
|
// 유니크 키를 위해 테이블명 접두사 추가
|
||||||
|
name: `${detailTable}.${col.name}`,
|
||||||
|
label: `[디테일] ${col.label || col.name}`,
|
||||||
|
originalName: col.name,
|
||||||
|
sourceTable: detailTable,
|
||||||
|
}));
|
||||||
|
allColumns = [...allColumns, ...detailCols];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length);
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", response);
|
// 기존 단일 테이블 모드
|
||||||
|
const response = await getTableSchema(tableName);
|
||||||
|
|
||||||
|
console.log("📊 테이블 스키마 응답:", response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 자동 생성 컬럼 제외
|
||||||
|
allColumns = response.data.columns.filter(
|
||||||
|
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("❌ 테이블 스키마 로드 실패:", response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
|
||||||
|
setSystemColumns(allColumns);
|
||||||
|
|
||||||
|
// 기존 매핑 템플릿 조회
|
||||||
|
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
||||||
|
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
|
||||||
|
|
||||||
|
if (mappingResponse.success && mappingResponse.data) {
|
||||||
|
// 저장된 매핑 템플릿이 있으면 자동 적용
|
||||||
|
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
|
||||||
|
const savedMappings = mappingResponse.data.columnMappings;
|
||||||
|
|
||||||
|
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||||
|
excelColumn: col,
|
||||||
|
systemColumn: savedMappings[col] || null,
|
||||||
|
}));
|
||||||
|
setColumnMappings(appliedMappings);
|
||||||
|
setIsAutoMappingLoaded(true);
|
||||||
|
|
||||||
|
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
|
||||||
|
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
|
||||||
|
} else {
|
||||||
|
// 매핑 템플릿이 없으면 초기 상태로 설정
|
||||||
|
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
||||||
|
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||||
|
excelColumn: col,
|
||||||
|
systemColumn: null,
|
||||||
|
}));
|
||||||
|
setColumnMappings(initialMappings);
|
||||||
|
setIsAutoMappingLoaded(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", error);
|
console.error("❌ 테이블 스키마 로드 실패:", error);
|
||||||
|
|
@ -239,18 +483,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const handleAutoMapping = () => {
|
const handleAutoMapping = () => {
|
||||||
const newMappings = excelColumns.map((excelCol) => {
|
const newMappings = excelColumns.map((excelCol) => {
|
||||||
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
||||||
|
// [마스터], [디테일] 접두사 제거 후 비교
|
||||||
|
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||||
|
|
||||||
// 1. 먼저 라벨로 매칭 시도
|
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
|
||||||
let matchedSystemCol = systemColumns.find(
|
let matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
(sysCol) =>
|
if (!sysCol.label) return false;
|
||||||
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
|
// [마스터], [디테일] 접두사 제거 후 비교
|
||||||
);
|
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||||
|
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
|
||||||
|
});
|
||||||
|
|
||||||
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
||||||
if (!matchedSystemCol) {
|
if (!matchedSystemCol) {
|
||||||
matchedSystemCol = systemColumns.find(
|
matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
|
// 마스터-디테일 모드: originalName이 있으면 사용
|
||||||
);
|
const originalName = (sysCol as any).originalName;
|
||||||
|
const colName = originalName || sysCol.name;
|
||||||
|
return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
|
||||||
|
if (!matchedSystemCol) {
|
||||||
|
matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
|
// 테이블.컬럼 형식에서 컬럼만 추출
|
||||||
|
const nameParts = sysCol.name.split(".");
|
||||||
|
const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
|
||||||
|
return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -285,6 +546,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
|
||||||
|
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
|
||||||
|
toast.error("마스터 정보를 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
|
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
// 빈 헤더가 아닌 열만 필터링
|
// 빈 헤더가 아닌 열만 필터링
|
||||||
|
|
@ -344,7 +611,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const mappedRow: Record<string, any> = {};
|
const mappedRow: Record<string, any> = {};
|
||||||
columnMappings.forEach((mapping) => {
|
columnMappings.forEach((mapping) => {
|
||||||
if (mapping.systemColumn) {
|
if (mapping.systemColumn) {
|
||||||
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
|
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
|
||||||
|
let colName = mapping.systemColumn;
|
||||||
|
if (isMasterDetail && colName.includes(".")) {
|
||||||
|
colName = colName.split(".")[1];
|
||||||
|
}
|
||||||
|
mappedRow[colName] = row[mapping.excelColumn];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return mappedRow;
|
return mappedRow;
|
||||||
|
|
@ -364,60 +636,96 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||||
);
|
);
|
||||||
|
|
||||||
let successCount = 0;
|
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
|
||||||
let failCount = 0;
|
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
||||||
|
console.log("📊 마스터-디테일 간단 모드 업로드:", {
|
||||||
|
masterDetailRelation,
|
||||||
|
masterFieldValues,
|
||||||
|
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
||||||
|
});
|
||||||
|
|
||||||
for (const row of filteredData) {
|
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
|
||||||
try {
|
screenId,
|
||||||
if (uploadMode === "insert") {
|
filteredData,
|
||||||
const formData = { screenId: 0, tableName, data: row };
|
masterFieldValues,
|
||||||
const result = await DynamicFormApi.saveFormData(formData);
|
masterDetailExcelConfig?.numberingRuleId || undefined,
|
||||||
if (result.success) {
|
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
|
||||||
successCount++;
|
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
|
||||||
} else {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast.success(
|
|
||||||
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
|
if (uploadResult.success && uploadResult.data) {
|
||||||
try {
|
const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data;
|
||||||
const mappingsToSave: Record<string, string | null> = {};
|
|
||||||
columnMappings.forEach((mapping) => {
|
toast.success(
|
||||||
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
|
`마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
|
||||||
});
|
(errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
||||||
|
|
||||||
console.log("💾 매핑 템플릿 저장 중...", {
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
mappingsToSave,
|
|
||||||
});
|
|
||||||
const saveResult = await saveMappingTemplate(
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
mappingsToSave
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (saveResult.success) {
|
// 매핑 템플릿 저장
|
||||||
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
|
await saveMappingTemplateInternal();
|
||||||
} else {
|
|
||||||
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🆕 마스터-디테일 기존 모드 처리
|
||||||
|
else if (isMasterDetail && screenId && masterDetailRelation) {
|
||||||
|
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
||||||
|
|
||||||
|
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
||||||
|
screenId,
|
||||||
|
filteredData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadResult.success && uploadResult.data) {
|
||||||
|
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
|
||||||
|
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장
|
||||||
|
await saveMappingTemplateInternal();
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 기존 단일 테이블 업로드 로직
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const row of filteredData) {
|
||||||
|
try {
|
||||||
|
if (uploadMode === "insert") {
|
||||||
|
const formData = { screenId: 0, tableName, data: row };
|
||||||
|
const result = await DynamicFormApi.saveFormData(formData);
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failCount++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess?.();
|
if (successCount > 0) {
|
||||||
} else {
|
toast.success(
|
||||||
toast.error("업로드에 실패했습니다.");
|
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장
|
||||||
|
await saveMappingTemplateInternal();
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error("업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 엑셀 업로드 실패:", error);
|
console.error("❌ 엑셀 업로드 실패:", error);
|
||||||
|
|
@ -427,6 +735,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장 헬퍼 함수
|
||||||
|
const saveMappingTemplateInternal = async () => {
|
||||||
|
try {
|
||||||
|
const mappingsToSave: Record<string, string | null> = {};
|
||||||
|
columnMappings.forEach((mapping) => {
|
||||||
|
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("💾 매핑 템플릿 저장 중...", {
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
mappingsToSave,
|
||||||
|
});
|
||||||
|
const saveResult = await saveMappingTemplate(
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
mappingsToSave
|
||||||
|
);
|
||||||
|
|
||||||
|
if (saveResult.success) {
|
||||||
|
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 모달 닫기 시 초기화
|
// 모달 닫기 시 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|
@ -441,6 +778,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setExcelColumns([]);
|
setExcelColumns([]);
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
|
// 🆕 마스터-디테일 모드 초기화
|
||||||
|
setMasterFieldValues({});
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|
@ -461,9 +800,21 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
엑셀 데이터 업로드
|
엑셀 데이터 업로드
|
||||||
|
{isMasterDetail && (
|
||||||
|
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
|
||||||
|
마스터-디테일
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요.
|
{isMasterDetail && masterDetailRelation ? (
|
||||||
|
<>
|
||||||
|
마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다.
|
||||||
|
마스터 데이터는 중복 입력 시 병합됩니다.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -518,6 +869,87 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
|
||||||
|
{hasMasterSelectFields && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
|
||||||
|
<div key={field.columnName} className="space-y-1">
|
||||||
|
<Label className="text-xs">
|
||||||
|
{field.columnLabel}
|
||||||
|
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
</Label>
|
||||||
|
{field.inputType === "entity" ? (
|
||||||
|
<Select
|
||||||
|
value={masterFieldValues[field.columnName]?.toString() || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setMasterFieldValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder={`${field.columnLabel} 선택`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entitySearchLoading[field.columnName] ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
로딩 중...
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
entitySearchData[field.columnName]?.map((item: any) => {
|
||||||
|
const keyValue = item[field.referenceColumn || "id"];
|
||||||
|
// displayColumn: 저장된 값 → DB에서 조회한 값 → referenceColumn → id
|
||||||
|
const displayColName =
|
||||||
|
field.displayColumn ||
|
||||||
|
entityDisplayColumns[field.columnName] ||
|
||||||
|
field.referenceColumn ||
|
||||||
|
"id";
|
||||||
|
const displayValue = item[displayColName] || keyValue;
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={keyValue}
|
||||||
|
value={keyValue?.toString()}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : field.inputType === "date" ? (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={masterFieldValues[field.columnName] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMasterFieldValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="h-9 w-full rounded-md border px-3 text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={masterFieldValues[field.columnName] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMasterFieldValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.columnName]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={field.columnLabel}
|
||||||
|
className="h-9 w-full rounded-md border px-3 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 파일 선택 영역 */}
|
{/* 파일 선택 영역 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -174,8 +174,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||||
if (editData) {
|
if (editData) {
|
||||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||||
setFormData(editData);
|
|
||||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
// 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
|
||||||
|
if (Array.isArray(editData)) {
|
||||||
|
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
|
||||||
|
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
|
||||||
|
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
|
||||||
|
} else {
|
||||||
|
setFormData(editData);
|
||||||
|
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* DELETE 액션 노드 속성 편집
|
* DELETE 액션 노드 속성 편집
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
|
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -24,6 +24,12 @@ interface DeleteActionPropertiesProps {
|
||||||
data: DeleteActionNodeData;
|
data: DeleteActionNodeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 소스 필드 타입
|
||||||
|
interface SourceField {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const OPERATORS = [
|
const OPERATORS = [
|
||||||
{ value: "EQUALS", label: "=" },
|
{ value: "EQUALS", label: "=" },
|
||||||
{ value: "NOT_EQUALS", label: "≠" },
|
{ value: "NOT_EQUALS", label: "≠" },
|
||||||
|
|
@ -34,7 +40,7 @@ const OPERATORS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||||||
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
|
const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore();
|
||||||
|
|
||||||
// 🔥 타겟 타입 상태
|
// 🔥 타겟 타입 상태
|
||||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||||
|
|
@ -43,6 +49,10 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||||
|
|
||||||
|
// 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||||
|
const [sourceFields, setSourceFields] = useState<SourceField[]>([]);
|
||||||
|
const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||||
|
|
||||||
// 🔥 외부 DB 관련 상태
|
// 🔥 외부 DB 관련 상태
|
||||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||||
|
|
@ -124,8 +134,106 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
// whereConditions 변경 시 fieldOpenState 초기화
|
// whereConditions 변경 시 fieldOpenState 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFieldOpenState(new Array(whereConditions.length).fill(false));
|
setFieldOpenState(new Array(whereConditions.length).fill(false));
|
||||||
|
setSourceFieldsOpenState(new Array(whereConditions.length).fill(false));
|
||||||
}, [whereConditions.length]);
|
}, [whereConditions.length]);
|
||||||
|
|
||||||
|
// 🆕 소스 필드 로딩 (연결된 입력 노드에서)
|
||||||
|
const loadSourceFields = useCallback(async () => {
|
||||||
|
// 현재 노드로 연결된 엣지 찾기
|
||||||
|
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||||
|
console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges);
|
||||||
|
|
||||||
|
if (incomingEdges.length === 0) {
|
||||||
|
console.log("⚠️ 연결된 소스 노드가 없습니다");
|
||||||
|
setSourceFields([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: SourceField[] = [];
|
||||||
|
const processedFields = new Set<string>();
|
||||||
|
|
||||||
|
for (const edge of incomingEdges) {
|
||||||
|
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||||
|
if (!sourceNode) continue;
|
||||||
|
|
||||||
|
console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data);
|
||||||
|
|
||||||
|
// 소스 노드 타입에 따라 필드 추출
|
||||||
|
if (sourceNode.type === "trigger" && sourceNode.data.tableName) {
|
||||||
|
// 트리거 노드: 테이블 컬럼 조회
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
|
||||||
|
if (columns && Array.isArray(columns)) {
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
if (!processedFields.has(colName)) {
|
||||||
|
processedFields.add(colName);
|
||||||
|
fields.push({
|
||||||
|
name: colName,
|
||||||
|
label: col.columnLabel || col.column_label || colName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("트리거 노드 컬럼 로딩 실패:", error);
|
||||||
|
}
|
||||||
|
} else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) {
|
||||||
|
// 테이블 소스 노드
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
|
||||||
|
if (columns && Array.isArray(columns)) {
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
if (!processedFields.has(colName)) {
|
||||||
|
processedFields.add(colName);
|
||||||
|
fields.push({
|
||||||
|
name: colName,
|
||||||
|
label: col.columnLabel || col.column_label || colName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 소스 노드 컬럼 로딩 실패:", error);
|
||||||
|
}
|
||||||
|
} else if (sourceNode.type === "condition") {
|
||||||
|
// 조건 노드: 연결된 이전 노드에서 필드 가져오기
|
||||||
|
const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id);
|
||||||
|
for (const condEdge of conditionIncomingEdges) {
|
||||||
|
const condSourceNode = nodes.find((n) => n.id === condEdge.source);
|
||||||
|
if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) {
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName);
|
||||||
|
if (columns && Array.isArray(columns)) {
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
if (!processedFields.has(colName)) {
|
||||||
|
processedFields.add(colName);
|
||||||
|
fields.push({
|
||||||
|
name: colName,
|
||||||
|
label: col.columnLabel || col.column_label || colName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("조건 노드 소스 컬럼 로딩 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ DELETE 노드 소스 필드:", fields);
|
||||||
|
setSourceFields(fields);
|
||||||
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
|
// 소스 필드 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
loadSourceFields();
|
||||||
|
}, [loadSourceFields]);
|
||||||
|
|
||||||
const loadExternalConnections = async () => {
|
const loadExternalConnections = async () => {
|
||||||
try {
|
try {
|
||||||
setExternalConnectionsLoading(true);
|
setExternalConnectionsLoading(true);
|
||||||
|
|
@ -239,22 +347,41 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
field: "",
|
field: "",
|
||||||
operator: "EQUALS",
|
operator: "EQUALS",
|
||||||
value: "",
|
value: "",
|
||||||
|
sourceField: undefined,
|
||||||
|
staticValue: undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setWhereConditions(newConditions);
|
setWhereConditions(newConditions);
|
||||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||||
|
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
|
||||||
|
|
||||||
|
// 자동 저장
|
||||||
|
updateNode(nodeId, {
|
||||||
|
whereConditions: newConditions,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveCondition = (index: number) => {
|
const handleRemoveCondition = (index: number) => {
|
||||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||||
setWhereConditions(newConditions);
|
setWhereConditions(newConditions);
|
||||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||||
|
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
|
||||||
|
|
||||||
|
// 자동 저장
|
||||||
|
updateNode(nodeId, {
|
||||||
|
whereConditions: newConditions,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||||
const newConditions = [...whereConditions];
|
const newConditions = [...whereConditions];
|
||||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||||
setWhereConditions(newConditions);
|
setWhereConditions(newConditions);
|
||||||
|
|
||||||
|
// 자동 저장
|
||||||
|
updateNode(nodeId, {
|
||||||
|
whereConditions: newConditions,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
|
@ -840,14 +967,125 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 소스 필드 - Combobox */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">값</Label>
|
<Label className="text-xs text-gray-600">소스 필드 (선택)</Label>
|
||||||
|
{sourceFields.length > 0 ? (
|
||||||
|
<Popover
|
||||||
|
open={sourceFieldsOpenState[index]}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...sourceFieldsOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={sourceFieldsOpenState[index]}
|
||||||
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
{condition.sourceField
|
||||||
|
? (() => {
|
||||||
|
const field = sourceFields.find((f) => f.name === condition.sourceField);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{field?.label || condition.sourceField}
|
||||||
|
</span>
|
||||||
|
{field?.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: "소스 필드 선택 (선택)"}
|
||||||
|
<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>
|
||||||
|
<CommandItem
|
||||||
|
value="_NONE_"
|
||||||
|
onSelect={() => {
|
||||||
|
handleConditionChange(index, "sourceField", undefined);
|
||||||
|
const newState = [...sourceFieldsOpenState];
|
||||||
|
newState[index] = false;
|
||||||
|
setSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
|
className="text-xs text-gray-400 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
!condition.sourceField ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
없음 (정적 값 사용)
|
||||||
|
</CommandItem>
|
||||||
|
{sourceFields.map((field) => (
|
||||||
|
<CommandItem
|
||||||
|
key={field.name}
|
||||||
|
value={field.name}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
handleConditionChange(index, "sourceField", currentValue);
|
||||||
|
const newState = [...sourceFieldsOpenState];
|
||||||
|
newState[index] = false;
|
||||||
|
setSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
condition.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
|
{field.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed border-gray-300 bg-gray-50 p-2 text-center text-xs text-gray-500">
|
||||||
|
연결된 소스 노드가 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-400">소스 데이터에서 값을 가져올 필드</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정적 값 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">정적 값</Label>
|
||||||
<Input
|
<Input
|
||||||
value={condition.value as string}
|
value={condition.staticValue || condition.value || ""}
|
||||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder="비교 값"
|
handleConditionChange(index, "staticValue", e.target.value || undefined);
|
||||||
|
handleConditionChange(index, "value", e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="비교할 고정 값"
|
||||||
className="mt-1 h-8 text-xs"
|
className="mt-1 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때 사용됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
|
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -18,6 +18,8 @@ import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
|
import { getNumberingRules } from "@/lib/api/numberingRule";
|
||||||
|
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
|
|
@ -89,6 +91,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||||||
|
|
||||||
|
// 🔥 채번 규칙 관련 상태
|
||||||
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
|
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
||||||
|
const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState<boolean[]>([]);
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || data.targetTable);
|
setDisplayName(data.displayName || data.targetTable);
|
||||||
|
|
@ -128,8 +135,33 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
|
setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
}, [fieldMappings.length]);
|
}, [fieldMappings.length]);
|
||||||
|
|
||||||
|
// 🔥 채번 규칙 로딩 (자동 생성 사용 시)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadNumberingRules = async () => {
|
||||||
|
setNumberingRulesLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getNumberingRules();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setNumberingRules(response.data);
|
||||||
|
console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`);
|
||||||
|
} else {
|
||||||
|
console.error("❌ 채번 규칙 로딩 실패:", response.error);
|
||||||
|
setNumberingRules([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 채번 규칙 로딩 오류:", error);
|
||||||
|
setNumberingRules([]);
|
||||||
|
} finally {
|
||||||
|
setNumberingRulesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadNumberingRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||||
|
|
@ -540,6 +572,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
sourceField: null,
|
sourceField: null,
|
||||||
targetField: "",
|
targetField: "",
|
||||||
staticValue: undefined,
|
staticValue: undefined,
|
||||||
|
valueType: "source" as const, // 🔥 기본값: 소스 필드
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
|
|
@ -548,6 +581,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// Combobox 열림 상태 배열 초기화
|
// Combobox 열림 상태 배열 초기화
|
||||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
|
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMapping = (index: number) => {
|
const handleRemoveMapping = (index: number) => {
|
||||||
|
|
@ -558,6 +592,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// Combobox 열림 상태 배열도 업데이트
|
// Combobox 열림 상태 배열도 업데이트
|
||||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
|
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||||
|
|
@ -586,6 +621,24 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
targetField: value,
|
targetField: value,
|
||||||
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
||||||
};
|
};
|
||||||
|
} else if (field === "valueType") {
|
||||||
|
// 🔥 값 생성 유형 변경 시 관련 필드 초기화
|
||||||
|
newMappings[index] = {
|
||||||
|
...newMappings[index],
|
||||||
|
valueType: value,
|
||||||
|
// 유형 변경 시 다른 유형의 값 초기화
|
||||||
|
...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }),
|
||||||
|
...(value !== "static" && { staticValue: undefined }),
|
||||||
|
...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }),
|
||||||
|
};
|
||||||
|
} else if (field === "numberingRuleId") {
|
||||||
|
// 🔥 채번 규칙 선택 시 이름도 함께 저장
|
||||||
|
const selectedRule = numberingRules.find((r) => r.ruleId === value);
|
||||||
|
newMappings[index] = {
|
||||||
|
...newMappings[index],
|
||||||
|
numberingRuleId: value,
|
||||||
|
numberingRuleName: selectedRule?.ruleName,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
newMappings[index] = {
|
newMappings[index] = {
|
||||||
...newMappings[index],
|
...newMappings[index],
|
||||||
|
|
@ -1165,54 +1218,203 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 소스 필드 입력/선택 */}
|
{/* 🔥 값 생성 유형 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">
|
<Label className="text-xs text-gray-600">값 생성 방식</Label>
|
||||||
소스 필드
|
<div className="mt-1 grid grid-cols-3 gap-1">
|
||||||
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
<button
|
||||||
</Label>
|
type="button"
|
||||||
{hasRestAPISource ? (
|
onClick={() => handleMappingChange(index, "valueType", "source")}
|
||||||
// REST API 소스인 경우: 직접 입력
|
className={cn(
|
||||||
|
"rounded border px-2 py-1 text-xs transition-all",
|
||||||
|
(mapping.valueType === "source" || !mapping.valueType)
|
||||||
|
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
소스 필드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMappingChange(index, "valueType", "static")}
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-2 py-1 text-xs transition-all",
|
||||||
|
mapping.valueType === "static"
|
||||||
|
? "border-orange-500 bg-orange-50 text-orange-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
고정값
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMappingChange(index, "valueType", "autoGenerate")}
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-2 py-1 text-xs transition-all flex items-center justify-center gap-1",
|
||||||
|
mapping.valueType === "autoGenerate"
|
||||||
|
? "border-purple-500 bg-purple-50 text-purple-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
자동생성
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */}
|
||||||
|
{(mapping.valueType === "source" || !mapping.valueType) && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
소스 필드
|
||||||
|
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||||||
|
</Label>
|
||||||
|
{hasRestAPISource ? (
|
||||||
|
// REST API 소스인 경우: 직접 입력
|
||||||
|
<Input
|
||||||
|
value={mapping.sourceField || ""}
|
||||||
|
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||||
|
placeholder="필드명 입력 (예: userId, userName)"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 일반 소스인 경우: Combobox 선택
|
||||||
|
<Popover
|
||||||
|
open={mappingSourceFieldsOpenState[index]}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setMappingSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||||
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
{mapping.sourceField
|
||||||
|
? (() => {
|
||||||
|
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{field?.label || mapping.sourceField}
|
||||||
|
</span>
|
||||||
|
{field?.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: "소스 필드 선택"}
|
||||||
|
<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>
|
||||||
|
{sourceFields.map((field) => (
|
||||||
|
<CommandItem
|
||||||
|
key={field.name}
|
||||||
|
value={field.name}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
handleMappingChange(index, "sourceField", currentValue || null);
|
||||||
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
|
newState[index] = false;
|
||||||
|
setMappingSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
|
{field.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
{hasRestAPISource && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🔥 고정값 입력 (valueType === "static" 일 때) */}
|
||||||
|
{mapping.valueType === "static" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">고정값</Label>
|
||||||
<Input
|
<Input
|
||||||
value={mapping.sourceField || ""}
|
value={mapping.staticValue || ""}
|
||||||
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||||
placeholder="필드명 입력 (예: userId, userName)"
|
placeholder="고정값 입력"
|
||||||
className="mt-1 h-8 text-xs"
|
className="mt-1 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
// 일반 소스인 경우: Combobox 선택
|
)}
|
||||||
|
|
||||||
|
{/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */}
|
||||||
|
{mapping.valueType === "autoGenerate" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
채번 규칙
|
||||||
|
{numberingRulesLoading && <span className="ml-1 text-gray-400">(로딩 중...)</span>}
|
||||||
|
</Label>
|
||||||
<Popover
|
<Popover
|
||||||
open={mappingSourceFieldsOpenState[index]}
|
open={mappingNumberingRulesOpenState[index]}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
const newState = [...mappingSourceFieldsOpenState];
|
const newState = [...mappingNumberingRulesOpenState];
|
||||||
newState[index] = open;
|
newState[index] = open;
|
||||||
setMappingSourceFieldsOpenState(newState);
|
setMappingNumberingRulesOpenState(newState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
aria-expanded={mappingNumberingRulesOpenState[index]}
|
||||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
disabled={numberingRulesLoading || numberingRules.length === 0}
|
||||||
>
|
>
|
||||||
{mapping.sourceField
|
{mapping.numberingRuleId
|
||||||
? (() => {
|
? (() => {
|
||||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<Sparkles className="h-3 w-3 text-purple-500" />
|
||||||
<span className="truncate font-medium">
|
<span className="truncate font-medium">
|
||||||
{field?.label || mapping.sourceField}
|
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId}
|
||||||
</span>
|
</span>
|
||||||
{field?.label && field.label !== field.name && (
|
|
||||||
<span className="text-muted-foreground font-mono text-xs">
|
|
||||||
{field.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
: "소스 필드 선택"}
|
: "채번 규칙 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -1222,37 +1424,36 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
<CommandInput placeholder="채번 규칙 검색..." className="text-xs sm:text-sm" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="text-xs sm:text-sm">
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
필드를 찾을 수 없습니다.
|
채번 규칙을 찾을 수 없습니다.
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{sourceFields.map((field) => (
|
{numberingRules.map((rule) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={field.name}
|
key={rule.ruleId}
|
||||||
value={field.name}
|
value={rule.ruleId}
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
handleMappingChange(index, "sourceField", currentValue || null);
|
handleMappingChange(index, "numberingRuleId", currentValue);
|
||||||
const newState = [...mappingSourceFieldsOpenState];
|
const newState = [...mappingNumberingRulesOpenState];
|
||||||
newState[index] = false;
|
newState[index] = false;
|
||||||
setMappingSourceFieldsOpenState(newState);
|
setMappingNumberingRulesOpenState(newState);
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
mapping.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
<span className="font-medium">{rule.ruleName}</span>
|
||||||
{field.label && field.label !== field.name && (
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
<span className="text-muted-foreground font-mono text-[10px]">
|
{rule.ruleId}
|
||||||
{field.name}
|
{rule.tableName && ` - ${rule.tableName}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1261,11 +1462,13 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
{numberingRules.length === 0 && !numberingRulesLoading && (
|
||||||
{hasRestAPISource && (
|
<p className="mt-1 text-xs text-orange-600">
|
||||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요.
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
<ArrowRight className="h-4 w-4 text-green-600" />
|
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||||
|
|
@ -1400,18 +1603,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 정적 값 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.staticValue || ""}
|
|
||||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
|
||||||
placeholder="소스 필드 대신 고정 값 사용"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1428,9 +1619,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
|
|
||||||
{/* 안내 */}
|
{/* 안내 */}
|
||||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||||
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
<p>테이블과 필드는 실제 데이터베이스에서 조회됩니다.</p>
|
||||||
<br />
|
<p className="mt-1">값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)</p>
|
||||||
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -671,9 +671,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||||
deletedItem.id,
|
deletedItem.id,
|
||||||
screenData.screenInfo.tableName,
|
screenData.screenInfo.tableName,
|
||||||
|
modalState.screenId || screenData.screenInfo?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
|
||||||
|
|
@ -1676,7 +1676,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
try {
|
try {
|
||||||
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
||||||
|
|
||||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert("삭제되었습니다.");
|
alert("삭제되었습니다.");
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -202,14 +202,19 @@ export class DynamicFormApi {
|
||||||
* 실제 테이블에서 폼 데이터 삭제
|
* 실제 테이블에서 폼 데이터 삭제
|
||||||
* @param id 레코드 ID
|
* @param id 레코드 ID
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
|
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
||||||
* @returns 삭제 결과
|
* @returns 삭제 결과
|
||||||
*/
|
*/
|
||||||
static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
|
static async deleteFormDataFromTable(
|
||||||
|
id: string | number,
|
||||||
|
tableName: string,
|
||||||
|
screenId?: number
|
||||||
|
): Promise<ApiResponse<void>> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
|
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId });
|
||||||
|
|
||||||
await apiClient.delete(`/dynamic-form/${id}`, {
|
await apiClient.delete(`/dynamic-form/${id}`, {
|
||||||
data: { tableName },
|
data: { tableName, screenId },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
||||||
|
|
@ -556,6 +561,192 @@ export class DynamicFormApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 마스터-디테일 엑셀 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조회
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @returns 마스터-디테일 관계 정보 (null이면 마스터-디테일 구조 아님)
|
||||||
|
*/
|
||||||
|
static async getMasterDetailRelation(screenId: number): Promise<ApiResponse<MasterDetailRelation | null>> {
|
||||||
|
try {
|
||||||
|
console.log("🔍 마스터-디테일 관계 조회:", screenId);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/data/master-detail/relation/${screenId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data?.data || null,
|
||||||
|
message: response.data?.message || "조회 완료",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 관계 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param filters 필터 조건
|
||||||
|
* @returns JOIN된 플랫 데이터
|
||||||
|
*/
|
||||||
|
static async getMasterDetailDownloadData(
|
||||||
|
screenId: number,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<ApiResponse<MasterDetailDownloadData>> {
|
||||||
|
try {
|
||||||
|
console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters });
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/download`, {
|
||||||
|
screenId,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: "데이터 조회 완료",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 다운로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 업로드
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param data 엑셀에서 읽은 플랫 데이터
|
||||||
|
* @returns 업로드 결과
|
||||||
|
*/
|
||||||
|
static async uploadMasterDetailData(
|
||||||
|
screenId: number,
|
||||||
|
data: Record<string, any>[]
|
||||||
|
): Promise<ApiResponse<MasterDetailUploadResult>> {
|
||||||
|
try {
|
||||||
|
console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length });
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/upload`, {
|
||||||
|
screenId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.data?.success,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: response.data?.message,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 업로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 간단 모드 엑셀 업로드
|
||||||
|
* - 마스터 정보는 UI에서 선택
|
||||||
|
* - 디테일 정보만 엑셀에서 업로드
|
||||||
|
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param detailData 디테일 데이터 배열
|
||||||
|
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
||||||
|
* @param numberingRuleId 채번 규칙 ID (optional)
|
||||||
|
* @param afterUploadFlowId 업로드 후 실행할 제어 ID (optional, 하위 호환성)
|
||||||
|
* @param afterUploadFlows 업로드 후 실행할 제어 목록 (optional)
|
||||||
|
* @returns 업로드 결과
|
||||||
|
*/
|
||||||
|
static async uploadMasterDetailSimple(
|
||||||
|
screenId: number,
|
||||||
|
detailData: Record<string, any>[],
|
||||||
|
masterFieldValues: Record<string, any>,
|
||||||
|
numberingRuleId?: string,
|
||||||
|
afterUploadFlowId?: string,
|
||||||
|
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
||||||
|
): Promise<ApiResponse<MasterDetailSimpleUploadResult>> {
|
||||||
|
try {
|
||||||
|
console.log("📤 마스터-디테일 간단 모드 업로드:", {
|
||||||
|
screenId,
|
||||||
|
detailRowCount: detailData.length,
|
||||||
|
masterFieldValues,
|
||||||
|
numberingRuleId,
|
||||||
|
afterUploadFlows: afterUploadFlows?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/upload-simple`, {
|
||||||
|
screenId,
|
||||||
|
detailData,
|
||||||
|
masterFieldValues,
|
||||||
|
numberingRuleId,
|
||||||
|
afterUploadFlowId,
|
||||||
|
afterUploadFlows,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.data?.success,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: response.data?.message,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 간단 모드 업로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 관계 타입
|
||||||
|
export interface MasterDetailRelation {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string;
|
||||||
|
detailFkColumn: string;
|
||||||
|
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 다운로드 데이터 타입
|
||||||
|
export interface MasterDetailDownloadData {
|
||||||
|
headers: string[];
|
||||||
|
columns: string[];
|
||||||
|
data: Record<string, any>[];
|
||||||
|
masterColumns: string[];
|
||||||
|
detailColumns: string[];
|
||||||
|
joinKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 업로드 결과 타입
|
||||||
|
export interface MasterDetailUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
masterUpdated: number;
|
||||||
|
detailInserted: number;
|
||||||
|
detailDeleted: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 간단 모드 업로드 결과 타입
|
||||||
|
export interface MasterDetailSimpleUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
detailInserted: number;
|
||||||
|
generatedKey: string; // 생성된 마스터 키
|
||||||
|
errors?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편의를 위한 기본 export
|
// 편의를 위한 기본 export
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ export const entityJoinApi = {
|
||||||
filterColumn?: string;
|
filterColumn?: string;
|
||||||
filterValue?: any;
|
filterValue?: any;
|
||||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}; // 🆕 중복 제거 설정
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||||
|
|
@ -99,6 +105,7 @@ export const entityJoinApi = {
|
||||||
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, // 🆕 제외 필터
|
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
||||||
|
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,6 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
||||||
// 🆕 연관 데이터 버튼 컴포넌트
|
// 🆕 연관 데이터 버튼 컴포넌트
|
||||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||||
|
|
||||||
// 🆕 피벗 그리드 컴포넌트
|
|
||||||
import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
# PivotGrid 컴포넌트 전체 구현 계획
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현
|
||||||
|
|
||||||
|
## 현재 상태: ✅ 모든 기능 구현 완료!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현된 기능 목록
|
||||||
|
|
||||||
|
### 1. 기본 피벗 테이블 ✅
|
||||||
|
- [x] 피벗 테이블 렌더링
|
||||||
|
- [x] 행/열 확장/축소
|
||||||
|
- [x] 합계/소계 표시
|
||||||
|
- [x] 전체 확장/축소 버튼
|
||||||
|
|
||||||
|
### 2. 필드 패널 (드래그앤드롭) ✅
|
||||||
|
- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터)
|
||||||
|
- [x] 각 영역에 배치된 필드 칩/태그 표시
|
||||||
|
- [x] 필드 제거 버튼 (X)
|
||||||
|
- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용)
|
||||||
|
- [x] 영역 간 필드 이동
|
||||||
|
- [x] 같은 영역 내 순서 변경
|
||||||
|
- [x] 드래그 시 시각적 피드백
|
||||||
|
|
||||||
|
### 3. 필드 선택기 (모달) ✅
|
||||||
|
- [x] 모달 열기/닫기
|
||||||
|
- [x] 사용 가능한 필드 목록
|
||||||
|
- [x] 필드 검색 기능
|
||||||
|
- [x] 필드별 영역 선택 드롭다운
|
||||||
|
- [x] 데이터 타입 아이콘 표시
|
||||||
|
- [x] 집계 함수 선택 (데이터 영역)
|
||||||
|
- [x] 표시 모드 선택 (데이터 영역)
|
||||||
|
|
||||||
|
### 4. 데이터 요약 (누계, % 모드) ✅
|
||||||
|
- [x] 절대값 표시 (기본)
|
||||||
|
- [x] 행 총계 대비 %
|
||||||
|
- [x] 열 총계 대비 %
|
||||||
|
- [x] 전체 총계 대비 %
|
||||||
|
- [x] 행/열 방향 누계
|
||||||
|
- [x] 이전 대비 차이
|
||||||
|
- [x] 이전 대비 % 차이
|
||||||
|
|
||||||
|
### 5. 필터링 ✅
|
||||||
|
- [x] 필터 팝업 컴포넌트 (FilterPopup)
|
||||||
|
- [x] 값 검색 기능
|
||||||
|
- [x] 체크박스 기반 값 선택
|
||||||
|
- [x] 포함/제외 모드
|
||||||
|
- [x] 전체 선택/해제
|
||||||
|
- [x] 선택된 항목 수 표시
|
||||||
|
|
||||||
|
### 6. Drill Down ✅
|
||||||
|
- [x] 셀 더블클릭 시 상세 데이터 모달
|
||||||
|
- [x] 원본 데이터 테이블 표시
|
||||||
|
- [x] 검색 기능
|
||||||
|
- [x] 정렬 기능
|
||||||
|
- [x] 페이지네이션
|
||||||
|
- [x] CSV/Excel 내보내기
|
||||||
|
|
||||||
|
### 7. Virtual Scrolling ✅
|
||||||
|
- [x] useVirtualScroll 훅 (행)
|
||||||
|
- [x] useVirtualColumnScroll 훅 (열)
|
||||||
|
- [x] useVirtual2DScroll 훅 (행+열)
|
||||||
|
- [x] overscan 버퍼 지원
|
||||||
|
|
||||||
|
### 8. Excel 내보내기 ✅
|
||||||
|
- [x] xlsx 라이브러리 사용
|
||||||
|
- [x] 피벗 데이터 Excel 내보내기
|
||||||
|
- [x] Drill Down 데이터 Excel 내보내기
|
||||||
|
- [x] CSV 내보내기 (기본)
|
||||||
|
- [x] 스타일링 (헤더, 데이터, 총계)
|
||||||
|
- [x] 숫자 포맷
|
||||||
|
|
||||||
|
### 9. 차트 통합 ✅
|
||||||
|
- [x] recharts 라이브러리 사용
|
||||||
|
- [x] 막대 차트
|
||||||
|
- [x] 누적 막대 차트
|
||||||
|
- [x] 선 차트
|
||||||
|
- [x] 영역 차트
|
||||||
|
- [x] 파이 차트
|
||||||
|
- [x] 범례 표시
|
||||||
|
- [x] 커스텀 툴팁
|
||||||
|
- [x] 차트 토글 버튼
|
||||||
|
|
||||||
|
### 10. 조건부 서식 (Conditional Formatting) ✅
|
||||||
|
- [x] Color Scale (색상 그라데이션)
|
||||||
|
- [x] Data Bar (데이터 막대)
|
||||||
|
- [x] Icon Set (아이콘)
|
||||||
|
- [x] Cell Value (조건 기반 스타일)
|
||||||
|
- [x] ConfigPanel에서 설정 UI
|
||||||
|
|
||||||
|
### 11. 상태 저장/복원 ✅
|
||||||
|
- [x] usePivotState 훅
|
||||||
|
- [x] localStorage/sessionStorage 지원
|
||||||
|
- [x] 자동 저장 (디바운스)
|
||||||
|
|
||||||
|
### 12. ConfigPanel 고도화 ✅
|
||||||
|
- [x] 데이터 소스 설정 (테이블 선택)
|
||||||
|
- [x] 필드별 영역 설정 (행, 열, 데이터, 필터)
|
||||||
|
- [x] 총계 옵션 설정
|
||||||
|
- [x] 스타일 설정 (테마, 교차 색상 등)
|
||||||
|
- [x] 내보내기 설정 (Excel/CSV)
|
||||||
|
- [x] 차트 설정 UI
|
||||||
|
- [x] 필드 선택기 설정 UI
|
||||||
|
- [x] 조건부 서식 설정 UI
|
||||||
|
- [x] 크기 설정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
pivot-grid/
|
||||||
|
├── components/
|
||||||
|
│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭)
|
||||||
|
│ ├── FieldChooser.tsx # 필드 선택기 모달
|
||||||
|
│ ├── DrillDownModal.tsx # Drill Down 모달
|
||||||
|
│ ├── FilterPopup.tsx # 필터 팝업
|
||||||
|
│ ├── PivotChart.tsx # 차트 컴포넌트
|
||||||
|
│ └── index.ts # 내보내기
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useVirtualScroll.ts # 가상 스크롤 훅
|
||||||
|
│ ├── usePivotState.ts # 상태 저장 훅
|
||||||
|
│ └── index.ts # 내보내기
|
||||||
|
├── utils/
|
||||||
|
│ ├── aggregation.ts # 집계 함수
|
||||||
|
│ ├── pivotEngine.ts # 피벗 엔진
|
||||||
|
│ ├── exportExcel.ts # Excel 내보내기
|
||||||
|
│ ├── conditionalFormat.ts # 조건부 서식
|
||||||
|
│ └── index.ts # 내보내기
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── PivotGridComponent.tsx # 메인 컴포넌트
|
||||||
|
├── PivotGridConfigPanel.tsx # 설정 패널
|
||||||
|
├── PivotGridRenderer.tsx # 렌더러
|
||||||
|
├── index.ts # 모듈 내보내기
|
||||||
|
└── PLAN.md # 이 파일
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 후순위 기능 (선택적)
|
||||||
|
|
||||||
|
다음 기능들은 필요 시 추가 구현 가능:
|
||||||
|
|
||||||
|
### 데이터 바인딩 확장
|
||||||
|
- [ ] OLAP Data Source 연동 (복잡)
|
||||||
|
- [ ] GraphQL 연동
|
||||||
|
- [ ] 실시간 데이터 업데이트 (WebSocket)
|
||||||
|
|
||||||
|
### 고급 기능
|
||||||
|
- [ ] 피벗 테이블 병합 (여러 데이터 소스)
|
||||||
|
- [ ] 계산 필드 (커스텀 수식)
|
||||||
|
- [ ] 데이터 정렬 옵션 강화
|
||||||
|
- [ ] 그룹핑 옵션 (날짜 그룹핑 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료일: 2026-01-08
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from "react";
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PivotGridProps,
|
PivotGridProps,
|
||||||
|
|
@ -15,8 +15,15 @@ import {
|
||||||
PivotFlatRow,
|
PivotFlatRow,
|
||||||
PivotCellValue,
|
PivotCellValue,
|
||||||
PivotGridState,
|
PivotGridState,
|
||||||
|
PivotAreaType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { processPivotData, pathToKey } from "./utils/pivotEngine";
|
import { processPivotData, pathToKey } from "./utils/pivotEngine";
|
||||||
|
import { exportPivotToExcel } from "./utils/exportExcel";
|
||||||
|
import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat";
|
||||||
|
import { FieldPanel } from "./components/FieldPanel";
|
||||||
|
import { FieldChooser } from "./components/FieldChooser";
|
||||||
|
import { DrillDownModal } from "./components/DrillDownModal";
|
||||||
|
import { PivotChart } from "./components/PivotChart";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -25,6 +32,9 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
LayoutGrid,
|
||||||
|
FileSpreadsheet,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -79,13 +89,22 @@ interface DataCellProps {
|
||||||
values: PivotCellValue[];
|
values: PivotCellValue[];
|
||||||
isTotal?: boolean;
|
isTotal?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
conditionalStyle?: CellFormatStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataCell: React.FC<DataCellProps> = ({
|
const DataCell: React.FC<DataCellProps> = ({
|
||||||
values,
|
values,
|
||||||
isTotal = false,
|
isTotal = false,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
|
conditionalStyle,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 조건부 서식 스타일 계산
|
||||||
|
const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {};
|
||||||
|
const hasDataBar = conditionalStyle?.dataBarWidth !== undefined;
|
||||||
|
const icon = conditionalStyle?.icon;
|
||||||
|
|
||||||
if (!values || values.length === 0) {
|
if (!values || values.length === 0) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
|
|
@ -94,6 +113,9 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
"px-2 py-1.5 text-right text-sm",
|
"px-2 py-1.5 text-right text-sm",
|
||||||
isTotal && "bg-primary/5 font-medium"
|
isTotal && "bg-primary/5 font-medium"
|
||||||
)}
|
)}
|
||||||
|
style={cellStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -105,14 +127,29 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-b border-border",
|
"border-r border-b border-border relative",
|
||||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||||
isTotal && "bg-primary/5 font-medium",
|
isTotal && "bg-primary/5 font-medium",
|
||||||
onClick && "cursor-pointer hover:bg-accent/50"
|
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
|
style={cellStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
{values[0].formattedValue}
|
{/* Data Bar */}
|
||||||
|
{hasDataBar && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 opacity-30"
|
||||||
|
style={{
|
||||||
|
width: `${conditionalStyle?.dataBarWidth}%`,
|
||||||
|
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10 flex items-center justify-end gap-1">
|
||||||
|
{icon && <span>{icon}</span>}
|
||||||
|
{values[0].formattedValue}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -124,14 +161,28 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
<td
|
<td
|
||||||
key={idx}
|
key={idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-b border-border",
|
"border-r border-b border-border relative",
|
||||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||||
isTotal && "bg-primary/5 font-medium",
|
isTotal && "bg-primary/5 font-medium",
|
||||||
onClick && "cursor-pointer hover:bg-accent/50"
|
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
|
style={cellStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
{val.formattedValue}
|
{hasDataBar && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 opacity-30"
|
||||||
|
style={{
|
||||||
|
width: `${conditionalStyle?.dataBarWidth}%`,
|
||||||
|
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10 flex items-center justify-end gap-1">
|
||||||
|
{icon && <span>{icon}</span>}
|
||||||
|
{val.formattedValue}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -142,7 +193,7 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
|
|
||||||
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
title,
|
title,
|
||||||
fields = [],
|
fields: initialFields = [],
|
||||||
totals = {
|
totals = {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
showColumnGrandTotals: true,
|
showColumnGrandTotals: true,
|
||||||
|
|
@ -157,24 +208,49 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
alternateRowColors: true,
|
alternateRowColors: true,
|
||||||
highlightTotals: true,
|
highlightTotals: true,
|
||||||
},
|
},
|
||||||
|
fieldChooser,
|
||||||
|
chart: chartConfig,
|
||||||
allowExpandAll = true,
|
allowExpandAll = true,
|
||||||
height = "auto",
|
height = "auto",
|
||||||
maxHeight,
|
maxHeight,
|
||||||
exportConfig,
|
exportConfig,
|
||||||
data: externalData,
|
data: externalData,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
|
onCellDoubleClick,
|
||||||
|
onFieldDrop,
|
||||||
onExpandChange,
|
onExpandChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 디버깅 로그
|
||||||
|
console.log("🔶 PivotGridComponent props:", {
|
||||||
|
title,
|
||||||
|
hasExternalData: !!externalData,
|
||||||
|
externalDataLength: externalData?.length,
|
||||||
|
initialFieldsLength: initialFields?.length,
|
||||||
|
});
|
||||||
// ==================== 상태 ====================
|
// ==================== 상태 ====================
|
||||||
|
|
||||||
|
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
|
||||||
const [pivotState, setPivotState] = useState<PivotGridState>({
|
const [pivotState, setPivotState] = useState<PivotGridState>({
|
||||||
expandedRowPaths: [],
|
expandedRowPaths: [],
|
||||||
expandedColumnPaths: [],
|
expandedColumnPaths: [],
|
||||||
sortConfig: null,
|
sortConfig: null,
|
||||||
filterConfig: {},
|
filterConfig: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [showFieldPanel, setShowFieldPanel] = useState(true);
|
||||||
|
const [showFieldChooser, setShowFieldChooser] = useState(false);
|
||||||
|
const [drillDownData, setDrillDownData] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
cellData: PivotCellData | null;
|
||||||
|
}>({ open: false, cellData: null });
|
||||||
|
const [showChart, setShowChart] = useState(chartConfig?.enabled || false);
|
||||||
|
|
||||||
|
// 외부 fields 변경 시 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialFields.length > 0) {
|
||||||
|
setFields(initialFields);
|
||||||
|
}
|
||||||
|
}, [initialFields]);
|
||||||
|
|
||||||
// 데이터
|
// 데이터
|
||||||
const data = externalData || [];
|
const data = externalData || [];
|
||||||
|
|
@ -205,6 +281,43 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
[fields]
|
[fields]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filterFields = useMemo(
|
||||||
|
() =>
|
||||||
|
fields
|
||||||
|
.filter((f) => f.area === "filter" && f.visible !== false)
|
||||||
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
||||||
|
[fields]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 사용 가능한 필드 목록 (FieldChooser용)
|
||||||
|
const availableFields = useMemo(() => {
|
||||||
|
if (data.length === 0) return [];
|
||||||
|
|
||||||
|
const sampleRow = data[0];
|
||||||
|
return Object.keys(sampleRow).map((key) => {
|
||||||
|
const existingField = fields.find((f) => f.field === key);
|
||||||
|
const value = sampleRow[key];
|
||||||
|
|
||||||
|
// 데이터 타입 추론
|
||||||
|
let dataType: "string" | "number" | "date" | "boolean" = "string";
|
||||||
|
if (typeof value === "number") dataType = "number";
|
||||||
|
else if (typeof value === "boolean") dataType = "boolean";
|
||||||
|
else if (value instanceof Date) dataType = "date";
|
||||||
|
else if (typeof value === "string") {
|
||||||
|
// 날짜 문자열 감지
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
field: key,
|
||||||
|
caption: existingField?.caption || key,
|
||||||
|
dataType,
|
||||||
|
isSelected: existingField?.visible !== false,
|
||||||
|
currentArea: existingField?.area,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [data, fields]);
|
||||||
|
|
||||||
// ==================== 피벗 처리 ====================
|
// ==================== 피벗 처리 ====================
|
||||||
|
|
||||||
const pivotResult = useMemo<PivotResult | null>(() => {
|
const pivotResult = useMemo<PivotResult | null>(() => {
|
||||||
|
|
@ -212,16 +325,83 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleFields = fields.filter((f) => f.visible !== false);
|
||||||
|
if (visibleFields.filter((f) => f.area !== "filter").length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return processPivotData(
|
return processPivotData(
|
||||||
data,
|
data,
|
||||||
fields,
|
visibleFields,
|
||||||
pivotState.expandedRowPaths,
|
pivotState.expandedRowPaths,
|
||||||
pivotState.expandedColumnPaths
|
pivotState.expandedColumnPaths
|
||||||
);
|
);
|
||||||
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
|
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
|
||||||
|
|
||||||
|
// 조건부 서식용 전체 값 수집
|
||||||
|
const allCellValues = useMemo(() => {
|
||||||
|
if (!pivotResult) return new Map<string, number[]>();
|
||||||
|
|
||||||
|
const valuesByField = new Map<string, number[]>();
|
||||||
|
|
||||||
|
// 데이터 매트릭스에서 모든 값 수집
|
||||||
|
pivotResult.dataMatrix.forEach((values) => {
|
||||||
|
values.forEach((val) => {
|
||||||
|
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
||||||
|
const existing = valuesByField.get(val.field) || [];
|
||||||
|
existing.push(val.value);
|
||||||
|
valuesByField.set(val.field, existing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 행 총계 값 수집
|
||||||
|
pivotResult.grandTotals.row.forEach((values) => {
|
||||||
|
values.forEach((val) => {
|
||||||
|
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
||||||
|
const existing = valuesByField.get(val.field) || [];
|
||||||
|
existing.push(val.value);
|
||||||
|
valuesByField.set(val.field, existing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 열 총계 값 수집
|
||||||
|
pivotResult.grandTotals.column.forEach((values) => {
|
||||||
|
values.forEach((val) => {
|
||||||
|
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
||||||
|
const existing = valuesByField.get(val.field) || [];
|
||||||
|
existing.push(val.value);
|
||||||
|
valuesByField.set(val.field, existing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return valuesByField;
|
||||||
|
}, [pivotResult]);
|
||||||
|
|
||||||
|
// 조건부 서식 스타일 계산 헬퍼
|
||||||
|
const getCellConditionalStyle = useCallback(
|
||||||
|
(value: number | undefined, field: string): CellFormatStyle => {
|
||||||
|
if (!style?.conditionalFormats || style.conditionalFormats.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const allValues = allCellValues.get(field) || [];
|
||||||
|
return getConditionalStyle(value, field, style.conditionalFormats, allValues);
|
||||||
|
},
|
||||||
|
[style?.conditionalFormats, allCellValues]
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== 이벤트 핸들러 ====================
|
// ==================== 이벤트 핸들러 ====================
|
||||||
|
|
||||||
|
// 필드 변경
|
||||||
|
const handleFieldsChange = useCallback(
|
||||||
|
(newFields: PivotFieldConfig[]) => {
|
||||||
|
setFields(newFields);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// 행 확장/축소
|
// 행 확장/축소
|
||||||
const handleToggleRowExpand = useCallback(
|
const handleToggleRowExpand = useCallback(
|
||||||
(path: string[]) => {
|
(path: string[]) => {
|
||||||
|
|
@ -256,7 +436,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
if (!pivotResult) return;
|
if (!pivotResult) return;
|
||||||
|
|
||||||
const allRowPaths: string[][] = [];
|
const allRowPaths: string[][] = [];
|
||||||
|
|
||||||
pivotResult.flatRows.forEach((row) => {
|
pivotResult.flatRows.forEach((row) => {
|
||||||
if (row.hasChildren) {
|
if (row.hasChildren) {
|
||||||
allRowPaths.push(row.path);
|
allRowPaths.push(row.path);
|
||||||
|
|
@ -296,6 +475,27 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
[onCellClick]
|
[onCellClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 셀 더블클릭 (Drill Down)
|
||||||
|
const handleCellDoubleClick = useCallback(
|
||||||
|
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
|
||||||
|
const cellData: PivotCellData = {
|
||||||
|
value: values[0]?.value,
|
||||||
|
rowPath,
|
||||||
|
columnPath: colPath,
|
||||||
|
field: values[0]?.field,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drill Down 모달 열기
|
||||||
|
setDrillDownData({ open: true, cellData });
|
||||||
|
|
||||||
|
// 외부 콜백 호출
|
||||||
|
if (onCellDoubleClick) {
|
||||||
|
onCellDoubleClick(cellData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCellDoubleClick]
|
||||||
|
);
|
||||||
|
|
||||||
// CSV 내보내기
|
// CSV 내보내기
|
||||||
const handleExportCSV = useCallback(() => {
|
const handleExportCSV = useCallback(() => {
|
||||||
if (!pivotResult) return;
|
if (!pivotResult) return;
|
||||||
|
|
@ -354,6 +554,20 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
link.click();
|
link.click();
|
||||||
}, [pivotResult, totals, title]);
|
}, [pivotResult, totals, title]);
|
||||||
|
|
||||||
|
// Excel 내보내기
|
||||||
|
const handleExportExcel = useCallback(async () => {
|
||||||
|
if (!pivotResult) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportPivotToExcel(pivotResult, fields, totals, {
|
||||||
|
fileName: title || "pivot_export",
|
||||||
|
title: title,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Excel 내보내기 실패:", error);
|
||||||
|
}
|
||||||
|
}, [pivotResult, fields, totals, title]);
|
||||||
|
|
||||||
// ==================== 렌더링 ====================
|
// ==================== 렌더링 ====================
|
||||||
|
|
||||||
// 빈 상태
|
// 빈 상태
|
||||||
|
|
@ -374,20 +588,51 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필드 미설정
|
// 필드 미설정
|
||||||
if (fields.length === 0) {
|
const hasActiveFields = fields.some(
|
||||||
|
(f) => f.visible !== false && f.area !== "filter"
|
||||||
|
);
|
||||||
|
if (!hasActiveFields) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center",
|
"flex flex-col",
|
||||||
"p-8 text-center text-muted-foreground",
|
"border border-border rounded-lg overflow-hidden bg-background"
|
||||||
"border border-dashed border-border rounded-lg"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
{/* 필드 패널 */}
|
||||||
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
<FieldPanel
|
||||||
<p className="text-xs mt-1">
|
fields={fields}
|
||||||
행, 열, 데이터 영역에 필드를 배치해주세요
|
onFieldsChange={handleFieldsChange}
|
||||||
</p>
|
collapsed={!showFieldPanel}
|
||||||
|
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 안내 메시지 */}
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
|
||||||
|
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
행, 열, 데이터 영역에 필드를 배치해주세요
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setShowFieldChooser(true)}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4 mr-2" />
|
||||||
|
필드 선택기 열기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 선택기 모달 */}
|
||||||
|
<FieldChooser
|
||||||
|
open={showFieldChooser}
|
||||||
|
onOpenChange={setShowFieldChooser}
|
||||||
|
availableFields={availableFields}
|
||||||
|
selectedFields={fields}
|
||||||
|
onFieldsChange={handleFieldsChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -416,6 +661,14 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
maxHeight: isFullscreen ? "none" : maxHeight,
|
maxHeight: isFullscreen ? "none" : maxHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */}
|
||||||
|
<FieldPanel
|
||||||
|
fields={fields}
|
||||||
|
onFieldsChange={handleFieldsChange}
|
||||||
|
collapsed={!showFieldPanel}
|
||||||
|
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 헤더 툴바 */}
|
{/* 헤더 툴바 */}
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -426,6 +679,30 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* 필드 선택기 버튼 */}
|
||||||
|
{fieldChooser?.enabled !== false && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={() => setShowFieldChooser(true)}
|
||||||
|
title="필드 선택기"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 패널 토글 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={() => setShowFieldPanel(!showFieldPanel)}
|
||||||
|
title={showFieldPanel ? "필드 패널 숨기기" : "필드 패널 보기"}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{allowExpandAll && (
|
{allowExpandAll && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -450,18 +727,43 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{exportConfig?.excel && (
|
{/* 차트 토글 */}
|
||||||
|
{chartConfig && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant={showChart ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2"
|
className="h-7 px-2"
|
||||||
onClick={handleExportCSV}
|
onClick={() => setShowChart(!showChart)}
|
||||||
title="CSV 내보내기"
|
title={showChart ? "차트 숨기기" : "차트 보기"}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 내보내기 버튼들 */}
|
||||||
|
{exportConfig?.excel && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
title="CSV 내보내기"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
title="Excel 내보내기"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -584,15 +886,25 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||||
const values = dataMatrix.get(cellKey) || [];
|
const values = dataMatrix.get(cellKey) || [];
|
||||||
|
|
||||||
|
// 조건부 서식 (첫 번째 값 기준)
|
||||||
|
const conditionalStyle =
|
||||||
|
values.length > 0 && values[0].field
|
||||||
|
? getCellConditionalStyle(values[0].value, values[0].field)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataCell
|
<DataCell
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
values={values}
|
values={values}
|
||||||
|
conditionalStyle={conditionalStyle}
|
||||||
onClick={
|
onClick={
|
||||||
onCellClick
|
onCellClick
|
||||||
? () => handleCellClick(row.path, col.path, values)
|
? () => handleCellClick(row.path, col.path, values)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onDoubleClick={() =>
|
||||||
|
handleCellDoubleClick(row.path, col.path, values)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -637,6 +949,38 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 차트 */}
|
||||||
|
{showChart && chartConfig && pivotResult && (
|
||||||
|
<PivotChart
|
||||||
|
pivotResult={pivotResult}
|
||||||
|
config={{
|
||||||
|
...chartConfig,
|
||||||
|
enabled: true,
|
||||||
|
}}
|
||||||
|
dataFields={dataFields}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 선택기 모달 */}
|
||||||
|
<FieldChooser
|
||||||
|
open={showFieldChooser}
|
||||||
|
onOpenChange={setShowFieldChooser}
|
||||||
|
availableFields={availableFields}
|
||||||
|
selectedFields={fields}
|
||||||
|
onFieldsChange={handleFieldsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drill Down 모달 */}
|
||||||
|
<DrillDownModal
|
||||||
|
open={drillDownData.open}
|
||||||
|
onOpenChange={(open) => setDrillDownData((prev) => ({ ...prev, open }))}
|
||||||
|
cellData={drillDownData.cellData}
|
||||||
|
data={data}
|
||||||
|
fields={fields}
|
||||||
|
rowFields={rowFields}
|
||||||
|
columnFields={columnFields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -431,14 +431,9 @@ const AreaFieldList: React.FC<AreaFieldListProps> = ({
|
||||||
) : (
|
) : (
|
||||||
availableColumns.map((col) => (
|
availableColumns.map((col) => (
|
||||||
<SelectItem key={col.column_name} value={col.column_name}>
|
<SelectItem key={col.column_name} value={col.column_name}>
|
||||||
<div className="flex items-center gap-2">
|
{col.column_comment
|
||||||
<span>{col.column_name}</span>
|
? `${col.column_name} (${col.column_comment})`
|
||||||
{col.column_comment && (
|
: col.column_name}
|
||||||
<span className="text-muted-foreground">
|
|
||||||
({col.column_comment})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -476,7 +471,8 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
setLoadingTables(true);
|
setLoadingTables(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/api/table-management/list");
|
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
|
||||||
|
const response = await apiClient.get("/table-management/tables");
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setTables(response.data.data || []);
|
setTables(response.data.data || []);
|
||||||
}
|
}
|
||||||
|
|
@ -499,8 +495,9 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
|
|
||||||
setLoadingColumns(true);
|
setLoadingColumns(true);
|
||||||
try {
|
try {
|
||||||
|
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/api/table-management/columns/${config.dataSource.tableName}`
|
`/table-management/tables/${config.dataSource.tableName}/columns`
|
||||||
);
|
);
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setColumns(response.data.data || []);
|
setColumns(response.data.data || []);
|
||||||
|
|
@ -550,14 +547,9 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
<SelectItem value="__none__">선택 안 함</SelectItem>
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
||||||
{tables.map((table) => (
|
{tables.map((table) => (
|
||||||
<SelectItem key={table.table_name} value={table.table_name}>
|
<SelectItem key={table.table_name} value={table.table_name}>
|
||||||
<div className="flex items-center gap-2">
|
{table.table_comment
|
||||||
<span>{table.table_name}</span>
|
? `${table.table_name} (${table.table_comment})`
|
||||||
{table.table_comment && (
|
: table.table_name}
|
||||||
<span className="text-muted-foreground">
|
|
||||||
({table.table_comment})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -717,6 +709,270 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* 차트 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">차트 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">차트 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.chart?.enabled === true}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
chart: {
|
||||||
|
...config.chart,
|
||||||
|
enabled: v,
|
||||||
|
type: config.chart?.type || "bar",
|
||||||
|
position: config.chart?.position || "bottom",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.chart?.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">차트 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={config.chart?.type || "bar"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
chart: {
|
||||||
|
...config.chart,
|
||||||
|
enabled: true,
|
||||||
|
type: v as "bar" | "line" | "area" | "pie" | "stackedBar",
|
||||||
|
position: config.chart?.position || "bottom",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="bar">막대 차트</SelectItem>
|
||||||
|
<SelectItem value="stackedBar">누적 막대 차트</SelectItem>
|
||||||
|
<SelectItem value="line">선 차트</SelectItem>
|
||||||
|
<SelectItem value="area">영역 차트</SelectItem>
|
||||||
|
<SelectItem value="pie">파이 차트</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">차트 높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.chart?.height || 300}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
chart: {
|
||||||
|
...config.chart,
|
||||||
|
enabled: true,
|
||||||
|
type: config.chart?.type || "bar",
|
||||||
|
position: config.chart?.position || "bottom",
|
||||||
|
height: Number(e.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">범례 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.chart?.showLegend !== false}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
chart: {
|
||||||
|
...config.chart,
|
||||||
|
enabled: true,
|
||||||
|
type: config.chart?.type || "bar",
|
||||||
|
position: config.chart?.position || "bottom",
|
||||||
|
showLegend: v,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 필드 선택기 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">필드 선택기 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">필드 선택기 활성화</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.fieldChooser?.enabled !== false}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
fieldChooser: { ...config.fieldChooser, enabled: v },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">검색 허용</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.fieldChooser?.allowSearch !== false}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
fieldChooser: { ...config.fieldChooser, allowSearch: v },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 조건부 서식 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">조건부 서식</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Color Scale (색상 그라데이션)</Label>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
config.style?.conditionalFormats?.some(
|
||||||
|
(r) => r.type === "colorScale"
|
||||||
|
) || false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const existingFormats = config.style?.conditionalFormats || [];
|
||||||
|
const filtered = existingFormats.filter(
|
||||||
|
(r) => r.type !== "colorScale"
|
||||||
|
);
|
||||||
|
updateConfig({
|
||||||
|
style: {
|
||||||
|
...config.style,
|
||||||
|
theme: config.style?.theme || "default",
|
||||||
|
headerStyle: config.style?.headerStyle || "default",
|
||||||
|
cellPadding: config.style?.cellPadding || "normal",
|
||||||
|
borderStyle: config.style?.borderStyle || "light",
|
||||||
|
conditionalFormats: v
|
||||||
|
? [
|
||||||
|
...filtered,
|
||||||
|
{
|
||||||
|
id: "colorScale-1",
|
||||||
|
type: "colorScale" as const,
|
||||||
|
colorScale: {
|
||||||
|
minColor: "#ff6b6b",
|
||||||
|
midColor: "#ffd93d",
|
||||||
|
maxColor: "#6bcb77",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Data Bar (데이터 막대)</Label>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
config.style?.conditionalFormats?.some(
|
||||||
|
(r) => r.type === "dataBar"
|
||||||
|
) || false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const existingFormats = config.style?.conditionalFormats || [];
|
||||||
|
const filtered = existingFormats.filter(
|
||||||
|
(r) => r.type !== "dataBar"
|
||||||
|
);
|
||||||
|
updateConfig({
|
||||||
|
style: {
|
||||||
|
...config.style,
|
||||||
|
theme: config.style?.theme || "default",
|
||||||
|
headerStyle: config.style?.headerStyle || "default",
|
||||||
|
cellPadding: config.style?.cellPadding || "normal",
|
||||||
|
borderStyle: config.style?.borderStyle || "light",
|
||||||
|
conditionalFormats: v
|
||||||
|
? [
|
||||||
|
...filtered,
|
||||||
|
{
|
||||||
|
id: "dataBar-1",
|
||||||
|
type: "dataBar" as const,
|
||||||
|
dataBar: {
|
||||||
|
color: "#3b82f6",
|
||||||
|
showValue: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Icon Set (아이콘)</Label>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
config.style?.conditionalFormats?.some(
|
||||||
|
(r) => r.type === "iconSet"
|
||||||
|
) || false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const existingFormats = config.style?.conditionalFormats || [];
|
||||||
|
const filtered = existingFormats.filter(
|
||||||
|
(r) => r.type !== "iconSet"
|
||||||
|
);
|
||||||
|
updateConfig({
|
||||||
|
style: {
|
||||||
|
...config.style,
|
||||||
|
theme: config.style?.theme || "default",
|
||||||
|
headerStyle: config.style?.headerStyle || "default",
|
||||||
|
cellPadding: config.style?.cellPadding || "normal",
|
||||||
|
borderStyle: config.style?.borderStyle || "light",
|
||||||
|
conditionalFormats: v
|
||||||
|
? [
|
||||||
|
...filtered,
|
||||||
|
{
|
||||||
|
id: "iconSet-1",
|
||||||
|
type: "iconSet" as const,
|
||||||
|
iconSet: {
|
||||||
|
type: "traffic",
|
||||||
|
thresholds: [33, 66],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.style?.conditionalFormats &&
|
||||||
|
config.style.conditionalFormats.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{config.style.conditionalFormats.length}개의 조건부 서식이
|
||||||
|
적용됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* 크기 설정 */}
|
{/* 크기 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-medium">크기 설정</Label>
|
<Label className="text-sm font-medium">크기 설정</Label>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,160 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import { PivotGridComponent } from "./PivotGridComponent";
|
import { PivotGridComponent } from "./PivotGridComponent";
|
||||||
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
||||||
|
import { PivotFieldConfig } from "./types";
|
||||||
|
|
||||||
|
// ==================== 샘플 데이터 (미리보기용) ====================
|
||||||
|
|
||||||
|
const SAMPLE_DATA = [
|
||||||
|
{ region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 },
|
||||||
|
{ region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 },
|
||||||
|
{ region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 },
|
||||||
|
{ region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 },
|
||||||
|
{ region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 },
|
||||||
|
{ region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 },
|
||||||
|
{ region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 },
|
||||||
|
{ region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 },
|
||||||
|
{ region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 },
|
||||||
|
{ region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 },
|
||||||
|
{ region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 },
|
||||||
|
{ region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 },
|
||||||
|
{ region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 },
|
||||||
|
{ region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 },
|
||||||
|
{ region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 },
|
||||||
|
{ region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 },
|
||||||
|
{ region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 },
|
||||||
|
{ region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 },
|
||||||
|
{ region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 },
|
||||||
|
{ region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 },
|
||||||
|
{ region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 },
|
||||||
|
{ region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 },
|
||||||
|
{ region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 },
|
||||||
|
{ region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 },
|
||||||
|
{ region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 },
|
||||||
|
{ region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 },
|
||||||
|
{ region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 },
|
||||||
|
{ region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 },
|
||||||
|
{ region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 },
|
||||||
|
{ region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 },
|
||||||
|
{ region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 },
|
||||||
|
{ region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 },
|
||||||
|
{ region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 },
|
||||||
|
{ region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 },
|
||||||
|
{ region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 },
|
||||||
|
{ region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SAMPLE_FIELDS: PivotFieldConfig[] = [
|
||||||
|
{
|
||||||
|
field: "region",
|
||||||
|
caption: "지역",
|
||||||
|
area: "row",
|
||||||
|
areaIndex: 0,
|
||||||
|
dataType: "string",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "product",
|
||||||
|
caption: "제품",
|
||||||
|
area: "row",
|
||||||
|
areaIndex: 1,
|
||||||
|
dataType: "string",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "quarter",
|
||||||
|
caption: "분기",
|
||||||
|
area: "column",
|
||||||
|
areaIndex: 0,
|
||||||
|
dataType: "string",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "sales",
|
||||||
|
caption: "매출",
|
||||||
|
area: "data",
|
||||||
|
areaIndex: 0,
|
||||||
|
dataType: "number",
|
||||||
|
summaryType: "sum",
|
||||||
|
format: { type: "number", precision: 0 },
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입)
|
||||||
|
*/
|
||||||
|
const PivotGridWrapper: React.FC<any> = (props) => {
|
||||||
|
// 컴포넌트 설정에서 값 추출
|
||||||
|
const componentConfig = props.componentConfig || props.config || {};
|
||||||
|
const configFields = componentConfig.fields || props.fields;
|
||||||
|
const configData = props.data;
|
||||||
|
|
||||||
|
// 디버깅 로그
|
||||||
|
console.log("🔷 PivotGridWrapper props:", {
|
||||||
|
isDesignMode: props.isDesignMode,
|
||||||
|
isInteractive: props.isInteractive,
|
||||||
|
hasComponentConfig: !!props.componentConfig,
|
||||||
|
hasConfig: !!props.config,
|
||||||
|
hasData: !!configData,
|
||||||
|
dataLength: configData?.length,
|
||||||
|
hasFields: !!configFields,
|
||||||
|
fieldsLength: configFields?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 디자인 모드 판단:
|
||||||
|
// 1. isDesignMode === true
|
||||||
|
// 2. isInteractive === false (편집 모드)
|
||||||
|
// 3. 데이터가 없는 경우
|
||||||
|
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||||
|
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
|
||||||
|
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||||
|
|
||||||
|
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||||
|
const usePreviewData = isDesignMode || !hasValidData;
|
||||||
|
|
||||||
|
// 최종 데이터/필드 결정
|
||||||
|
const finalData = usePreviewData ? SAMPLE_DATA : configData;
|
||||||
|
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||||
|
const finalTitle = usePreviewData
|
||||||
|
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||||
|
: (componentConfig.title || props.title);
|
||||||
|
|
||||||
|
console.log("🔷 PivotGridWrapper final:", {
|
||||||
|
isDesignMode,
|
||||||
|
usePreviewData,
|
||||||
|
finalDataLength: finalData?.length,
|
||||||
|
finalFieldsLength: finalFields?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 총계 설정
|
||||||
|
const totalsConfig = componentConfig.totals || props.totals || {
|
||||||
|
showRowGrandTotals: true,
|
||||||
|
showColumnGrandTotals: true,
|
||||||
|
showRowTotals: true,
|
||||||
|
showColumnTotals: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PivotGridComponent
|
||||||
|
title={finalTitle}
|
||||||
|
data={finalData}
|
||||||
|
fields={finalFields}
|
||||||
|
totals={totalsConfig}
|
||||||
|
style={componentConfig.style || props.style}
|
||||||
|
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||||
|
chart={componentConfig.chart || props.chart}
|
||||||
|
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||||
|
height={componentConfig.height || props.height || "400px"}
|
||||||
|
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||||
|
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||||
|
onCellClick={props.onCellClick}
|
||||||
|
onCellDoubleClick={props.onCellDoubleClick}
|
||||||
|
onFieldDrop={props.onFieldDrop}
|
||||||
|
onExpandChange={props.onExpandChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PivotGrid 컴포넌트 정의
|
* PivotGrid 컴포넌트 정의
|
||||||
|
|
@ -17,13 +171,15 @@ const PivotGridDefinition = createComponentDefinition({
|
||||||
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
||||||
category: ComponentCategory.DISPLAY,
|
category: ComponentCategory.DISPLAY,
|
||||||
webType: "text",
|
webType: "text",
|
||||||
component: PivotGridComponent,
|
component: PivotGridWrapper, // 래퍼 컴포넌트 사용
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
dataSource: {
|
dataSource: {
|
||||||
type: "table",
|
type: "table",
|
||||||
tableName: "",
|
tableName: "",
|
||||||
},
|
},
|
||||||
fields: [],
|
fields: SAMPLE_FIELDS,
|
||||||
|
// 미리보기용 샘플 데이터
|
||||||
|
sampleData: SAMPLE_DATA,
|
||||||
totals: {
|
totals: {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
showColumnGrandTotals: true,
|
showColumnGrandTotals: true,
|
||||||
|
|
@ -61,9 +217,75 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||||
static componentDefinition = PivotGridDefinition;
|
static componentDefinition = PivotGridDefinition;
|
||||||
|
|
||||||
render(): React.ReactElement {
|
render(): React.ReactElement {
|
||||||
|
const props = this.props as any;
|
||||||
|
|
||||||
|
// 컴포넌트 설정에서 값 추출
|
||||||
|
const componentConfig = props.componentConfig || props.config || {};
|
||||||
|
const configFields = componentConfig.fields || props.fields;
|
||||||
|
const configData = props.data;
|
||||||
|
|
||||||
|
// 디버깅 로그
|
||||||
|
console.log("🔷 PivotGridRenderer props:", {
|
||||||
|
isDesignMode: props.isDesignMode,
|
||||||
|
isInteractive: props.isInteractive,
|
||||||
|
hasComponentConfig: !!props.componentConfig,
|
||||||
|
hasConfig: !!props.config,
|
||||||
|
hasData: !!configData,
|
||||||
|
dataLength: configData?.length,
|
||||||
|
hasFields: !!configFields,
|
||||||
|
fieldsLength: configFields?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 디자인 모드 판단:
|
||||||
|
// 1. isDesignMode === true
|
||||||
|
// 2. isInteractive === false (편집 모드)
|
||||||
|
// 3. 데이터가 없는 경우
|
||||||
|
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||||
|
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
|
||||||
|
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||||
|
|
||||||
|
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||||
|
const usePreviewData = isDesignMode || !hasValidData;
|
||||||
|
|
||||||
|
// 최종 데이터/필드 결정
|
||||||
|
const finalData = usePreviewData ? SAMPLE_DATA : configData;
|
||||||
|
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||||
|
const finalTitle = usePreviewData
|
||||||
|
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||||
|
: (componentConfig.title || props.title);
|
||||||
|
|
||||||
|
console.log("🔷 PivotGridRenderer final:", {
|
||||||
|
isDesignMode,
|
||||||
|
usePreviewData,
|
||||||
|
finalDataLength: finalData?.length,
|
||||||
|
finalFieldsLength: finalFields?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 총계 설정
|
||||||
|
const totalsConfig = componentConfig.totals || props.totals || {
|
||||||
|
showRowGrandTotals: true,
|
||||||
|
showColumnGrandTotals: true,
|
||||||
|
showRowTotals: true,
|
||||||
|
showColumnTotals: true,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PivotGridComponent
|
<PivotGridComponent
|
||||||
{...this.props}
|
title={finalTitle}
|
||||||
|
data={finalData}
|
||||||
|
fields={finalFields}
|
||||||
|
totals={totalsConfig}
|
||||||
|
style={componentConfig.style || props.style}
|
||||||
|
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||||
|
chart={componentConfig.chart || props.chart}
|
||||||
|
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||||
|
height={componentConfig.height || props.height || "400px"}
|
||||||
|
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||||
|
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||||
|
onCellClick={props.onCellClick}
|
||||||
|
onCellDoubleClick={props.onCellDoubleClick}
|
||||||
|
onFieldDrop={props.onFieldDrop}
|
||||||
|
onExpandChange={props.onExpandChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,429 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DrillDownModal 컴포넌트
|
||||||
|
* 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PivotCellData, PivotFieldConfig } from "../types";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
interface DrillDownModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
cellData: PivotCellData | null;
|
||||||
|
data: any[]; // 전체 원본 데이터
|
||||||
|
fields: PivotFieldConfig[];
|
||||||
|
rowFields: PivotFieldConfig[];
|
||||||
|
columnFields: PivotFieldConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortConfig {
|
||||||
|
field: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const DrillDownModal: React.FC<DrillDownModalProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
cellData,
|
||||||
|
data,
|
||||||
|
fields,
|
||||||
|
rowFields,
|
||||||
|
columnFields,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
|
||||||
|
|
||||||
|
// 드릴다운 데이터 필터링
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!cellData || !data) return [];
|
||||||
|
|
||||||
|
// 행/열 경로에 해당하는 데이터 필터링
|
||||||
|
let result = data.filter((row) => {
|
||||||
|
// 행 경로 매칭
|
||||||
|
for (let i = 0; i < cellData.rowPath.length; i++) {
|
||||||
|
const field = rowFields[i];
|
||||||
|
if (field && String(row[field.field]) !== cellData.rowPath[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 열 경로 매칭
|
||||||
|
for (let i = 0; i < cellData.columnPath.length; i++) {
|
||||||
|
const field = columnFields[i];
|
||||||
|
if (field && String(row[field.field]) !== cellData.columnPath[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
result = result.filter((row) =>
|
||||||
|
Object.values(row).some((val) =>
|
||||||
|
String(val).toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (sortConfig) {
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
const aVal = a[sortConfig.field];
|
||||||
|
const bVal = b[sortConfig.field];
|
||||||
|
|
||||||
|
if (aVal === null || aVal === undefined) return 1;
|
||||||
|
if (bVal === null || bVal === undefined) return -1;
|
||||||
|
|
||||||
|
let comparison = 0;
|
||||||
|
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||||
|
comparison = aVal - bVal;
|
||||||
|
} else {
|
||||||
|
comparison = String(aVal).localeCompare(String(bVal));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortConfig.direction === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return filteredData.slice(start, start + pageSize);
|
||||||
|
}, [filteredData, currentPage, pageSize]);
|
||||||
|
|
||||||
|
// 표시할 컬럼 결정
|
||||||
|
const displayColumns = useMemo(() => {
|
||||||
|
// 모든 필드의 field명 수집
|
||||||
|
const fieldNames = new Set<string>();
|
||||||
|
|
||||||
|
// fields에서 가져오기
|
||||||
|
fields.forEach((f) => fieldNames.add(f.field));
|
||||||
|
|
||||||
|
// 데이터에서 추가 컬럼 가져오기
|
||||||
|
if (data.length > 0) {
|
||||||
|
Object.keys(data[0]).forEach((key) => fieldNames.add(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(fieldNames).map((fieldName) => {
|
||||||
|
const fieldConfig = fields.find((f) => f.field === fieldName);
|
||||||
|
return {
|
||||||
|
field: fieldName,
|
||||||
|
caption: fieldConfig?.caption || fieldName,
|
||||||
|
dataType: fieldConfig?.dataType || "string",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [fields, data]);
|
||||||
|
|
||||||
|
// 정렬 토글
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
setSortConfig((prev) => {
|
||||||
|
if (!prev || prev.field !== field) {
|
||||||
|
return { field, direction: "asc" };
|
||||||
|
}
|
||||||
|
if (prev.direction === "asc") {
|
||||||
|
return { field, direction: "desc" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSV 내보내기
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
if (filteredData.length === 0) return;
|
||||||
|
|
||||||
|
const headers = displayColumns.map((c) => c.caption);
|
||||||
|
const rows = filteredData.map((row) =>
|
||||||
|
displayColumns.map((c) => {
|
||||||
|
const val = row[c.field];
|
||||||
|
if (val === null || val === undefined) return "";
|
||||||
|
if (typeof val === "string" && val.includes(",")) {
|
||||||
|
return `"${val}"`;
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob(["\uFEFF" + csv], {
|
||||||
|
type: "text/csv;charset=utf-8;",
|
||||||
|
});
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 경로 표시
|
||||||
|
const pathDisplay = cellData
|
||||||
|
? [
|
||||||
|
...(cellData.rowPath.length > 0
|
||||||
|
? [`행: ${cellData.rowPath.join(" > ")}`]
|
||||||
|
: []),
|
||||||
|
...(cellData.columnPath.length > 0
|
||||||
|
? [`열: ${cellData.columnPath.join(" > ")}`]
|
||||||
|
: []),
|
||||||
|
].join(" | ")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>상세 데이터</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{pathDisplay || "선택한 셀의 원본 데이터"}
|
||||||
|
<span className="ml-2 text-primary font-medium">
|
||||||
|
({filteredData.length}건)
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 툴바 */}
|
||||||
|
<div className="flex items-center gap-2 py-2 border-b border-border">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPageSize(Number(v));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28 h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">10개씩</SelectItem>
|
||||||
|
<SelectItem value="20">20개씩</SelectItem>
|
||||||
|
<SelectItem value="50">50개씩</SelectItem>
|
||||||
|
<SelectItem value="100">100개씩</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={filteredData.length === 0}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<ScrollArea className="flex-1 -mx-6">
|
||||||
|
<div className="px-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{displayColumns.map((col) => (
|
||||||
|
<TableHead
|
||||||
|
key={col.field}
|
||||||
|
className="whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleSort(col.field)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{col.caption}</span>
|
||||||
|
{sortConfig?.field === col.field ? (
|
||||||
|
sortConfig.direction === "asc" ? (
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={displayColumns.length}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedData.map((row, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
{displayColumns.map((col) => (
|
||||||
|
<TableCell
|
||||||
|
key={col.field}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap",
|
||||||
|
col.dataType === "number" && "text-right tabular-nums"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCellValue(row[col.field], col.dataType)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{(currentPage - 1) * pageSize + 1} -{" "}
|
||||||
|
{Math.min(currentPage * pageSize, filteredData.length)} /{" "}
|
||||||
|
{filteredData.length}건
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="px-3 text-sm">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 유틸리티 ====================
|
||||||
|
|
||||||
|
function formatCellValue(value: any, dataType: string): string {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
if (dataType === "number") {
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) return String(value);
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType === "date") {
|
||||||
|
try {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date.toLocaleDateString("ko-KR");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 원본 반환
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DrillDownModal;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,441 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FieldChooser 컴포넌트
|
||||||
|
* 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Columns,
|
||||||
|
Rows,
|
||||||
|
BarChart3,
|
||||||
|
GripVertical,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Type,
|
||||||
|
Hash,
|
||||||
|
Calendar,
|
||||||
|
ToggleLeft,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
interface AvailableField {
|
||||||
|
field: string;
|
||||||
|
caption: string;
|
||||||
|
dataType: "string" | "number" | "date" | "boolean";
|
||||||
|
isSelected: boolean;
|
||||||
|
currentArea?: PivotAreaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldChooserProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
availableFields: AvailableField[];
|
||||||
|
selectedFields: PivotFieldConfig[];
|
||||||
|
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 영역 설정 ====================
|
||||||
|
|
||||||
|
const AREA_OPTIONS: {
|
||||||
|
value: PivotAreaType | "none";
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{ value: "none", label: "사용 안함", icon: <Minus className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "filter", label: "필터", icon: <Filter className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "row", label: "행", icon: <Rows className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "column", label: "열", icon: <Columns className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "data", label: "데이터", icon: <BarChart3 className="h-3.5 w-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [
|
||||||
|
{ value: "sum", label: "합계" },
|
||||||
|
{ value: "count", label: "개수" },
|
||||||
|
{ value: "avg", label: "평균" },
|
||||||
|
{ value: "min", label: "최소" },
|
||||||
|
{ value: "max", label: "최대" },
|
||||||
|
{ value: "countDistinct", label: "고유 개수" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
||||||
|
{ value: "absoluteValue", label: "절대값" },
|
||||||
|
{ value: "percentOfRowTotal", label: "행 총계 %" },
|
||||||
|
{ value: "percentOfColumnTotal", label: "열 총계 %" },
|
||||||
|
{ value: "percentOfGrandTotal", label: "전체 총계 %" },
|
||||||
|
{ value: "runningTotalByRow", label: "행 누계" },
|
||||||
|
{ value: "runningTotalByColumn", label: "열 누계" },
|
||||||
|
{ value: "differenceFromPrevious", label: "이전 대비 차이" },
|
||||||
|
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
string: <Type className="h-3.5 w-3.5" />,
|
||||||
|
number: <Hash className="h-3.5 w-3.5" />,
|
||||||
|
date: <Calendar className="h-3.5 w-3.5" />,
|
||||||
|
boolean: <ToggleLeft className="h-3.5 w-3.5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 필드 아이템 ====================
|
||||||
|
|
||||||
|
interface FieldItemProps {
|
||||||
|
field: AvailableField;
|
||||||
|
config?: PivotFieldConfig;
|
||||||
|
onAreaChange: (area: PivotAreaType | "none") => void;
|
||||||
|
onSummaryChange?: (summary: AggregationType) => void;
|
||||||
|
onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldItem: React.FC<FieldItemProps> = ({
|
||||||
|
field,
|
||||||
|
config,
|
||||||
|
onAreaChange,
|
||||||
|
onSummaryChange,
|
||||||
|
onDisplayModeChange,
|
||||||
|
}) => {
|
||||||
|
const currentArea = config?.area || "none";
|
||||||
|
const isSelected = currentArea !== "none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-2 rounded-md border",
|
||||||
|
"transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/5 border-primary/30"
|
||||||
|
: "bg-background border-border hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 데이터 타입 아이콘 */}
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{DATA_TYPE_ICONS[field.dataType] || <Type className="h-3.5 w-3.5" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드명 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{field.caption}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{field.field}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 영역 선택 */}
|
||||||
|
<Select
|
||||||
|
value={currentArea}
|
||||||
|
onValueChange={(value) => onAreaChange(value as PivotAreaType | "none")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AREA_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{option.icon}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 집계 함수 선택 (데이터 영역인 경우) */}
|
||||||
|
{currentArea === "data" && onSummaryChange && (
|
||||||
|
<Select
|
||||||
|
value={config?.summaryType || "sum"}
|
||||||
|
onValueChange={(value) => onSummaryChange(value as AggregationType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUMMARY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 표시 모드 선택 (데이터 영역인 경우) */}
|
||||||
|
{currentArea === "data" && onDisplayModeChange && (
|
||||||
|
<Select
|
||||||
|
value={config?.summaryDisplayMode || "absoluteValue"}
|
||||||
|
onValueChange={(value) => onDisplayModeChange(value as SummaryDisplayMode)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DISPLAY_MODE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const FieldChooser: React.FC<FieldChooserProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
availableFields,
|
||||||
|
selectedFields,
|
||||||
|
onFieldsChange,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">(
|
||||||
|
"all"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 필터링된 필드 목록
|
||||||
|
const filteredFields = useMemo(() => {
|
||||||
|
let result = availableFields;
|
||||||
|
|
||||||
|
// 검색어 필터
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(f) =>
|
||||||
|
f.caption.toLowerCase().includes(query) ||
|
||||||
|
f.field.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 상태 필터
|
||||||
|
if (filterType === "selected") {
|
||||||
|
result = result.filter((f) =>
|
||||||
|
selectedFields.some((sf) => sf.field === f.field && sf.visible !== false)
|
||||||
|
);
|
||||||
|
} else if (filterType === "unselected") {
|
||||||
|
result = result.filter(
|
||||||
|
(f) =>
|
||||||
|
!selectedFields.some(
|
||||||
|
(sf) => sf.field === f.field && sf.visible !== false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [availableFields, selectedFields, searchQuery, filterType]);
|
||||||
|
|
||||||
|
// 필드 영역 변경
|
||||||
|
const handleAreaChange = (
|
||||||
|
field: AvailableField,
|
||||||
|
area: PivotAreaType | "none"
|
||||||
|
) => {
|
||||||
|
const existingConfig = selectedFields.find((f) => f.field === field.field);
|
||||||
|
|
||||||
|
if (area === "none") {
|
||||||
|
// 필드 제거 또는 숨기기
|
||||||
|
if (existingConfig) {
|
||||||
|
const newFields = selectedFields.map((f) =>
|
||||||
|
f.field === field.field ? { ...f, visible: false } : f
|
||||||
|
);
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 필드 추가 또는 영역 변경
|
||||||
|
if (existingConfig) {
|
||||||
|
const newFields = selectedFields.map((f) =>
|
||||||
|
f.field === field.field
|
||||||
|
? { ...f, area, visible: true }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
} else {
|
||||||
|
// 새 필드 추가
|
||||||
|
const newField: PivotFieldConfig = {
|
||||||
|
field: field.field,
|
||||||
|
caption: field.caption,
|
||||||
|
area,
|
||||||
|
dataType: field.dataType,
|
||||||
|
visible: true,
|
||||||
|
summaryType: area === "data" ? "sum" : undefined,
|
||||||
|
areaIndex: selectedFields.filter((f) => f.area === area).length,
|
||||||
|
};
|
||||||
|
onFieldsChange([...selectedFields, newField]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 집계 함수 변경
|
||||||
|
const handleSummaryChange = (
|
||||||
|
field: AvailableField,
|
||||||
|
summaryType: AggregationType
|
||||||
|
) => {
|
||||||
|
const newFields = selectedFields.map((f) =>
|
||||||
|
f.field === field.field ? { ...f, summaryType } : f
|
||||||
|
);
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 표시 모드 변경
|
||||||
|
const handleDisplayModeChange = (
|
||||||
|
field: AvailableField,
|
||||||
|
displayMode: SummaryDisplayMode
|
||||||
|
) => {
|
||||||
|
const newFields = selectedFields.map((f) =>
|
||||||
|
f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f
|
||||||
|
);
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 필드 선택 해제
|
||||||
|
const handleClearAll = () => {
|
||||||
|
const newFields = selectedFields.map((f) => ({ ...f, visible: false }));
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 통계
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const visible = selectedFields.filter((f) => f.visible !== false);
|
||||||
|
return {
|
||||||
|
total: availableFields.length,
|
||||||
|
selected: visible.length,
|
||||||
|
filter: visible.filter((f) => f.area === "filter").length,
|
||||||
|
row: visible.filter((f) => f.area === "row").length,
|
||||||
|
column: visible.filter((f) => f.area === "column").length,
|
||||||
|
data: visible.filter((f) => f.area === "data").length,
|
||||||
|
};
|
||||||
|
}, [availableFields, selectedFields]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>필드 선택기</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 통계 */}
|
||||||
|
<div className="flex items-center gap-4 py-2 px-1 text-xs text-muted-foreground border-b border-border">
|
||||||
|
<span>전체: {stats.total}</span>
|
||||||
|
<span className="text-primary font-medium">
|
||||||
|
선택됨: {stats.selected}
|
||||||
|
</span>
|
||||||
|
<span>필터: {stats.filter}</span>
|
||||||
|
<span>행: {stats.row}</span>
|
||||||
|
<span>열: {stats.column}</span>
|
||||||
|
<span>데이터: {stats.data}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 필터 */}
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="필드 검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFilterType(v as "all" | "selected" | "unselected")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32 h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="selected">선택됨</SelectItem>
|
||||||
|
<SelectItem value="unselected">미선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 목록 */}
|
||||||
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
{filteredFields.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredFields.map((field) => {
|
||||||
|
const config = selectedFields.find(
|
||||||
|
(f) => f.field === field.field && f.visible !== false
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FieldItem
|
||||||
|
key={field.field}
|
||||||
|
field={field}
|
||||||
|
config={config}
|
||||||
|
onAreaChange={(area) => handleAreaChange(field, area)}
|
||||||
|
onSummaryChange={
|
||||||
|
config?.area === "data"
|
||||||
|
? (summary) => handleSummaryChange(field, summary)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDisplayModeChange={
|
||||||
|
config?.area === "data"
|
||||||
|
? (mode) => handleDisplayModeChange(field, mode)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FieldChooser;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,551 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FieldPanel 컴포넌트
|
||||||
|
* 피벗 그리드 상단의 필드 배치 영역 (필터, 열, 행, 데이터)
|
||||||
|
* 드래그 앤 드롭으로 필드 재배치 가능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragStartEvent,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PivotFieldConfig, PivotAreaType } from "../types";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Filter,
|
||||||
|
Columns,
|
||||||
|
Rows,
|
||||||
|
BarChart3,
|
||||||
|
GripVertical,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
interface FieldPanelProps {
|
||||||
|
fields: PivotFieldConfig[];
|
||||||
|
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||||
|
onFieldRemove?: (field: PivotFieldConfig) => void;
|
||||||
|
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldChipProps {
|
||||||
|
field: PivotFieldConfig;
|
||||||
|
onRemove: () => void;
|
||||||
|
onSettingsChange?: (field: PivotFieldConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DroppableAreaProps {
|
||||||
|
area: PivotAreaType;
|
||||||
|
fields: PivotFieldConfig[];
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onFieldRemove: (field: PivotFieldConfig) => void;
|
||||||
|
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
|
||||||
|
isOver?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 영역 설정 ====================
|
||||||
|
|
||||||
|
const AREA_CONFIG: Record<
|
||||||
|
PivotAreaType,
|
||||||
|
{ title: string; icon: React.ReactNode; color: string }
|
||||||
|
> = {
|
||||||
|
filter: {
|
||||||
|
title: "필터",
|
||||||
|
icon: <Filter className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800",
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
title: "열",
|
||||||
|
icon: <Columns className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
title: "행",
|
||||||
|
icon: <Rows className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: "데이터",
|
||||||
|
icon: <BarChart3 className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 필드 칩 (드래그 가능) ====================
|
||||||
|
|
||||||
|
const SortableFieldChip: React.FC<FieldChipProps> = ({
|
||||||
|
field,
|
||||||
|
onRemove,
|
||||||
|
onSettingsChange,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: `${field.area}-${field.field}` });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||||
|
"bg-background border border-border shadow-sm",
|
||||||
|
"hover:bg-accent/50 transition-colors",
|
||||||
|
isDragging && "opacity-50 shadow-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 필드 라벨 */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-1 hover:text-primary">
|
||||||
|
<span className="font-medium">{field.caption}</span>
|
||||||
|
{field.area === "data" && field.summaryType && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({getSummaryLabel(field.summaryType)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
|
{field.area === "data" && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "sum" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
합계
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "count" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
개수
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "avg" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
평균
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "min" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
최소
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "max" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
최대
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({
|
||||||
|
...field,
|
||||||
|
sortOrder: field.sortOrder === "asc" ? "desc" : "asc",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSettingsChange?.({ ...field, visible: false })}
|
||||||
|
>
|
||||||
|
필드 숨기기
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 드롭 영역 ====================
|
||||||
|
|
||||||
|
const DroppableArea: React.FC<DroppableAreaProps> = ({
|
||||||
|
area,
|
||||||
|
fields,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
onFieldRemove,
|
||||||
|
onFieldSettingsChange,
|
||||||
|
isOver,
|
||||||
|
}) => {
|
||||||
|
const config = AREA_CONFIG[area];
|
||||||
|
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
|
||||||
|
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
|
||||||
|
"transition-colors duration-200",
|
||||||
|
config.color,
|
||||||
|
isOver && "border-primary bg-primary/5"
|
||||||
|
)}
|
||||||
|
data-area={area}
|
||||||
|
>
|
||||||
|
{/* 영역 헤더 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
{areaFields.length > 0 && (
|
||||||
|
<span className="text-[10px] bg-muted px-1 rounded">
|
||||||
|
{areaFields.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 목록 */}
|
||||||
|
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||||
|
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||||
|
{areaFields.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground/50 italic">
|
||||||
|
필드를 여기로 드래그
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
areaFields.map((field) => (
|
||||||
|
<SortableFieldChip
|
||||||
|
key={`${area}-${field.field}`}
|
||||||
|
field={field}
|
||||||
|
onRemove={() => onFieldRemove(field)}
|
||||||
|
onSettingsChange={onFieldSettingsChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 유틸리티 ====================
|
||||||
|
|
||||||
|
function getSummaryLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
sum: "합계",
|
||||||
|
count: "개수",
|
||||||
|
avg: "평균",
|
||||||
|
min: "최소",
|
||||||
|
max: "최대",
|
||||||
|
countDistinct: "고유",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
|
fields,
|
||||||
|
onFieldsChange,
|
||||||
|
onFieldRemove,
|
||||||
|
onFieldSettingsChange,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
|
}) => {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [overArea, setOverArea] = useState<PivotAreaType | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 드래그 시작
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 오버
|
||||||
|
const handleDragOver = (event: DragOverEvent) => {
|
||||||
|
const { over } = event;
|
||||||
|
if (!over) {
|
||||||
|
setOverArea(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드롭 영역 감지
|
||||||
|
const overId = over.id as string;
|
||||||
|
const targetArea = overId.split("-")[0] as PivotAreaType;
|
||||||
|
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||||
|
setOverArea(targetArea);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 종료
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
setOverArea(null);
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
// 필드 정보 파싱
|
||||||
|
const [sourceArea, sourceField] = activeId.split("-") as [
|
||||||
|
PivotAreaType,
|
||||||
|
string
|
||||||
|
];
|
||||||
|
const [targetArea] = overId.split("-") as [PivotAreaType, string];
|
||||||
|
|
||||||
|
// 같은 영역 내 정렬
|
||||||
|
if (sourceArea === targetArea) {
|
||||||
|
const areaFields = fields.filter((f) => f.area === sourceArea);
|
||||||
|
const sourceIndex = areaFields.findIndex((f) => f.field === sourceField);
|
||||||
|
const targetIndex = areaFields.findIndex(
|
||||||
|
(f) => `${f.area}-${f.field}` === overId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceIndex !== targetIndex && targetIndex >= 0) {
|
||||||
|
// 순서 변경
|
||||||
|
const newFields = [...fields];
|
||||||
|
const fieldToMove = newFields.find(
|
||||||
|
(f) => f.field === sourceField && f.area === sourceArea
|
||||||
|
);
|
||||||
|
if (fieldToMove) {
|
||||||
|
fieldToMove.areaIndex = targetIndex;
|
||||||
|
// 다른 필드들 인덱스 조정
|
||||||
|
newFields
|
||||||
|
.filter((f) => f.area === sourceArea && f.field !== sourceField)
|
||||||
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
|
||||||
|
.forEach((f, idx) => {
|
||||||
|
f.areaIndex = idx >= targetIndex ? idx + 1 : idx;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 영역으로 이동
|
||||||
|
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||||
|
const newFields = fields.map((f) => {
|
||||||
|
if (f.field === sourceField && f.area === sourceArea) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
area: targetArea as PivotAreaType,
|
||||||
|
areaIndex: fields.filter((ff) => ff.area === targetArea).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 제거
|
||||||
|
const handleFieldRemove = (field: PivotFieldConfig) => {
|
||||||
|
if (onFieldRemove) {
|
||||||
|
onFieldRemove(field);
|
||||||
|
} else {
|
||||||
|
// 기본 동작: visible을 false로 설정
|
||||||
|
const newFields = fields.map((f) =>
|
||||||
|
f.field === field.field && f.area === field.area
|
||||||
|
? { ...f, visible: false }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 설정 변경
|
||||||
|
const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => {
|
||||||
|
if (onFieldSettingsChange) {
|
||||||
|
onFieldSettingsChange(updatedField);
|
||||||
|
}
|
||||||
|
const newFields = fields.map((f) =>
|
||||||
|
f.field === updatedField.field && f.area === updatedField.area
|
||||||
|
? updatedField
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
onFieldsChange(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 활성 필드 찾기 (드래그 중인 필드)
|
||||||
|
const activeField = activeId
|
||||||
|
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border px-3 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
필드 패널 펼치기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="border-b border-border bg-muted/20 p-3">
|
||||||
|
{/* 2x2 그리드로 영역 배치 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="filter"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.filter.title}
|
||||||
|
icon={AREA_CONFIG.filter.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "filter"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 열 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="column"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.column.title}
|
||||||
|
icon={AREA_CONFIG.column.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "column"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 행 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="row"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.row.title}
|
||||||
|
icon={AREA_CONFIG.row.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "row"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 데이터 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="data"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.data.title}
|
||||||
|
icon={AREA_CONFIG.data.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "data"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 접기 버튼 */}
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<div className="flex justify-center mt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="text-xs h-6"
|
||||||
|
>
|
||||||
|
필드 패널 접기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드래그 오버레이 */}
|
||||||
|
<DragOverlay>
|
||||||
|
{activeField ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||||
|
"bg-background border border-primary shadow-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{activeField.caption}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FieldPanel;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FilterPopup 컴포넌트
|
||||||
|
* 피벗 필드의 값을 필터링하는 팝업
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PivotFieldConfig } from "../types";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
CheckSquare,
|
||||||
|
Square,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
interface FilterPopupProps {
|
||||||
|
field: PivotFieldConfig;
|
||||||
|
data: any[];
|
||||||
|
onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const FilterPopup: React.FC<FilterPopupProps> = ({
|
||||||
|
field,
|
||||||
|
data,
|
||||||
|
onFilterChange,
|
||||||
|
trigger,
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedValues, setSelectedValues] = useState<Set<any>>(
|
||||||
|
new Set(field.filterValues || [])
|
||||||
|
);
|
||||||
|
const [filterType, setFilterType] = useState<"include" | "exclude">(
|
||||||
|
field.filterType || "include"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 고유 값 추출
|
||||||
|
const uniqueValues = useMemo(() => {
|
||||||
|
const values = new Set<any>();
|
||||||
|
data.forEach((row) => {
|
||||||
|
const value = row[field.field];
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(values).sort((a, b) => {
|
||||||
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||||
|
return String(a).localeCompare(String(b), "ko");
|
||||||
|
});
|
||||||
|
}, [data, field.field]);
|
||||||
|
|
||||||
|
// 필터링된 값 목록
|
||||||
|
const filteredValues = useMemo(() => {
|
||||||
|
if (!searchQuery) return uniqueValues;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return uniqueValues.filter((val) =>
|
||||||
|
String(val).toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [uniqueValues, searchQuery]);
|
||||||
|
|
||||||
|
// 값 토글
|
||||||
|
const handleValueToggle = (value: any) => {
|
||||||
|
const newSelected = new Set(selectedValues);
|
||||||
|
if (newSelected.has(value)) {
|
||||||
|
newSelected.delete(value);
|
||||||
|
} else {
|
||||||
|
newSelected.add(value);
|
||||||
|
}
|
||||||
|
setSelectedValues(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모두 선택
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
setSelectedValues(new Set(filteredValues));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모두 해제
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setSelectedValues(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 적용
|
||||||
|
const handleApply = () => {
|
||||||
|
onFilterChange(field, Array.from(selectedValues), filterType);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setSelectedValues(new Set());
|
||||||
|
setFilterType("include");
|
||||||
|
onFilterChange(field, [], "include");
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 활성 상태
|
||||||
|
const isFilterActive = field.filterValues && field.filterValues.length > 0;
|
||||||
|
|
||||||
|
// 선택된 항목 수
|
||||||
|
const selectedCount = selectedValues.size;
|
||||||
|
const totalCount = uniqueValues.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"p-1 rounded hover:bg-accent",
|
||||||
|
isFilterActive && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 p-0" align="start">
|
||||||
|
<div className="p-3 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium">{field.caption} 필터</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType("include")}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 text-xs rounded",
|
||||||
|
filterType === "include"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
포함
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType("exclude")}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 text-xs rounded",
|
||||||
|
filterType === "exclude"
|
||||||
|
? "bg-destructive text-destructive-foreground"
|
||||||
|
: "bg-muted hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
제외
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전체 선택/해제 */}
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{selectedCount} / {totalCount} 선택됨
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<CheckSquare className="h-3 w-3" />
|
||||||
|
전체 선택
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
전체 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 값 목록 */}
|
||||||
|
<ScrollArea className="h-48">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{filteredValues.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||||
|
결과가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredValues.map((value) => (
|
||||||
|
<label
|
||||||
|
key={String(value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer",
|
||||||
|
"hover:bg-muted text-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedValues.has(value)}
|
||||||
|
onCheckedChange={() => handleValueToggle(value)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{String(value)}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
({data.filter((r) => r[field.field] === value).length})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex items-center justify-between p-2 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterPopup;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PivotChart 컴포넌트
|
||||||
|
* 피벗 데이터를 차트로 시각화
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types";
|
||||||
|
import { pathToKey } from "../utils/pivotEngine";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
interface PivotChartProps {
|
||||||
|
pivotResult: PivotResult;
|
||||||
|
config: PivotChartConfig;
|
||||||
|
dataFields: PivotFieldConfig[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 색상 ====================
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#4472C4", // 파랑
|
||||||
|
"#ED7D31", // 주황
|
||||||
|
"#A5A5A5", // 회색
|
||||||
|
"#FFC000", // 노랑
|
||||||
|
"#5B9BD5", // 하늘
|
||||||
|
"#70AD47", // 초록
|
||||||
|
"#264478", // 진한 파랑
|
||||||
|
"#9E480E", // 진한 주황
|
||||||
|
"#636363", // 진한 회색
|
||||||
|
"#997300", // 진한 노랑
|
||||||
|
];
|
||||||
|
|
||||||
|
// ==================== 데이터 변환 ====================
|
||||||
|
|
||||||
|
function transformDataForChart(
|
||||||
|
pivotResult: PivotResult,
|
||||||
|
dataFields: PivotFieldConfig[]
|
||||||
|
): any[] {
|
||||||
|
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
||||||
|
|
||||||
|
// 행 기준 차트 데이터 생성
|
||||||
|
return flatRows.map((row) => {
|
||||||
|
const dataPoint: any = {
|
||||||
|
name: row.caption,
|
||||||
|
path: row.path,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 각 열에 대한 데이터 추가
|
||||||
|
flatColumns.forEach((col) => {
|
||||||
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||||
|
const values = dataMatrix.get(cellKey);
|
||||||
|
|
||||||
|
if (values && values.length > 0) {
|
||||||
|
const columnName = col.caption || "전체";
|
||||||
|
dataPoint[columnName] = values[0].value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 총계 추가
|
||||||
|
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||||
|
if (rowTotal && rowTotal.length > 0) {
|
||||||
|
dataPoint["총계"] = rowTotal[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataPoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformDataForPie(
|
||||||
|
pivotResult: PivotResult,
|
||||||
|
dataFields: PivotFieldConfig[]
|
||||||
|
): any[] {
|
||||||
|
const { flatRows, grandTotals } = pivotResult;
|
||||||
|
|
||||||
|
return flatRows.map((row, idx) => {
|
||||||
|
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||||
|
return {
|
||||||
|
name: row.caption,
|
||||||
|
value: rowTotal?.[0]?.value || 0,
|
||||||
|
color: COLORS[idx % COLORS.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 차트 컴포넌트 ====================
|
||||||
|
|
||||||
|
const CustomTooltip: React.FC<any> = ({ active, payload, label }) => {
|
||||||
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-border rounded-lg shadow-lg p-2">
|
||||||
|
<p className="text-sm font-medium mb-1">{label}</p>
|
||||||
|
{payload.map((entry: any, idx: number) => (
|
||||||
|
<p key={idx} className="text-xs" style={{ color: entry.color }}>
|
||||||
|
{entry.name}: {entry.value?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 막대 차트
|
||||||
|
const PivotBarChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
columns: string[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
stacked?: boolean;
|
||||||
|
}> = ({ data, columns, height, showLegend, stacked }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="square"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Bar
|
||||||
|
key={col}
|
||||||
|
dataKey={col}
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
stackId={stacked ? "stack" : undefined}
|
||||||
|
radius={stacked ? 0 : [4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선 차트
|
||||||
|
const PivotLineChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
columns: string[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
}> = ({ data, columns, height, showLegend }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="line"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Line
|
||||||
|
key={col}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={col}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4, fill: COLORS[idx % COLORS.length] }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 영역 차트
|
||||||
|
const PivotAreaChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
columns: string[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
}> = ({ data, columns, height, showLegend }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="square"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Area
|
||||||
|
key={col}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={col}
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파이 차트
|
||||||
|
const PivotPieChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
}> = ({ data, height, showLegend }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={height / 3}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name} (${(percent * 100).toFixed(1)}%)`
|
||||||
|
}
|
||||||
|
labelLine
|
||||||
|
>
|
||||||
|
{data.map((entry, idx) => (
|
||||||
|
<Cell key={idx} fill={entry.color || COLORS[idx % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="circle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const PivotChart: React.FC<PivotChartProps> = ({
|
||||||
|
pivotResult,
|
||||||
|
config,
|
||||||
|
dataFields,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
// 차트 데이터 변환
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (config.type === "pie") {
|
||||||
|
return transformDataForPie(pivotResult, dataFields);
|
||||||
|
}
|
||||||
|
return transformDataForChart(pivotResult, dataFields);
|
||||||
|
}, [pivotResult, dataFields, config.type]);
|
||||||
|
|
||||||
|
// 열 이름 목록 (파이 차트 제외)
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
if (config.type === "pie" || chartData.length === 0) return [];
|
||||||
|
|
||||||
|
const firstItem = chartData[0];
|
||||||
|
return Object.keys(firstItem).filter(
|
||||||
|
(key) => key !== "name" && key !== "path"
|
||||||
|
);
|
||||||
|
}, [chartData, config.type]);
|
||||||
|
|
||||||
|
const height = config.height || 300;
|
||||||
|
const showLegend = config.showLegend !== false;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-t border-border bg-background p-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 차트 렌더링 */}
|
||||||
|
{config.type === "bar" && (
|
||||||
|
<PivotBarChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "stackedBar" && (
|
||||||
|
<PivotBarChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
stacked
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "line" && (
|
||||||
|
<PivotLineChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "area" && (
|
||||||
|
<PivotAreaChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "pie" && (
|
||||||
|
<PivotPieChart
|
||||||
|
data={chartData}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PivotChart;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* PivotGrid 서브 컴포넌트 내보내기
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { FieldPanel } from "./FieldPanel";
|
||||||
|
export { FieldChooser } from "./FieldChooser";
|
||||||
|
export { DrillDownModal } from "./DrillDownModal";
|
||||||
|
export { FilterPopup } from "./FilterPopup";
|
||||||
|
export { PivotChart } from "./PivotChart";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* PivotGrid 커스텀 훅 내보내기
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
useVirtualScroll,
|
||||||
|
useVirtualColumnScroll,
|
||||||
|
useVirtual2DScroll,
|
||||||
|
} from "./useVirtualScroll";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
VirtualScrollOptions,
|
||||||
|
VirtualScrollResult,
|
||||||
|
VirtualColumnScrollOptions,
|
||||||
|
VirtualColumnScrollResult,
|
||||||
|
Virtual2DScrollOptions,
|
||||||
|
Virtual2DScrollResult,
|
||||||
|
} from "./useVirtualScroll";
|
||||||
|
|
||||||
|
export { usePivotState } from "./usePivotState";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PivotStateConfig,
|
||||||
|
SavedPivotState,
|
||||||
|
UsePivotStateResult,
|
||||||
|
} from "./usePivotState";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PivotState 훅
|
||||||
|
* 피벗 그리드 상태 저장/복원 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { PivotFieldConfig, PivotGridState } from "../types";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
export interface PivotStateConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
storageKey?: string;
|
||||||
|
storageType?: "localStorage" | "sessionStorage";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedPivotState {
|
||||||
|
version: string;
|
||||||
|
timestamp: number;
|
||||||
|
fields: PivotFieldConfig[];
|
||||||
|
expandedRowPaths: string[][];
|
||||||
|
expandedColumnPaths: string[][];
|
||||||
|
filterConfig: Record<string, any[]>;
|
||||||
|
sortConfig: {
|
||||||
|
field: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePivotStateResult {
|
||||||
|
// 상태
|
||||||
|
fields: PivotFieldConfig[];
|
||||||
|
pivotState: PivotGridState;
|
||||||
|
|
||||||
|
// 상태 변경
|
||||||
|
setFields: (fields: PivotFieldConfig[]) => void;
|
||||||
|
setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void;
|
||||||
|
|
||||||
|
// 저장/복원
|
||||||
|
saveState: () => void;
|
||||||
|
loadState: () => boolean;
|
||||||
|
clearState: () => void;
|
||||||
|
hasStoredState: () => boolean;
|
||||||
|
|
||||||
|
// 상태 정보
|
||||||
|
lastSaved: Date | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 상수 ====================
|
||||||
|
|
||||||
|
const STATE_VERSION = "1.0.0";
|
||||||
|
const DEFAULT_STORAGE_KEY = "pivot-grid-state";
|
||||||
|
|
||||||
|
// ==================== 훅 ====================
|
||||||
|
|
||||||
|
export function usePivotState(
|
||||||
|
initialFields: PivotFieldConfig[],
|
||||||
|
config: PivotStateConfig
|
||||||
|
): UsePivotStateResult {
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
storageKey = DEFAULT_STORAGE_KEY,
|
||||||
|
storageType = "localStorage",
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [fields, setFieldsInternal] = useState<PivotFieldConfig[]>(initialFields);
|
||||||
|
const [pivotState, setPivotStateInternal] = useState<PivotGridState>({
|
||||||
|
expandedRowPaths: [],
|
||||||
|
expandedColumnPaths: [],
|
||||||
|
sortConfig: null,
|
||||||
|
filterConfig: {},
|
||||||
|
});
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [initialStateLoaded, setInitialStateLoaded] = useState(false);
|
||||||
|
|
||||||
|
// 스토리지 가져오기
|
||||||
|
const getStorage = useCallback(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return storageType === "localStorage" ? localStorage : sessionStorage;
|
||||||
|
}, [storageType]);
|
||||||
|
|
||||||
|
// 저장된 상태 확인
|
||||||
|
const hasStoredState = useCallback((): boolean => {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return false;
|
||||||
|
return storage.getItem(storageKey) !== null;
|
||||||
|
}, [getStorage, storageKey]);
|
||||||
|
|
||||||
|
// 상태 저장
|
||||||
|
const saveState = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
const stateToSave: SavedPivotState = {
|
||||||
|
version: STATE_VERSION,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
fields,
|
||||||
|
expandedRowPaths: pivotState.expandedRowPaths,
|
||||||
|
expandedColumnPaths: pivotState.expandedColumnPaths,
|
||||||
|
filterConfig: pivotState.filterConfig,
|
||||||
|
sortConfig: pivotState.sortConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(storageKey, JSON.stringify(stateToSave));
|
||||||
|
setLastSaved(new Date());
|
||||||
|
setIsDirty(false);
|
||||||
|
console.log("✅ 피벗 상태 저장됨:", storageKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 피벗 상태 저장 실패:", error);
|
||||||
|
}
|
||||||
|
}, [enabled, getStorage, storageKey, fields, pivotState]);
|
||||||
|
|
||||||
|
// 상태 불러오기
|
||||||
|
const loadState = useCallback((): boolean => {
|
||||||
|
if (!enabled) return false;
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = storage.getItem(storageKey);
|
||||||
|
if (!saved) return false;
|
||||||
|
|
||||||
|
const parsedState: SavedPivotState = JSON.parse(saved);
|
||||||
|
|
||||||
|
// 버전 체크
|
||||||
|
if (parsedState.version !== STATE_VERSION) {
|
||||||
|
console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 복원
|
||||||
|
setFieldsInternal(parsedState.fields);
|
||||||
|
setPivotStateInternal({
|
||||||
|
expandedRowPaths: parsedState.expandedRowPaths,
|
||||||
|
expandedColumnPaths: parsedState.expandedColumnPaths,
|
||||||
|
sortConfig: parsedState.sortConfig,
|
||||||
|
filterConfig: parsedState.filterConfig,
|
||||||
|
});
|
||||||
|
setLastSaved(new Date(parsedState.timestamp));
|
||||||
|
setIsDirty(false);
|
||||||
|
|
||||||
|
console.log("✅ 피벗 상태 복원됨:", storageKey);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 피벗 상태 복원 실패:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [enabled, getStorage, storageKey]);
|
||||||
|
|
||||||
|
// 상태 초기화
|
||||||
|
const clearState = useCallback(() => {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.removeItem(storageKey);
|
||||||
|
setLastSaved(null);
|
||||||
|
console.log("🗑️ 피벗 상태 삭제됨:", storageKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 피벗 상태 삭제 실패:", error);
|
||||||
|
}
|
||||||
|
}, [getStorage, storageKey]);
|
||||||
|
|
||||||
|
// 필드 변경 (dirty 플래그 설정)
|
||||||
|
const setFields = useCallback((newFields: PivotFieldConfig[]) => {
|
||||||
|
setFieldsInternal(newFields);
|
||||||
|
setIsDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 피벗 상태 변경 (dirty 플래그 설정)
|
||||||
|
const setPivotState = useCallback(
|
||||||
|
(newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => {
|
||||||
|
setPivotStateInternal(newState);
|
||||||
|
setIsDirty(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialStateLoaded && enabled && hasStoredState()) {
|
||||||
|
loadState();
|
||||||
|
setInitialStateLoaded(true);
|
||||||
|
}
|
||||||
|
}, [enabled, hasStoredState, loadState, initialStateLoaded]);
|
||||||
|
|
||||||
|
// 초기 필드 동기화 (저장된 상태가 없을 때)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStateLoaded) return;
|
||||||
|
if (!hasStoredState() && initialFields.length > 0) {
|
||||||
|
setFieldsInternal(initialFields);
|
||||||
|
setInitialStateLoaded(true);
|
||||||
|
}
|
||||||
|
}, [initialFields, hasStoredState, initialStateLoaded]);
|
||||||
|
|
||||||
|
// 자동 저장 (변경 시)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !isDirty) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
saveState();
|
||||||
|
}, 1000); // 1초 디바운스
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [enabled, isDirty, saveState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fields,
|
||||||
|
pivotState,
|
||||||
|
setFields,
|
||||||
|
setPivotState,
|
||||||
|
saveState,
|
||||||
|
loadState,
|
||||||
|
clearState,
|
||||||
|
hasStoredState,
|
||||||
|
lastSaved,
|
||||||
|
isDirty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePivotState;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual Scroll 훅
|
||||||
|
* 대용량 피벗 데이터의 가상 스크롤 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
export interface VirtualScrollOptions {
|
||||||
|
itemCount: number; // 전체 아이템 수
|
||||||
|
itemHeight: number; // 각 아이템 높이 (px)
|
||||||
|
containerHeight: number; // 컨테이너 높이 (px)
|
||||||
|
overscan?: number; // 버퍼 아이템 수 (기본: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualScrollResult {
|
||||||
|
// 현재 보여야 할 아이템 범위
|
||||||
|
startIndex: number;
|
||||||
|
endIndex: number;
|
||||||
|
|
||||||
|
// 가상 스크롤 관련 값
|
||||||
|
totalHeight: number; // 전체 높이
|
||||||
|
offsetTop: number; // 상단 오프셋
|
||||||
|
|
||||||
|
// 보여지는 아이템 목록
|
||||||
|
visibleItems: number[];
|
||||||
|
|
||||||
|
// 이벤트 핸들러
|
||||||
|
onScroll: (scrollTop: number) => void;
|
||||||
|
|
||||||
|
// 컨테이너 ref
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 훅 ====================
|
||||||
|
|
||||||
|
export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult {
|
||||||
|
const {
|
||||||
|
itemCount,
|
||||||
|
itemHeight,
|
||||||
|
containerHeight,
|
||||||
|
overscan = 5,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
|
||||||
|
// 보이는 아이템 수
|
||||||
|
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
||||||
|
|
||||||
|
// 시작/끝 인덱스 계산
|
||||||
|
const { startIndex, endIndex } = useMemo(() => {
|
||||||
|
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||||
|
const end = Math.min(
|
||||||
|
itemCount - 1,
|
||||||
|
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
|
||||||
|
);
|
||||||
|
return { startIndex: start, endIndex: end };
|
||||||
|
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
|
||||||
|
|
||||||
|
// 전체 높이
|
||||||
|
const totalHeight = itemCount * itemHeight;
|
||||||
|
|
||||||
|
// 상단 오프셋
|
||||||
|
const offsetTop = startIndex * itemHeight;
|
||||||
|
|
||||||
|
// 보이는 아이템 인덱스 배열
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
const items: number[] = [];
|
||||||
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [startIndex, endIndex]);
|
||||||
|
|
||||||
|
// 스크롤 핸들러
|
||||||
|
const onScroll = useCallback((newScrollTop: number) => {
|
||||||
|
setScrollTop(newScrollTop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스크롤 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollTop(container.scrollTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
totalHeight,
|
||||||
|
offsetTop,
|
||||||
|
visibleItems,
|
||||||
|
onScroll,
|
||||||
|
containerRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 열 가상 스크롤 ====================
|
||||||
|
|
||||||
|
export interface VirtualColumnScrollOptions {
|
||||||
|
columnCount: number; // 전체 열 수
|
||||||
|
columnWidth: number; // 각 열 너비 (px)
|
||||||
|
containerWidth: number; // 컨테이너 너비 (px)
|
||||||
|
overscan?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualColumnScrollResult {
|
||||||
|
startIndex: number;
|
||||||
|
endIndex: number;
|
||||||
|
totalWidth: number;
|
||||||
|
offsetLeft: number;
|
||||||
|
visibleColumns: number[];
|
||||||
|
onScroll: (scrollLeft: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVirtualColumnScroll(
|
||||||
|
options: VirtualColumnScrollOptions
|
||||||
|
): VirtualColumnScrollResult {
|
||||||
|
const {
|
||||||
|
columnCount,
|
||||||
|
columnWidth,
|
||||||
|
containerWidth,
|
||||||
|
overscan = 3,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
|
|
||||||
|
const { startIndex, endIndex } = useMemo(() => {
|
||||||
|
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan);
|
||||||
|
const end = Math.min(
|
||||||
|
columnCount - 1,
|
||||||
|
Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan
|
||||||
|
);
|
||||||
|
return { startIndex: start, endIndex: end };
|
||||||
|
}, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]);
|
||||||
|
|
||||||
|
const totalWidth = columnCount * columnWidth;
|
||||||
|
const offsetLeft = startIndex * columnWidth;
|
||||||
|
|
||||||
|
const visibleColumns = useMemo(() => {
|
||||||
|
const cols: number[] = [];
|
||||||
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
|
cols.push(i);
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}, [startIndex, endIndex]);
|
||||||
|
|
||||||
|
const onScroll = useCallback((newScrollLeft: number) => {
|
||||||
|
setScrollLeft(newScrollLeft);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
totalWidth,
|
||||||
|
offsetLeft,
|
||||||
|
visibleColumns,
|
||||||
|
onScroll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 2D 가상 스크롤 (행 + 열) ====================
|
||||||
|
|
||||||
|
export interface Virtual2DScrollOptions {
|
||||||
|
rowCount: number;
|
||||||
|
columnCount: number;
|
||||||
|
rowHeight: number;
|
||||||
|
columnWidth: number;
|
||||||
|
containerHeight: number;
|
||||||
|
containerWidth: number;
|
||||||
|
rowOverscan?: number;
|
||||||
|
columnOverscan?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Virtual2DScrollResult {
|
||||||
|
// 행 범위
|
||||||
|
rowStartIndex: number;
|
||||||
|
rowEndIndex: number;
|
||||||
|
totalHeight: number;
|
||||||
|
offsetTop: number;
|
||||||
|
visibleRows: number[];
|
||||||
|
|
||||||
|
// 열 범위
|
||||||
|
columnStartIndex: number;
|
||||||
|
columnEndIndex: number;
|
||||||
|
totalWidth: number;
|
||||||
|
offsetLeft: number;
|
||||||
|
visibleColumns: number[];
|
||||||
|
|
||||||
|
// 스크롤 핸들러
|
||||||
|
onScroll: (scrollTop: number, scrollLeft: number) => void;
|
||||||
|
|
||||||
|
// 컨테이너 ref
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVirtual2DScroll(
|
||||||
|
options: Virtual2DScrollOptions
|
||||||
|
): Virtual2DScrollResult {
|
||||||
|
const {
|
||||||
|
rowCount,
|
||||||
|
columnCount,
|
||||||
|
rowHeight,
|
||||||
|
columnWidth,
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
rowOverscan = 5,
|
||||||
|
columnOverscan = 3,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
|
|
||||||
|
// 행 계산
|
||||||
|
const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => {
|
||||||
|
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan);
|
||||||
|
const end = Math.min(
|
||||||
|
rowCount - 1,
|
||||||
|
Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows: number[] = [];
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
rows.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowStartIndex: start,
|
||||||
|
rowEndIndex: end,
|
||||||
|
visibleRows: rows,
|
||||||
|
};
|
||||||
|
}, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]);
|
||||||
|
|
||||||
|
// 열 계산
|
||||||
|
const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => {
|
||||||
|
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan);
|
||||||
|
const end = Math.min(
|
||||||
|
columnCount - 1,
|
||||||
|
Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols: number[] = [];
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
cols.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnStartIndex: start,
|
||||||
|
columnEndIndex: end,
|
||||||
|
visibleColumns: cols,
|
||||||
|
};
|
||||||
|
}, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]);
|
||||||
|
|
||||||
|
const totalHeight = rowCount * rowHeight;
|
||||||
|
const totalWidth = columnCount * columnWidth;
|
||||||
|
const offsetTop = rowStartIndex * rowHeight;
|
||||||
|
const offsetLeft = columnStartIndex * columnWidth;
|
||||||
|
|
||||||
|
const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => {
|
||||||
|
setScrollTop(newScrollTop);
|
||||||
|
setScrollLeft(newScrollLeft);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스크롤 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollTop(container.scrollTop);
|
||||||
|
setScrollLeft(container.scrollLeft);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowStartIndex,
|
||||||
|
rowEndIndex,
|
||||||
|
totalHeight,
|
||||||
|
offsetTop,
|
||||||
|
visibleRows,
|
||||||
|
columnStartIndex,
|
||||||
|
columnEndIndex,
|
||||||
|
totalWidth,
|
||||||
|
offsetLeft,
|
||||||
|
visibleColumns,
|
||||||
|
onScroll,
|
||||||
|
containerRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useVirtualScroll;
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ export type {
|
||||||
// 기본 타입
|
// 기본 타입
|
||||||
PivotAreaType,
|
PivotAreaType,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
|
SummaryDisplayMode,
|
||||||
SortDirection,
|
SortDirection,
|
||||||
DateGroupInterval,
|
DateGroupInterval,
|
||||||
FieldDataType,
|
FieldDataType,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,19 @@ export type PivotAreaType = "row" | "column" | "data" | "filter";
|
||||||
// 집계 함수 타입
|
// 집계 함수 타입
|
||||||
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
|
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
|
||||||
|
|
||||||
|
// 요약 표시 모드
|
||||||
|
export type SummaryDisplayMode =
|
||||||
|
| "absoluteValue" // 절대값 (기본)
|
||||||
|
| "percentOfColumnTotal" // 열 총계 대비 %
|
||||||
|
| "percentOfRowTotal" // 행 총계 대비 %
|
||||||
|
| "percentOfGrandTotal" // 전체 총계 대비 %
|
||||||
|
| "percentOfColumnGrandTotal" // 열 대총계 대비 %
|
||||||
|
| "percentOfRowGrandTotal" // 행 대총계 대비 %
|
||||||
|
| "runningTotalByRow" // 행 방향 누계
|
||||||
|
| "runningTotalByColumn" // 열 방향 누계
|
||||||
|
| "differenceFromPrevious" // 이전 대비 차이
|
||||||
|
| "percentDifferenceFromPrevious"; // 이전 대비 % 차이
|
||||||
|
|
||||||
// 정렬 방향
|
// 정렬 방향
|
||||||
export type SortDirection = "asc" | "desc" | "none";
|
export type SortDirection = "asc" | "desc" | "none";
|
||||||
|
|
||||||
|
|
@ -48,6 +61,8 @@ export interface PivotFieldConfig {
|
||||||
|
|
||||||
// 집계 설정 (data 영역용)
|
// 집계 설정 (data 영역용)
|
||||||
summaryType?: AggregationType; // 집계 함수
|
summaryType?: AggregationType; // 집계 함수
|
||||||
|
summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드
|
||||||
|
showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭)
|
||||||
|
|
||||||
// 정렬 설정
|
// 정렬 설정
|
||||||
sortBy?: "value" | "caption"; // 정렬 기준
|
sortBy?: "value" | "caption"; // 정렬 기준
|
||||||
|
|
@ -151,6 +166,45 @@ export interface PivotChartConfig {
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 조건부 서식 규칙
|
||||||
|
export interface ConditionalFormatRule {
|
||||||
|
id: string;
|
||||||
|
type: "colorScale" | "dataBar" | "iconSet" | "cellValue";
|
||||||
|
field?: string; // 적용할 데이터 필드 (없으면 전체)
|
||||||
|
|
||||||
|
// colorScale: 값 범위에 따른 색상 그라데이션
|
||||||
|
colorScale?: {
|
||||||
|
minColor: string; // 최소값 색상 (예: "#ff0000")
|
||||||
|
midColor?: string; // 중간값 색상 (선택)
|
||||||
|
maxColor: string; // 최대값 색상 (예: "#00ff00")
|
||||||
|
};
|
||||||
|
|
||||||
|
// dataBar: 값에 따른 막대 표시
|
||||||
|
dataBar?: {
|
||||||
|
color: string; // 막대 색상
|
||||||
|
showValue?: boolean; // 값 표시 여부
|
||||||
|
minValue?: number; // 최소값 (없으면 자동)
|
||||||
|
maxValue?: number; // 최대값 (없으면 자동)
|
||||||
|
};
|
||||||
|
|
||||||
|
// iconSet: 값에 따른 아이콘 표시
|
||||||
|
iconSet?: {
|
||||||
|
type: "arrows" | "traffic" | "rating" | "flags";
|
||||||
|
thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100)
|
||||||
|
reverse?: boolean; // 아이콘 순서 반전
|
||||||
|
};
|
||||||
|
|
||||||
|
// cellValue: 조건에 따른 스타일
|
||||||
|
cellValue?: {
|
||||||
|
operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between";
|
||||||
|
value1: number;
|
||||||
|
value2?: number; // between 연산자용
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
bold?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 스타일 설정
|
// 스타일 설정
|
||||||
export interface PivotStyleConfig {
|
export interface PivotStyleConfig {
|
||||||
theme: "default" | "compact" | "modern";
|
theme: "default" | "compact" | "modern";
|
||||||
|
|
@ -159,6 +213,7 @@ export interface PivotStyleConfig {
|
||||||
borderStyle: "none" | "light" | "heavy";
|
borderStyle: "none" | "light" | "heavy";
|
||||||
alternateRowColors?: boolean;
|
alternateRowColors?: boolean;
|
||||||
highlightTotals?: boolean; // 총합계 강조
|
highlightTotals?: boolean; // 총합계 강조
|
||||||
|
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 내보내기 설정 ====================
|
// ==================== 내보내기 설정 ====================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
/**
|
||||||
|
* 조건부 서식 유틸리티
|
||||||
|
* 셀 값에 따른 스타일 계산
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConditionalFormatRule } from "../types";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
export interface CellFormatStyle {
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
fontWeight?: string;
|
||||||
|
dataBarWidth?: number; // 0-100%
|
||||||
|
dataBarColor?: string;
|
||||||
|
icon?: string; // 이모지 또는 아이콘 이름
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 색상 유틸리티 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEX 색상을 RGB로 변환
|
||||||
|
*/
|
||||||
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result
|
||||||
|
? {
|
||||||
|
r: parseInt(result[1], 16),
|
||||||
|
g: parseInt(result[2], 16),
|
||||||
|
b: parseInt(result[3], 16),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RGB를 HEX로 변환
|
||||||
|
*/
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return (
|
||||||
|
"#" +
|
||||||
|
[r, g, b]
|
||||||
|
.map((x) => {
|
||||||
|
const hex = Math.round(x).toString(16);
|
||||||
|
return hex.length === 1 ? "0" + hex : hex;
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 색상 사이의 보간
|
||||||
|
*/
|
||||||
|
function interpolateColor(
|
||||||
|
color1: string,
|
||||||
|
color2: string,
|
||||||
|
factor: number
|
||||||
|
): string {
|
||||||
|
const rgb1 = hexToRgb(color1);
|
||||||
|
const rgb2 = hexToRgb(color2);
|
||||||
|
|
||||||
|
if (!rgb1 || !rgb2) return color1;
|
||||||
|
|
||||||
|
const r = rgb1.r + (rgb2.r - rgb1.r) * factor;
|
||||||
|
const g = rgb1.g + (rgb2.g - rgb1.g) * factor;
|
||||||
|
const b = rgb1.b + (rgb2.b - rgb1.b) * factor;
|
||||||
|
|
||||||
|
return rgbToHex(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 조건부 서식 계산 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color Scale 스타일 계산
|
||||||
|
*/
|
||||||
|
function applyColorScale(
|
||||||
|
value: number,
|
||||||
|
minValue: number,
|
||||||
|
maxValue: number,
|
||||||
|
rule: ConditionalFormatRule
|
||||||
|
): CellFormatStyle {
|
||||||
|
if (!rule.colorScale) return {};
|
||||||
|
|
||||||
|
const { minColor, midColor, maxColor } = rule.colorScale;
|
||||||
|
const range = maxValue - minValue;
|
||||||
|
|
||||||
|
if (range === 0) {
|
||||||
|
return { backgroundColor: minColor };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = (value - minValue) / range;
|
||||||
|
|
||||||
|
let backgroundColor: string;
|
||||||
|
|
||||||
|
if (midColor) {
|
||||||
|
// 3색 그라데이션
|
||||||
|
if (normalizedValue <= 0.5) {
|
||||||
|
backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2);
|
||||||
|
} else {
|
||||||
|
backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2색 그라데이션
|
||||||
|
backgroundColor = interpolateColor(minColor, maxColor, normalizedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배경색에 따른 텍스트 색상 결정
|
||||||
|
const rgb = hexToRgb(backgroundColor);
|
||||||
|
const textColor =
|
||||||
|
rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186
|
||||||
|
? "#000000"
|
||||||
|
: "#ffffff";
|
||||||
|
|
||||||
|
return { backgroundColor, textColor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Bar 스타일 계산
|
||||||
|
*/
|
||||||
|
function applyDataBar(
|
||||||
|
value: number,
|
||||||
|
minValue: number,
|
||||||
|
maxValue: number,
|
||||||
|
rule: ConditionalFormatRule
|
||||||
|
): CellFormatStyle {
|
||||||
|
if (!rule.dataBar) return {};
|
||||||
|
|
||||||
|
const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar;
|
||||||
|
|
||||||
|
const min = ruleMin ?? minValue;
|
||||||
|
const max = ruleMax ?? maxValue;
|
||||||
|
const range = max - min;
|
||||||
|
|
||||||
|
if (range === 0) {
|
||||||
|
return { dataBarWidth: 100, dataBarColor: color };
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.max(0, Math.min(100, ((value - min) / range) * 100));
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataBarWidth: width,
|
||||||
|
dataBarColor: color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon Set 스타일 계산
|
||||||
|
*/
|
||||||
|
function applyIconSet(
|
||||||
|
value: number,
|
||||||
|
minValue: number,
|
||||||
|
maxValue: number,
|
||||||
|
rule: ConditionalFormatRule
|
||||||
|
): CellFormatStyle {
|
||||||
|
if (!rule.iconSet) return {};
|
||||||
|
|
||||||
|
const { type, thresholds, reverse } = rule.iconSet;
|
||||||
|
const range = maxValue - minValue;
|
||||||
|
const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100;
|
||||||
|
|
||||||
|
// 아이콘 정의
|
||||||
|
const iconSets: Record<string, string[]> = {
|
||||||
|
arrows: ["↓", "→", "↑"],
|
||||||
|
traffic: ["🔴", "🟡", "🟢"],
|
||||||
|
rating: ["⭐", "⭐⭐", "⭐⭐⭐"],
|
||||||
|
flags: ["🚩", "🏳️", "🏁"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = iconSets[type] || iconSets.arrows;
|
||||||
|
const sortedIcons = reverse ? [...icons].reverse() : icons;
|
||||||
|
|
||||||
|
// 임계값에 따른 아이콘 선택
|
||||||
|
let iconIndex = 0;
|
||||||
|
for (let i = 0; i < thresholds.length; i++) {
|
||||||
|
if (percentage >= thresholds[i]) {
|
||||||
|
iconIndex = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iconIndex = Math.min(iconIndex, sortedIcons.length - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: sortedIcons[iconIndex],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell Value 조건 스타일 계산
|
||||||
|
*/
|
||||||
|
function applyCellValue(
|
||||||
|
value: number,
|
||||||
|
rule: ConditionalFormatRule
|
||||||
|
): CellFormatStyle {
|
||||||
|
if (!rule.cellValue) return {};
|
||||||
|
|
||||||
|
const { operator, value1, value2, backgroundColor, textColor, bold } =
|
||||||
|
rule.cellValue;
|
||||||
|
|
||||||
|
let matches = false;
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case ">":
|
||||||
|
matches = value > value1;
|
||||||
|
break;
|
||||||
|
case ">=":
|
||||||
|
matches = value >= value1;
|
||||||
|
break;
|
||||||
|
case "<":
|
||||||
|
matches = value < value1;
|
||||||
|
break;
|
||||||
|
case "<=":
|
||||||
|
matches = value <= value1;
|
||||||
|
break;
|
||||||
|
case "=":
|
||||||
|
matches = value === value1;
|
||||||
|
break;
|
||||||
|
case "!=":
|
||||||
|
matches = value !== value1;
|
||||||
|
break;
|
||||||
|
case "between":
|
||||||
|
matches = value2 !== undefined && value >= value1 && value <= value2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matches) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor,
|
||||||
|
textColor,
|
||||||
|
fontWeight: bold ? "bold" : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 메인 함수 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 서식 적용
|
||||||
|
*/
|
||||||
|
export function getConditionalStyle(
|
||||||
|
value: number | null | undefined,
|
||||||
|
field: string,
|
||||||
|
rules: ConditionalFormatRule[],
|
||||||
|
allValues: number[] // 해당 필드의 모든 값 (min/max 계산용)
|
||||||
|
): CellFormatStyle {
|
||||||
|
if (value === null || value === undefined || isNaN(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// min/max 계산
|
||||||
|
const numericValues = allValues.filter((v) => !isNaN(v));
|
||||||
|
const minValue = Math.min(...numericValues);
|
||||||
|
const maxValue = Math.max(...numericValues);
|
||||||
|
|
||||||
|
let resultStyle: CellFormatStyle = {};
|
||||||
|
|
||||||
|
// 해당 필드에 적용되는 규칙 필터링 및 적용
|
||||||
|
for (const rule of rules) {
|
||||||
|
// 필드 필터 확인
|
||||||
|
if (rule.field && rule.field !== field) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ruleStyle: CellFormatStyle = {};
|
||||||
|
|
||||||
|
switch (rule.type) {
|
||||||
|
case "colorScale":
|
||||||
|
ruleStyle = applyColorScale(value, minValue, maxValue, rule);
|
||||||
|
break;
|
||||||
|
case "dataBar":
|
||||||
|
ruleStyle = applyDataBar(value, minValue, maxValue, rule);
|
||||||
|
break;
|
||||||
|
case "iconSet":
|
||||||
|
ruleStyle = applyIconSet(value, minValue, maxValue, rule);
|
||||||
|
break;
|
||||||
|
case "cellValue":
|
||||||
|
ruleStyle = applyCellValue(value, rule);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스타일 병합 (나중 규칙이 우선)
|
||||||
|
resultStyle = { ...resultStyle, ...ruleStyle };
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 서식 스타일을 React 스타일 객체로 변환
|
||||||
|
*/
|
||||||
|
export function formatStyleToReact(
|
||||||
|
style: CellFormatStyle
|
||||||
|
): React.CSSProperties {
|
||||||
|
const result: React.CSSProperties = {};
|
||||||
|
|
||||||
|
if (style.backgroundColor) {
|
||||||
|
result.backgroundColor = style.backgroundColor;
|
||||||
|
}
|
||||||
|
if (style.textColor) {
|
||||||
|
result.color = style.textColor;
|
||||||
|
}
|
||||||
|
if (style.fontWeight) {
|
||||||
|
result.fontWeight = style.fontWeight as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getConditionalStyle;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
/**
|
||||||
|
* Excel 내보내기 유틸리티
|
||||||
|
* 피벗 테이블 데이터를 Excel 파일로 내보내기
|
||||||
|
* xlsx 라이브러리 사용 (브라우저 호환)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import {
|
||||||
|
PivotResult,
|
||||||
|
PivotFieldConfig,
|
||||||
|
PivotTotalsConfig,
|
||||||
|
} from "../types";
|
||||||
|
import { pathToKey } from "./pivotEngine";
|
||||||
|
|
||||||
|
// ==================== 타입 ====================
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
fileName?: string;
|
||||||
|
sheetName?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
includeHeaders?: boolean;
|
||||||
|
includeTotals?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 메인 함수 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 피벗 데이터를 Excel로 내보내기
|
||||||
|
*/
|
||||||
|
export async function exportPivotToExcel(
|
||||||
|
pivotResult: PivotResult,
|
||||||
|
fields: PivotFieldConfig[],
|
||||||
|
totals: PivotTotalsConfig,
|
||||||
|
options: ExportOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const {
|
||||||
|
fileName = "pivot_export",
|
||||||
|
sheetName = "Pivot",
|
||||||
|
title,
|
||||||
|
includeHeaders = true,
|
||||||
|
includeTotals = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 필드 분류
|
||||||
|
const rowFields = fields
|
||||||
|
.filter((f) => f.area === "row" && f.visible !== false)
|
||||||
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||||
|
|
||||||
|
// 데이터 배열 생성
|
||||||
|
const data: any[][] = [];
|
||||||
|
|
||||||
|
// 제목 추가
|
||||||
|
if (title) {
|
||||||
|
data.push([title]);
|
||||||
|
data.push([]); // 빈 행
|
||||||
|
}
|
||||||
|
|
||||||
|
// 헤더 행
|
||||||
|
if (includeHeaders) {
|
||||||
|
const headerRow: any[] = [
|
||||||
|
rowFields.map((f) => f.caption).join(" / ") || "항목",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 열 헤더
|
||||||
|
for (const col of pivotResult.flatColumns) {
|
||||||
|
headerRow.push(col.caption || "(전체)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총계 헤더
|
||||||
|
if (totals?.showRowGrandTotals && includeTotals) {
|
||||||
|
headerRow.push("총계");
|
||||||
|
}
|
||||||
|
|
||||||
|
data.push(headerRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 행
|
||||||
|
for (const row of pivotResult.flatRows) {
|
||||||
|
const excelRow: any[] = [];
|
||||||
|
|
||||||
|
// 행 헤더 (들여쓰기 포함)
|
||||||
|
const indent = " ".repeat(row.level);
|
||||||
|
excelRow.push(indent + row.caption);
|
||||||
|
|
||||||
|
// 데이터 셀
|
||||||
|
for (const col of pivotResult.flatColumns) {
|
||||||
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||||
|
const values = pivotResult.dataMatrix.get(cellKey);
|
||||||
|
|
||||||
|
if (values && values.length > 0) {
|
||||||
|
excelRow.push(values[0].value);
|
||||||
|
} else {
|
||||||
|
excelRow.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 행 총계
|
||||||
|
if (totals?.showRowGrandTotals && includeTotals) {
|
||||||
|
const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path));
|
||||||
|
if (rowTotal && rowTotal.length > 0) {
|
||||||
|
excelRow.push(rowTotal[0].value);
|
||||||
|
} else {
|
||||||
|
excelRow.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.push(excelRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 열 총계 행
|
||||||
|
if (totals?.showColumnGrandTotals && includeTotals) {
|
||||||
|
const totalRow: any[] = ["총계"];
|
||||||
|
|
||||||
|
for (const col of pivotResult.flatColumns) {
|
||||||
|
const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path));
|
||||||
|
if (colTotal && colTotal.length > 0) {
|
||||||
|
totalRow.push(colTotal[0].value);
|
||||||
|
} else {
|
||||||
|
totalRow.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대총합
|
||||||
|
if (totals?.showRowGrandTotals) {
|
||||||
|
const grandTotal = pivotResult.grandTotals.grand;
|
||||||
|
if (grandTotal && grandTotal.length > 0) {
|
||||||
|
totalRow.push(grandTotal[0].value);
|
||||||
|
} else {
|
||||||
|
totalRow.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.push(totalRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 워크시트 생성
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
||||||
|
|
||||||
|
// 컬럼 너비 설정
|
||||||
|
const colWidths: XLSX.ColInfo[] = [];
|
||||||
|
const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0);
|
||||||
|
for (let i = 0; i < maxCols; i++) {
|
||||||
|
colWidths.push({ wch: i === 0 ? 25 : 15 });
|
||||||
|
}
|
||||||
|
worksheet["!cols"] = colWidths;
|
||||||
|
|
||||||
|
// 워크북 생성
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
|
|
||||||
|
// 파일 다운로드
|
||||||
|
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drill Down 데이터를 Excel로 내보내기
|
||||||
|
*/
|
||||||
|
export async function exportDrillDownToExcel(
|
||||||
|
data: any[],
|
||||||
|
columns: { field: string; caption: string }[],
|
||||||
|
options: ExportOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const {
|
||||||
|
fileName = "drilldown_export",
|
||||||
|
sheetName = "Data",
|
||||||
|
title,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 데이터 배열 생성
|
||||||
|
const sheetData: any[][] = [];
|
||||||
|
|
||||||
|
// 제목
|
||||||
|
if (title) {
|
||||||
|
sheetData.push([title]);
|
||||||
|
sheetData.push([]); // 빈 행
|
||||||
|
}
|
||||||
|
|
||||||
|
// 헤더
|
||||||
|
const headerRow = columns.map((col) => col.caption);
|
||||||
|
sheetData.push(headerRow);
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
for (const row of data) {
|
||||||
|
const dataRow = columns.map((col) => row[col.field] ?? "");
|
||||||
|
sheetData.push(dataRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 워크시트 생성
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(sheetData);
|
||||||
|
|
||||||
|
// 컬럼 너비 설정
|
||||||
|
const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 }));
|
||||||
|
worksheet["!cols"] = colWidths;
|
||||||
|
|
||||||
|
// 워크북 생성
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
|
|
||||||
|
// 파일 다운로드
|
||||||
|
XLSX.writeFile(workbook, `${fileName}.xlsx`);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export * from "./aggregation";
|
export * from "./aggregation";
|
||||||
export * from "./pivotEngine";
|
export * from "./pivotEngine";
|
||||||
|
export * from "./exportExcel";
|
||||||
|
export * from "./conditionalFormat";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
PivotCellValue,
|
PivotCellValue,
|
||||||
DateGroupInterval,
|
DateGroupInterval,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
|
SummaryDisplayMode,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
||||||
|
|
||||||
|
|
@ -418,6 +419,185 @@ function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
|
||||||
return leaves;
|
return leaves;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Summary Display Mode 적용 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary Display Mode에 따른 값 변환
|
||||||
|
*/
|
||||||
|
function applyDisplayMode(
|
||||||
|
value: number,
|
||||||
|
displayMode: SummaryDisplayMode | undefined,
|
||||||
|
rowTotal: number,
|
||||||
|
columnTotal: number,
|
||||||
|
grandTotal: number,
|
||||||
|
prevValue: number | null,
|
||||||
|
runningTotal: number,
|
||||||
|
format?: PivotFieldConfig["format"]
|
||||||
|
): { value: number; formattedValue: string } {
|
||||||
|
if (!displayMode || displayMode === "absoluteValue") {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
formattedValue: formatNumber(value, format),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let resultValue: number;
|
||||||
|
let formatOverride: PivotFieldConfig["format"] | undefined;
|
||||||
|
|
||||||
|
switch (displayMode) {
|
||||||
|
case "percentOfRowTotal":
|
||||||
|
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||||
|
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "percentOfColumnTotal":
|
||||||
|
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||||
|
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "percentOfGrandTotal":
|
||||||
|
resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100;
|
||||||
|
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "percentOfRowGrandTotal":
|
||||||
|
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
|
||||||
|
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "percentOfColumnGrandTotal":
|
||||||
|
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
|
||||||
|
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "runningTotalByRow":
|
||||||
|
case "runningTotalByColumn":
|
||||||
|
resultValue = runningTotal;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "differenceFromPrevious":
|
||||||
|
resultValue = prevValue === null ? 0 : value - prevValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "percentDifferenceFromPrevious":
|
||||||
|
resultValue = prevValue === null || prevValue === 0
|
||||||
|
? 0
|
||||||
|
: ((value - prevValue) / Math.abs(prevValue)) * 100;
|
||||||
|
formatOverride = { type: "percent", precision: 2, suffix: "%" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
resultValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: resultValue,
|
||||||
|
formattedValue: formatNumber(resultValue, formatOverride || format),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 매트릭스에 Summary Display Mode 적용
|
||||||
|
*/
|
||||||
|
function applyDisplayModeToMatrix(
|
||||||
|
matrix: Map<string, PivotCellValue[]>,
|
||||||
|
dataFields: PivotFieldConfig[],
|
||||||
|
flatRows: PivotFlatRow[],
|
||||||
|
flatColumnLeaves: string[][],
|
||||||
|
rowTotals: Map<string, PivotCellValue[]>,
|
||||||
|
columnTotals: Map<string, PivotCellValue[]>,
|
||||||
|
grandTotals: PivotCellValue[]
|
||||||
|
): Map<string, PivotCellValue[]> {
|
||||||
|
// displayMode가 있는 데이터 필드가 있는지 확인
|
||||||
|
const hasDisplayMode = dataFields.some(
|
||||||
|
(df) => df.summaryDisplayMode || df.showValuesAs
|
||||||
|
);
|
||||||
|
if (!hasDisplayMode) return matrix;
|
||||||
|
|
||||||
|
const newMatrix = new Map<string, PivotCellValue[]>();
|
||||||
|
|
||||||
|
// 누계를 위한 추적 (행별, 열별)
|
||||||
|
const rowRunningTotals: Map<string, number[]> = new Map(); // fieldIndex -> 누계
|
||||||
|
const colRunningTotals: Map<string, Map<number, number>> = new Map(); // colKey -> fieldIndex -> 누계
|
||||||
|
|
||||||
|
// 행 순서대로 처리
|
||||||
|
for (const row of flatRows) {
|
||||||
|
// 이전 열 값 추적 (차이 계산용)
|
||||||
|
const prevColValues: (number | null)[] = dataFields.map(() => null);
|
||||||
|
|
||||||
|
for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) {
|
||||||
|
const colPath = flatColumnLeaves[colIdx];
|
||||||
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||||
|
const values = matrix.get(cellKey);
|
||||||
|
|
||||||
|
if (!values) {
|
||||||
|
newMatrix.set(cellKey, []);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowKey = pathToKey(row.path);
|
||||||
|
const colKey = pathToKey(colPath);
|
||||||
|
|
||||||
|
// 총합 가져오기
|
||||||
|
const rowTotal = rowTotals.get(rowKey);
|
||||||
|
const colTotal = columnTotals.get(colKey);
|
||||||
|
|
||||||
|
const newValues: PivotCellValue[] = values.map((val, fieldIdx) => {
|
||||||
|
const dataField = dataFields[fieldIdx];
|
||||||
|
const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs;
|
||||||
|
|
||||||
|
if (!displayMode || displayMode === "absoluteValue") {
|
||||||
|
prevColValues[fieldIdx] = val.value;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 누계 계산
|
||||||
|
// 행 방향 누계
|
||||||
|
if (!rowRunningTotals.has(rowKey)) {
|
||||||
|
rowRunningTotals.set(rowKey, dataFields.map(() => 0));
|
||||||
|
}
|
||||||
|
const rowRunning = rowRunningTotals.get(rowKey)!;
|
||||||
|
rowRunning[fieldIdx] += val.value || 0;
|
||||||
|
|
||||||
|
// 열 방향 누계
|
||||||
|
if (!colRunningTotals.has(colKey)) {
|
||||||
|
colRunningTotals.set(colKey, new Map());
|
||||||
|
}
|
||||||
|
const colRunning = colRunningTotals.get(colKey)!;
|
||||||
|
if (!colRunning.has(fieldIdx)) {
|
||||||
|
colRunning.set(fieldIdx, 0);
|
||||||
|
}
|
||||||
|
colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0));
|
||||||
|
|
||||||
|
const result = applyDisplayMode(
|
||||||
|
val.value || 0,
|
||||||
|
displayMode,
|
||||||
|
rowTotal?.[fieldIdx]?.value || 0,
|
||||||
|
colTotal?.[fieldIdx]?.value || 0,
|
||||||
|
grandTotals[fieldIdx]?.value || 0,
|
||||||
|
prevColValues[fieldIdx],
|
||||||
|
displayMode === "runningTotalByRow"
|
||||||
|
? rowRunning[fieldIdx]
|
||||||
|
: colRunning.get(fieldIdx) || 0,
|
||||||
|
dataField.format
|
||||||
|
);
|
||||||
|
|
||||||
|
prevColValues[fieldIdx] = val.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
field: val.field,
|
||||||
|
value: result.value,
|
||||||
|
formattedValue: result.formattedValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
newMatrix.set(cellKey, newValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 총합계 계산 ====================
|
// ==================== 총합계 계산 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -584,7 +764,7 @@ export function processPivotData(
|
||||||
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
||||||
|
|
||||||
// 데이터 매트릭스 생성
|
// 데이터 매트릭스 생성
|
||||||
const dataMatrix = buildDataMatrix(
|
let dataMatrix = buildDataMatrix(
|
||||||
filteredData,
|
filteredData,
|
||||||
rowFields,
|
rowFields,
|
||||||
columnFields,
|
columnFields,
|
||||||
|
|
@ -603,6 +783,17 @@ export function processPivotData(
|
||||||
flatColumnLeaves
|
flatColumnLeaves
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Summary Display Mode 적용
|
||||||
|
dataMatrix = applyDisplayModeToMatrix(
|
||||||
|
dataMatrix,
|
||||||
|
dataFields,
|
||||||
|
flatRows,
|
||||||
|
flatColumnLeaves,
|
||||||
|
grandTotals.row,
|
||||||
|
grandTotals.column,
|
||||||
|
grandTotals.grand
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rowHeaders,
|
rowHeaders,
|
||||||
columnHeaders,
|
columnHeaders,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||||
|
|
@ -135,6 +136,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (item[underscoreKey] !== undefined) {
|
if (item[underscoreKey] !== undefined) {
|
||||||
return item[underscoreKey];
|
return item[underscoreKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6️⃣ 🆕 모든 키에서 _fieldName으로 끝나는 키 찾기
|
||||||
|
// 예: partner_id_customer_name (프론트엔드가 customer_id로 추론했지만 실제는 partner_id인 경우)
|
||||||
|
const matchingKey = Object.keys(item).find((key) => key.endsWith(`_${fieldName}`));
|
||||||
|
if (matchingKey && item[matchingKey] !== undefined) {
|
||||||
|
return item[matchingKey];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -162,6 +170,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 추가 탭 관련 상태
|
||||||
|
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭
|
||||||
|
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터 캐시
|
||||||
|
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
|
||||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||||
|
|
@ -603,6 +616,41 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 🆕 간단한 값 포맷팅 함수 (추가 탭용)
|
||||||
|
const formatValue = useCallback(
|
||||||
|
(
|
||||||
|
value: any,
|
||||||
|
format?: {
|
||||||
|
type?: "number" | "currency" | "date" | "text";
|
||||||
|
thousandSeparator?: boolean;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
dateFormat?: string;
|
||||||
|
},
|
||||||
|
): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 포맷
|
||||||
|
if (
|
||||||
|
format?.type === "number" ||
|
||||||
|
format?.type === "currency" ||
|
||||||
|
format?.thousandSeparator ||
|
||||||
|
format?.decimalPlaces !== undefined
|
||||||
|
) {
|
||||||
|
return formatNumberValue(value, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
},
|
||||||
|
[formatDateValue, formatNumberValue],
|
||||||
|
);
|
||||||
|
|
||||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||||
const formatCellValue = useCallback(
|
const formatCellValue = useCallback(
|
||||||
(
|
(
|
||||||
|
|
@ -953,11 +1001,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
||||||
|
|
||||||
// 엔티티 조인 API로 데이터 조회
|
// 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달)
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
search: searchConditions,
|
search: searchConditions,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||||
|
|
@ -1030,12 +1079,137 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 추가 탭 데이터 로딩 함수
|
||||||
|
const loadTabData = useCallback(
|
||||||
|
async (tabIndex: number, leftItem: any) => {
|
||||||
|
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
||||||
|
if (!tabConfig || !leftItem || isDesignMode) return;
|
||||||
|
|
||||||
|
const tabTableName = tabConfig.tableName;
|
||||||
|
if (!tabTableName) return;
|
||||||
|
|
||||||
|
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
|
||||||
|
try {
|
||||||
|
// 조인 키 확인
|
||||||
|
const keys = tabConfig.relation?.keys;
|
||||||
|
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
||||||
|
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
||||||
|
|
||||||
|
let resultData: any[] = [];
|
||||||
|
|
||||||
|
if (leftColumn && rightColumn) {
|
||||||
|
// 조인 조건이 있는 경우
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
const searchConditions: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (keys && keys.length > 0) {
|
||||||
|
// 복합키
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||||
|
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 단일키
|
||||||
|
const leftValue = leftItem[leftColumn];
|
||||||
|
if (leftValue !== undefined) {
|
||||||
|
searchConditions[rightColumn] = leftValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions);
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
|
search: searchConditions,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
resultData = result.data || [];
|
||||||
|
} else {
|
||||||
|
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
resultData = result.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 필터 적용
|
||||||
|
const dataFilter = tabConfig.dataFilter;
|
||||||
|
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||||
|
resultData = resultData.filter((item: any) => {
|
||||||
|
return dataFilter.conditions.every((cond: any) => {
|
||||||
|
const value = item[cond.column];
|
||||||
|
const condValue = cond.value;
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals":
|
||||||
|
return value === condValue;
|
||||||
|
case "notEquals":
|
||||||
|
return value !== condValue;
|
||||||
|
case "contains":
|
||||||
|
return String(value).includes(String(condValue));
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 제거 적용
|
||||||
|
const deduplication = tabConfig.deduplication;
|
||||||
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
|
const groupedMap = new Map<string, any>();
|
||||||
|
resultData.forEach((item) => {
|
||||||
|
const key = String(item[deduplication.groupByColumn] || "");
|
||||||
|
const existing = groupedMap.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
groupedMap.set(key, item);
|
||||||
|
} else {
|
||||||
|
// keepStrategy에 따라 유지할 항목 결정
|
||||||
|
const sortCol = deduplication.sortColumn || "start_date";
|
||||||
|
const existingVal = existing[sortCol];
|
||||||
|
const newVal = item[sortCol];
|
||||||
|
if (deduplication.keepStrategy === "latest" && newVal > existingVal) {
|
||||||
|
groupedMap.set(key, item);
|
||||||
|
} else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) {
|
||||||
|
groupedMap.set(key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resultData = Array.from(groupedMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length);
|
||||||
|
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
|
||||||
|
toast({
|
||||||
|
title: "데이터 로드 실패",
|
||||||
|
description: `탭 데이터를 불러올 수 없습니다.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
||||||
|
);
|
||||||
|
|
||||||
// 좌측 항목 선택 핸들러
|
// 좌측 항목 선택 핸들러
|
||||||
const handleLeftItemSelect = useCallback(
|
const handleLeftItemSelect = useCallback(
|
||||||
(item: any) => {
|
(item: any) => {
|
||||||
setSelectedLeftItem(item);
|
setSelectedLeftItem(item);
|
||||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||||
loadRightData(item);
|
setTabsData({}); // 모든 탭 데이터 초기화
|
||||||
|
|
||||||
|
// 현재 활성 탭에 따라 데이터 로드
|
||||||
|
if (activeTabIndex === 0) {
|
||||||
|
loadRightData(item);
|
||||||
|
} else {
|
||||||
|
loadTabData(activeTabIndex, item);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
||||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
|
@ -1046,7 +1220,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
|
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 탭 변경 핸들러
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(newTabIndex: number) => {
|
||||||
|
setActiveTabIndex(newTabIndex);
|
||||||
|
|
||||||
|
// 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드
|
||||||
|
if (selectedLeftItem) {
|
||||||
|
if (newTabIndex === 0) {
|
||||||
|
// 기본 탭: 우측 패널 데이터가 없으면 로드
|
||||||
|
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 추가 탭: 해당 탭 데이터가 없으면 로드
|
||||||
|
if (!tabsData[newTabIndex]) {
|
||||||
|
loadTabData(newTabIndex, selectedLeftItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 우측 항목 확장/축소 토글
|
// 우측 항목 확장/축소 토글
|
||||||
|
|
@ -1442,14 +1639,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
// 수정 버튼 핸들러
|
// 수정 버튼 핸들러
|
||||||
const handleEditClick = useCallback(
|
const handleEditClick = useCallback(
|
||||||
(panel: "left" | "right", item: any) => {
|
async (panel: "left" | "right", item: any) => {
|
||||||
|
// 🆕 현재 활성 탭의 설정 가져오기
|
||||||
|
const currentTabConfig =
|
||||||
|
activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||||
// 🆕 우측 패널 수정 버튼 설정 확인
|
// 🆕 우측 패널 수정 버튼 설정 확인
|
||||||
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") {
|
||||||
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
const modalScreenId = currentTabConfig?.editButton?.modalScreenId;
|
||||||
|
|
||||||
if (modalScreenId) {
|
if (modalScreenId) {
|
||||||
// 커스텀 모달 화면 열기
|
// 커스텀 모달 화면 열기
|
||||||
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
const rightTableName = currentTabConfig?.tableName || "";
|
||||||
|
|
||||||
console.log("✅ 수정 모달 열기:", {
|
console.log("✅ 수정 모달 열기:", {
|
||||||
tableName: rightTableName,
|
tableName: rightTableName,
|
||||||
|
|
@ -1463,33 +1665,108 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 groupByColumns 추출
|
// 🆕 groupByColumns 추출
|
||||||
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
const groupByColumns = currentTabConfig?.editButton?.groupByColumns || [];
|
||||||
|
|
||||||
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
||||||
groupByColumns,
|
groupByColumns,
|
||||||
editButtonConfig: componentConfig.rightPanel?.editButton,
|
editButtonConfig: currentTabConfig?.editButton,
|
||||||
hasGroupByColumns: groupByColumns.length > 0,
|
hasGroupByColumns: groupByColumns.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출)
|
||||||
|
let allRelatedRecords = [item]; // 기본값: 현재 아이템만
|
||||||
|
|
||||||
|
if (groupByColumns.length > 0) {
|
||||||
|
// groupByColumns 값으로 검색 조건 생성
|
||||||
|
const matchConditions: Record<string, any> = {};
|
||||||
|
groupByColumns.forEach((col: string) => {
|
||||||
|
if (item[col] !== undefined && item[col] !== null) {
|
||||||
|
matchConditions[col] = item[col];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", {
|
||||||
|
테이블: rightTableName,
|
||||||
|
조건: matchConditions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(matchConditions).length > 0) {
|
||||||
|
// 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출)
|
||||||
|
try {
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
|
// 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확)
|
||||||
|
const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({
|
||||||
|
id: `exact-${key}`,
|
||||||
|
columnName: key,
|
||||||
|
operator: "equals",
|
||||||
|
value: value,
|
||||||
|
valueType: "text",
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters);
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
|
// search 대신 dataFilter 사용 (정확 매칭)
|
||||||
|
dataFilter: {
|
||||||
|
enabled: true,
|
||||||
|
matchType: "all",
|
||||||
|
filters: exactMatchFilters,
|
||||||
|
},
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
// 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기)
|
||||||
|
deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔍 디버깅: API 응답 구조 확인
|
||||||
|
console.log("🔍 [SplitPanel] API 응답 전체:", result);
|
||||||
|
console.log("🔍 [SplitPanel] result.data:", result.data);
|
||||||
|
console.log("🔍 [SplitPanel] result 타입:", typeof result);
|
||||||
|
|
||||||
|
// result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라)
|
||||||
|
const dataArray = Array.isArray(result) ? result : (result.data || []);
|
||||||
|
|
||||||
|
if (dataArray.length > 0) {
|
||||||
|
allRelatedRecords = dataArray;
|
||||||
|
console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", {
|
||||||
|
조건: matchConditions,
|
||||||
|
결과수: allRelatedRecords.length,
|
||||||
|
레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error);
|
||||||
|
allRelatedRecords = [item];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
||||||
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("openScreenModal", {
|
new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
screenId: modalScreenId,
|
screenId: modalScreenId,
|
||||||
editData: item, // 전체 데이터를 직접 전달
|
editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열)
|
||||||
...(groupByColumns.length > 0 && {
|
urlParams: {
|
||||||
urlParams: {
|
mode: "edit", // 🆕 수정 모드 표시
|
||||||
|
...(groupByColumns.length > 0 && {
|
||||||
groupByColumns: JSON.stringify(groupByColumns),
|
groupByColumns: JSON.stringify(groupByColumns),
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
||||||
screenId: modalScreenId,
|
screenId: modalScreenId,
|
||||||
editData: item,
|
editData: allRelatedRecords,
|
||||||
|
recordCount: allRelatedRecords.length,
|
||||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1503,7 +1780,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setEditModalFormData({ ...item });
|
setEditModalFormData({ ...item });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
},
|
},
|
||||||
[componentConfig],
|
[componentConfig, activeTabIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 수정 모달 저장
|
// 수정 모달 저장
|
||||||
|
|
@ -1603,13 +1880,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
// 삭제 확인
|
// 삭제 확인
|
||||||
const handleDeleteConfirm = useCallback(async () => {
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
|
// 🆕 현재 활성 탭의 설정 가져오기
|
||||||
|
const currentTabConfig =
|
||||||
|
activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||||
|
|
||||||
// 우측 패널 삭제 시 중계 테이블 확인
|
// 우측 패널 삭제 시 중계 테이블 확인
|
||||||
let tableName =
|
let tableName =
|
||||||
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
|
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : currentTabConfig?.tableName;
|
||||||
|
|
||||||
// 우측 패널 + 중계 테이블 모드인 경우
|
// 우측 패널 + 중계 테이블 모드인 경우
|
||||||
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
|
if (deleteModalPanel === "right" && currentTabConfig?.addConfig?.targetTable) {
|
||||||
tableName = componentConfig.rightPanel.addConfig.targetTable;
|
tableName = currentTabConfig.addConfig.targetTable;
|
||||||
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1739,7 +2022,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setRightData(null);
|
setRightData(null);
|
||||||
}
|
}
|
||||||
} else if (deleteModalPanel === "right" && selectedLeftItem) {
|
} else if (deleteModalPanel === "right" && selectedLeftItem) {
|
||||||
loadRightData(selectedLeftItem);
|
// 🆕 현재 활성 탭에 따라 새로고침
|
||||||
|
if (activeTabIndex === 0) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
} else {
|
||||||
|
loadTabData(activeTabIndex, selectedLeftItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -1763,7 +2051,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, activeTabIndex, loadTabData]);
|
||||||
|
|
||||||
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
||||||
const handleItemAddClick = useCallback(
|
const handleItemAddClick = useCallback(
|
||||||
|
|
@ -2584,6 +2872,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="flex flex-shrink-0 flex-col"
|
className="flex flex-shrink-0 flex-col"
|
||||||
>
|
>
|
||||||
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
||||||
|
{/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */}
|
||||||
|
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && (
|
||||||
|
<div className="flex-shrink-0 border-b">
|
||||||
|
<Tabs
|
||||||
|
value={String(activeTabIndex)}
|
||||||
|
onValueChange={(value) => handleTabChange(Number(value))}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="h-9 w-full justify-start rounded-none border-b-0 bg-transparent p-0 px-2">
|
||||||
|
<TabsTrigger
|
||||||
|
value="0"
|
||||||
|
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
|
>
|
||||||
|
{componentConfig.rightPanel?.title || "기본"}
|
||||||
|
</TabsTrigger>
|
||||||
|
{componentConfig.rightPanel?.additionalTabs?.map((tab, index) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.tabId}
|
||||||
|
value={String(index + 1)}
|
||||||
|
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
|
>
|
||||||
|
{tab.label || `탭 ${index + 1}`}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CardHeader
|
<CardHeader
|
||||||
className="flex-shrink-0 border-b"
|
className="flex-shrink-0 border-b"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -2596,16 +2912,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold">
|
<CardTitle className="text-base font-semibold">
|
||||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
{activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel?.title || "우측 패널"
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title ||
|
||||||
|
componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label ||
|
||||||
|
"우측 패널"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{componentConfig.rightPanel?.showAdd && (
|
{/* 현재 활성 탭에 따른 추가 버튼 */}
|
||||||
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
{activeTabIndex === 0
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
? componentConfig.rightPanel?.showAdd && (
|
||||||
추가
|
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||||
</Button>
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
)}
|
추가
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
|
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2625,16 +2953,228 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-auto p-4">
|
||||||
{/* 우측 데이터 */}
|
{/* 🆕 추가 탭 데이터 렌더링 */}
|
||||||
{isLoadingRight ? (
|
{activeTabIndex > 0 ? (
|
||||||
// 로딩 중
|
// 추가 탭 컨텐츠
|
||||||
<div className="flex h-full items-center justify-center">
|
(() => {
|
||||||
<div className="text-center">
|
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
const currentTabData = tabsData[activeTabIndex] || [];
|
||||||
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
const isTabLoading = tabsLoading[activeTabIndex];
|
||||||
</div>
|
|
||||||
</div>
|
if (isTabLoading) {
|
||||||
) : rightData ? (
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedLeftItem) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">좌측에서 항목을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTabData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 데이터 렌더링 (목록/테이블 모드)
|
||||||
|
const isTableMode = currentTabConfig?.displayMode === "table";
|
||||||
|
|
||||||
|
if (isTableMode) {
|
||||||
|
// 테이블 모드
|
||||||
|
const displayColumns = currentTabConfig?.columns || [];
|
||||||
|
const columnsToShow =
|
||||||
|
displayColumns.length > 0
|
||||||
|
? displayColumns.map((col) => ({
|
||||||
|
...col,
|
||||||
|
label: col.label || col.name,
|
||||||
|
}))
|
||||||
|
: Object.keys(currentTabData[0] || {})
|
||||||
|
.filter(shouldShowField)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((key) => ({ name: key, label: key }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
{columnsToShow.map((col: any) => (
|
||||||
|
<th
|
||||||
|
key={col.name}
|
||||||
|
className="px-3 py-2 text-left font-medium"
|
||||||
|
style={{ width: col.width ? `${col.width}px` : "auto" }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||||
|
<th className="w-20 px-3 py-2 text-center font-medium">작업</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentTabData.map((item: any, idx: number) => (
|
||||||
|
<tr key={item.id || idx} className="hover:bg-muted/30 border-t">
|
||||||
|
{columnsToShow.map((col: any) => (
|
||||||
|
<td key={col.name} className="px-3 py-2">
|
||||||
|
{formatValue(item[col.name], col.format)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{currentTabConfig?.showEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => handleEditClick("right", item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||||
|
onClick={() => handleDeleteClick("right", item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 목록 (카드) 모드
|
||||||
|
const displayColumns = currentTabConfig?.columns || [];
|
||||||
|
const summaryCount = currentTabConfig?.summaryColumnCount ?? 3;
|
||||||
|
const showLabel = currentTabConfig?.summaryShowLabel ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{currentTabData.map((item: any, idx: number) => {
|
||||||
|
const itemId = item.id || idx;
|
||||||
|
const isExpanded = expandedRightItems.has(itemId);
|
||||||
|
|
||||||
|
// 표시할 컬럼 결정
|
||||||
|
const columnsToShow =
|
||||||
|
displayColumns.length > 0
|
||||||
|
? displayColumns
|
||||||
|
: Object.keys(item)
|
||||||
|
.filter(shouldShowField)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((key) => ({ name: key, label: key }));
|
||||||
|
|
||||||
|
const summaryColumns = columnsToShow.slice(0, summaryCount);
|
||||||
|
const detailColumns = columnsToShow.slice(summaryCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={itemId} className="rounded-lg border bg-white p-3">
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-start justify-between"
|
||||||
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{summaryColumns.map((col: any) => (
|
||||||
|
<div key={col.name} className="text-sm">
|
||||||
|
{showLabel && (
|
||||||
|
<span className="text-muted-foreground mr-1">{col.label}:</span>
|
||||||
|
)}
|
||||||
|
<span className={col.bold ? "font-semibold" : ""}>
|
||||||
|
{formatValue(item[col.name], col.format)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex items-center gap-1">
|
||||||
|
{currentTabConfig?.showEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditClick("right", item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteClick("right", item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detailColumns.length > 0 && (
|
||||||
|
isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isExpanded && detailColumns.length > 0 && (
|
||||||
|
<div className="mt-2 border-t pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{detailColumns.map((col: any) => (
|
||||||
|
<div key={col.name} className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{col.label}:</span>
|
||||||
|
<span className="ml-1">{formatValue(item[col.name], col.format)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
/* 기본 탭 (우측 패널) 데이터 */
|
||||||
|
<>
|
||||||
|
{isLoadingRight ? (
|
||||||
|
// 로딩 중
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : rightData ? (
|
||||||
// 실제 데이터 표시
|
// 실제 데이터 표시
|
||||||
Array.isArray(rightData) ? (
|
Array.isArray(rightData) ? (
|
||||||
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
||||||
|
|
@ -3077,6 +3617,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
// Accordion 제거 - 단순 섹션으로 변경
|
// Accordion 제거 - 단순 섹션으로 변경
|
||||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown } from "lucide-react";
|
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, GripVertical } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
|
|
@ -189,6 +190,848 @@ const ScreenSelector: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추가 탭 설정 패널 (우측 패널과 동일한 구조)
|
||||||
|
*/
|
||||||
|
interface AdditionalTabConfigPanelProps {
|
||||||
|
tab: AdditionalTabConfig;
|
||||||
|
tabIndex: number;
|
||||||
|
config: SplitPanelLayoutConfig;
|
||||||
|
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||||
|
availableRightTables: TableInfo[];
|
||||||
|
leftTableColumns: ColumnInfo[];
|
||||||
|
menuObjid?: number;
|
||||||
|
// 공유 컬럼 로드 상태
|
||||||
|
loadedTableColumns: Record<string, ColumnInfo[]>;
|
||||||
|
loadTableColumns: (tableName: string) => Promise<void>;
|
||||||
|
loadingColumns: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
|
tab,
|
||||||
|
tabIndex,
|
||||||
|
config,
|
||||||
|
updateRightPanel,
|
||||||
|
availableRightTables,
|
||||||
|
leftTableColumns,
|
||||||
|
menuObjid,
|
||||||
|
loadedTableColumns,
|
||||||
|
loadTableColumns,
|
||||||
|
loadingColumns,
|
||||||
|
}) => {
|
||||||
|
// 탭 테이블 변경 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) {
|
||||||
|
loadTableColumns(tab.tableName);
|
||||||
|
}
|
||||||
|
}, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]);
|
||||||
|
|
||||||
|
// 현재 탭의 컬럼 목록
|
||||||
|
const tabColumns = useMemo(() => {
|
||||||
|
return tab.tableName ? loadedTableColumns[tab.tableName] || [] : [];
|
||||||
|
}, [tab.tableName, loadedTableColumns]);
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
const loadingTabColumns = tab.tableName ? loadingColumns[tab.tableName] || false : false;
|
||||||
|
|
||||||
|
// 탭 업데이트 헬퍼
|
||||||
|
const updateTab = (updates: Partial<AdditionalTabConfig>) => {
|
||||||
|
const newTabs = [...(config.rightPanel?.additionalTabs || [])];
|
||||||
|
newTabs[tabIndex] = { ...tab, ...updates };
|
||||||
|
updateRightPanel({ additionalTabs: newTabs });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionItem
|
||||||
|
key={tab.tabId}
|
||||||
|
value={tab.tabId}
|
||||||
|
className="rounded-lg border bg-gray-50"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-3 py-2 hover:no-underline">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{tab.label || `탭 ${tabIndex + 1}`}
|
||||||
|
</span>
|
||||||
|
{tab.tableName && (
|
||||||
|
<span className="text-xs text-gray-500">({tab.tableName})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-3 pb-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ===== 1. 기본 정보 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">기본 정보</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">탭 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.label}
|
||||||
|
onChange={(e) => updateTab({ label: e.target.value })}
|
||||||
|
placeholder="탭 이름"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.title}
|
||||||
|
onChange={(e) => updateTab({ title: e.target.value })}
|
||||||
|
placeholder="패널 제목"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">패널 헤더 높이</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={tab.panelHeaderHeight ?? 48}
|
||||||
|
onChange={(e) => updateTab({ panelHeaderHeight: parseInt(e.target.value) || 48 })}
|
||||||
|
placeholder="48"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 2. 테이블 선택 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">테이블 설정</Label>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{tab.tableName || "테이블을 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{availableRightTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
|
onSelect={() => updateTab({ tableName: table.tableName, columns: [] })}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
tab.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 3. 표시 모드 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">표시 설정</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">표시 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.displayMode || "list"}
|
||||||
|
onValueChange={(value: "list" | "table") => updateTab({ displayMode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="list">목록 (카드)</SelectItem>
|
||||||
|
<SelectItem value="table">테이블</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 설정 (목록 모드) */}
|
||||||
|
{tab.displayMode === "list" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">요약 컬럼 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={tab.summaryColumnCount ?? 3}
|
||||||
|
onChange={(e) => updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-5">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-summary-label`}
|
||||||
|
checked={tab.summaryShowLabel ?? true}
|
||||||
|
onCheckedChange={(checked) => updateTab({ summaryShowLabel: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-summary-label`} className="text-xs">라벨 표시</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 4. 컬럼 매핑 (조인 키) ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">컬럼 매핑 (조인 키)</Label>
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
좌측 패널 선택 시 관련 데이터만 표시합니다
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">좌측 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
relation: {
|
||||||
|
...tab.relation,
|
||||||
|
type: "join",
|
||||||
|
keys: [{ leftColumn: value, rightColumn: tab.relation?.keys?.[0]?.rightColumn || "" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{leftTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">우측 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
relation: {
|
||||||
|
...tab.relation,
|
||||||
|
type: "join",
|
||||||
|
keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 5. 기능 버튼 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">기능 버튼</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-search`}
|
||||||
|
checked={tab.showSearch}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showSearch: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-search`} className="text-xs">검색</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-add`}
|
||||||
|
checked={tab.showAdd}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showAdd: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-add`} className="text-xs">추가</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-edit`}
|
||||||
|
checked={tab.showEdit}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showEdit: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-edit`} className="text-xs">수정</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-delete`}
|
||||||
|
checked={tab.showDelete}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showDelete: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-delete`} className="text-xs">삭제</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 6. 표시 컬럼 설정 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold text-green-700">표시할 컬럼 선택</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const currentColumns = tab.columns || [];
|
||||||
|
const newColumns = [...currentColumns, { name: "", label: "", width: 100 }];
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!tab.tableName || loadingTabColumns}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-600">
|
||||||
|
표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 테이블 미선택 상태 */}
|
||||||
|
{!tab.tableName && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">먼저 테이블을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 선택됨 - 컬럼 목록 */}
|
||||||
|
{tab.tableName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 로딩 상태 */}
|
||||||
|
{loadingTabColumns && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">컬럼을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 설정된 컬럼이 없을 때 */}
|
||||||
|
{!loadingTabColumns && (tab.columns || []).length === 0 && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">설정된 컬럼이 없습니다</p>
|
||||||
|
<p className="mt-1 text-[10px] text-gray-400">컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 설정된 컬럼 목록 */}
|
||||||
|
{!loadingTabColumns && (tab.columns || []).length > 0 && (
|
||||||
|
(tab.columns || []).map((col, colIndex) => (
|
||||||
|
<div key={colIndex} className="space-y-2 rounded-md border bg-white p-3">
|
||||||
|
{/* 상단: 순서 변경 + 삭제 버튼 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (colIndex === 0) return;
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
[newColumns[colIndex - 1], newColumns[colIndex]] = [newColumns[colIndex], newColumns[colIndex - 1]];
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={colIndex === 0}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const columns = tab.columns || [];
|
||||||
|
if (colIndex === columns.length - 1) return;
|
||||||
|
const newColumns = [...columns];
|
||||||
|
[newColumns[colIndex], newColumns[colIndex + 1]] = [newColumns[colIndex + 1], newColumns[colIndex]];
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={colIndex === (tab.columns || []).length - 1}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-[10px] text-gray-400">#{colIndex + 1}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (tab.columns || []).filter((_, i) => i !== colIndex);
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-500">컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={col.name}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const selectedCol = tabColumns.find((c) => c.columnName === value);
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
newColumns[colIndex] = {
|
||||||
|
...col,
|
||||||
|
name: value,
|
||||||
|
label: selectedCol?.columnLabel || value,
|
||||||
|
};
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
<span className="ml-1 text-[10px] text-gray-400">({column.columnName})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 + 너비 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-500">라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, label: e.target.value };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
placeholder="표시 라벨"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-500">너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={col.width || 100}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, width: parseInt(e.target.value) || 100 };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
placeholder="100"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
|
||||||
|
{tab.showAdd && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold text-purple-700">추가 모달 컬럼 설정</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const currentColumns = tab.addModalColumns || [];
|
||||||
|
const newColumns = [...currentColumns, { name: "", label: "", required: false }];
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!tab.tableName}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(tab.addModalColumns || []).length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">추가 모달에 표시할 컬럼을 설정하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(tab.addModalColumns || []).map((col, colIndex) => (
|
||||||
|
<div key={colIndex} className="flex items-center gap-2 rounded-md border bg-white p-2">
|
||||||
|
<Select
|
||||||
|
value={col.name}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const selectedCol = tabColumns.find((c) => c.columnName === value);
|
||||||
|
const newColumns = [...(tab.addModalColumns || [])];
|
||||||
|
newColumns[colIndex] = {
|
||||||
|
...col,
|
||||||
|
name: value,
|
||||||
|
label: selectedCol?.columnLabel || value,
|
||||||
|
};
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(tab.addModalColumns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, label: e.target.value };
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={col.required}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const newColumns = [...(tab.addModalColumns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, required: !!checked };
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px]">필수</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (tab.addModalColumns || []).filter((_, i) => i !== colIndex);
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 text-red-500"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 8. 데이터 필터링 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">데이터 필터링</Label>
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={tab.tableName}
|
||||||
|
columns={tabColumns}
|
||||||
|
config={tab.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateTab({ dataFilter })}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 9. 중복 데이터 제거 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">중복 데이터 제거</Label>
|
||||||
|
<Switch
|
||||||
|
checked={tab.deduplication?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
updateTab({
|
||||||
|
deduplication: {
|
||||||
|
enabled: true,
|
||||||
|
groupByColumn: "",
|
||||||
|
keepStrategy: "latest",
|
||||||
|
sortColumn: "start_date",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTab({ deduplication: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tab.deduplication?.enabled && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">기준 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deduplication?.groupByColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
deduplication: { ...tab.deduplication!, groupByColumn: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">정렬 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deduplication?.sortColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
deduplication: { ...tab.deduplication!, sortColumn: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">유지 전략</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deduplication?.keepStrategy || "latest"}
|
||||||
|
onValueChange={(value: "latest" | "earliest" | "base_price" | "current_date") => {
|
||||||
|
updateTab({
|
||||||
|
deduplication: { ...tab.deduplication!, keepStrategy: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="latest">최신</SelectItem>
|
||||||
|
<SelectItem value="earliest">가장 오래된</SelectItem>
|
||||||
|
<SelectItem value="current_date">현재 날짜 기준</SelectItem>
|
||||||
|
<SelectItem value="base_price">기준가</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 10. 수정 버튼 설정 ===== */}
|
||||||
|
{tab.showEdit && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-700">수정 버튼 설정</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">수정 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.editButton?.mode || "auto"}
|
||||||
|
onValueChange={(value: "auto" | "modal") => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, mode: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab.editButton?.mode === "modal" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">수정 모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={tab.editButton?.modalScreenId}
|
||||||
|
onChange={(screenId) => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.editButton?.buttonLabel || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, buttonLabel: e.target.value || undefined },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="수정"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.editButton?.buttonVariant || "ghost"}
|
||||||
|
onValueChange={(value: "default" | "outline" | "ghost") => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, buttonVariant: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ghost">Ghost (기본)</SelectItem>
|
||||||
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹핑 기준 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">그룹핑 기준 컬럼</Label>
|
||||||
|
<p className="text-[9px] text-gray-500">수정 시 같은 값을 가진 레코드를 함께 불러옵니다</p>
|
||||||
|
<div className="max-h-[120px] space-y-1 overflow-y-auto rounded-md border bg-white p-2">
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<div key={col.columnName} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-groupby-${col.columnName}`}
|
||||||
|
checked={(tab.editButton?.groupByColumns || []).includes(col.columnName)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const current = tab.editButton?.groupByColumns || [];
|
||||||
|
const newColumns = checked
|
||||||
|
? [...current, col.columnName]
|
||||||
|
: current.filter((c) => c !== col.columnName);
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, groupByColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-groupby-${col.columnName}`} className="text-[10px]">
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 11. 삭제 버튼 설정 ===== */}
|
||||||
|
{tab.showDelete && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||||
|
<Label className="text-xs font-semibold text-red-700">삭제 버튼 설정</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.deleteButton?.buttonLabel || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTab({
|
||||||
|
deleteButton: { ...tab.deleteButton, enabled: true, buttonLabel: e.target.value || undefined },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="삭제"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deleteButton?.buttonVariant || "ghost"}
|
||||||
|
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
|
||||||
|
updateTab({
|
||||||
|
deleteButton: { ...tab.deleteButton, enabled: true, buttonVariant: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ghost">Ghost (기본)</SelectItem>
|
||||||
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="destructive">Destructive (빨강)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">삭제 확인 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.deleteButton?.confirmMessage || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTab({
|
||||||
|
deleteButton: { ...tab.deleteButton, enabled: true, confirmMessage: e.target.value || undefined },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="정말 삭제하시겠습니까?"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 탭 삭제 버튼 ===== */}
|
||||||
|
<div className="flex justify-end border-t pt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
|
onClick={() => {
|
||||||
|
const newTabs = config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || [];
|
||||||
|
updateRightPanel({ additionalTabs: newTabs });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
|
탭 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SplitPanelLayout 설정 패널
|
* SplitPanelLayout 설정 패널
|
||||||
*/
|
*/
|
||||||
|
|
@ -2854,6 +3697,72 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================== */}
|
||||||
|
{/* 추가 탭 설정 (우측 패널과 동일한 구조) */}
|
||||||
|
{/* ======================================== */}
|
||||||
|
<div className="mt-4 space-y-4 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">추가 탭</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
우측 패널에 다른 테이블 데이터를 탭으로 추가합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newTab: AdditionalTabConfig = {
|
||||||
|
tabId: `tab_${Date.now()}`,
|
||||||
|
label: `탭 ${(config.rightPanel?.additionalTabs?.length || 0) + 1}`,
|
||||||
|
title: "",
|
||||||
|
tableName: "",
|
||||||
|
displayMode: "list",
|
||||||
|
showSearch: false,
|
||||||
|
showAdd: false,
|
||||||
|
showEdit: true,
|
||||||
|
showDelete: true,
|
||||||
|
summaryColumnCount: 3,
|
||||||
|
summaryShowLabel: true,
|
||||||
|
};
|
||||||
|
updateRightPanel({
|
||||||
|
additionalTabs: [...(config.rightPanel?.additionalTabs || []), newTab],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
탭 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가된 탭 목록 */}
|
||||||
|
{(config.rightPanel?.additionalTabs?.length || 0) > 0 ? (
|
||||||
|
<Accordion type="multiple" className="space-y-2">
|
||||||
|
{config.rightPanel?.additionalTabs?.map((tab, tabIndex) => (
|
||||||
|
<AdditionalTabConfigPanel
|
||||||
|
key={tab.tabId}
|
||||||
|
tab={tab}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
config={config}
|
||||||
|
updateRightPanel={updateRightPanel}
|
||||||
|
availableRightTables={availableRightTables}
|
||||||
|
leftTableColumns={leftTableColumns}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
loadedTableColumns={loadedTableColumns}
|
||||||
|
loadTableColumns={loadTableColumns}
|
||||||
|
loadingColumns={loadingColumns}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
추가된 탭이 없습니다. [탭 추가] 버튼을 클릭하여 새 탭을 추가하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 레이아웃 설정 */}
|
||||||
<div className="mt-4 space-y-4 border-t pt-4">
|
<div className="mt-4 space-y-4 border-t pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -2886,3 +3795,4 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,105 @@
|
||||||
|
|
||||||
import { DataFilterConfig } from "@/types/screen-management";
|
import { DataFilterConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label)
|
||||||
|
*/
|
||||||
|
export interface AdditionalTabConfig {
|
||||||
|
// 탭 고유 정보
|
||||||
|
tabId: string;
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
// === 우측 패널과 동일한 설정 ===
|
||||||
|
title: string;
|
||||||
|
panelHeaderHeight?: number;
|
||||||
|
tableName?: string;
|
||||||
|
dataSource?: string;
|
||||||
|
displayMode?: "list" | "table";
|
||||||
|
showSearch?: boolean;
|
||||||
|
showAdd?: boolean;
|
||||||
|
showEdit?: boolean;
|
||||||
|
showDelete?: boolean;
|
||||||
|
summaryColumnCount?: number;
|
||||||
|
summaryShowLabel?: boolean;
|
||||||
|
|
||||||
|
columns?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
width?: number;
|
||||||
|
sortable?: boolean;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
bold?: boolean;
|
||||||
|
format?: {
|
||||||
|
type?: "number" | "currency" | "date" | "text";
|
||||||
|
thousandSeparator?: boolean;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
dateFormat?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
addModalColumns?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
relation?: {
|
||||||
|
type?: "join" | "detail";
|
||||||
|
leftColumn?: string;
|
||||||
|
rightColumn?: string;
|
||||||
|
foreignKey?: string;
|
||||||
|
keys?: Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
addConfig?: {
|
||||||
|
targetTable?: string;
|
||||||
|
autoFillColumns?: Record<string, any>;
|
||||||
|
leftPanelColumn?: string;
|
||||||
|
targetColumn?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
tableConfig?: {
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
showRowNumber?: boolean;
|
||||||
|
rowHeight?: number;
|
||||||
|
headerHeight?: number;
|
||||||
|
striped?: boolean;
|
||||||
|
bordered?: boolean;
|
||||||
|
hoverable?: boolean;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
|
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
editButton?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mode: "auto" | "modal";
|
||||||
|
modalScreenId?: number;
|
||||||
|
buttonLabel?: string;
|
||||||
|
buttonVariant?: "default" | "outline" | "ghost";
|
||||||
|
groupByColumns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteButton?: {
|
||||||
|
enabled: boolean;
|
||||||
|
buttonLabel?: string;
|
||||||
|
buttonVariant?: "default" | "outline" | "ghost" | "destructive";
|
||||||
|
confirmMessage?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface SplitPanelLayoutConfig {
|
export interface SplitPanelLayoutConfig {
|
||||||
// 좌측 패널 설정
|
// 좌측 패널 설정
|
||||||
leftPanel: {
|
leftPanel: {
|
||||||
|
|
@ -165,6 +264,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost")
|
buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost")
|
||||||
confirmMessage?: string; // 삭제 확인 메시지
|
confirmMessage?: string; // 삭제 확인 메시지
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 추가 탭 설정 (멀티 테이블 탭)
|
||||||
|
additionalTabs?: AdditionalTabConfig[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,10 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const [rightTableOpen, setRightTableOpen] = useState(false);
|
const [rightTableOpen, setRightTableOpen] = useState(false);
|
||||||
const [leftModalOpen, setLeftModalOpen] = useState(false);
|
const [leftModalOpen, setLeftModalOpen] = useState(false);
|
||||||
const [rightModalOpen, setRightModalOpen] = useState(false);
|
const [rightModalOpen, setRightModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 개별 수정 모달 화면 선택 Popover 상태
|
||||||
|
const [leftEditModalOpen, setLeftEditModalOpen] = useState(false);
|
||||||
|
const [rightEditModalOpen, setRightEditModalOpen] = useState(false);
|
||||||
|
|
||||||
// 컬럼 세부설정 모달 상태 (기존 - 하위 호환성)
|
// 컬럼 세부설정 모달 상태 (기존 - 하위 호환성)
|
||||||
const [columnConfigModalOpen, setColumnConfigModalOpen] = useState(false);
|
const [columnConfigModalOpen, setColumnConfigModalOpen] = useState(false);
|
||||||
|
|
@ -1004,6 +1008,19 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onCheckedChange={(checked) => updateConfig("leftPanel.showEditButton", checked)}
|
onCheckedChange={(checked) => updateConfig("leftPanel.showEditButton", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */}
|
||||||
|
{config.leftPanel?.showEditButton && (
|
||||||
|
<div className="ml-4 mt-1">
|
||||||
|
<Label className="text-xs">수정 모달 화면</Label>
|
||||||
|
<ScreenSelect
|
||||||
|
value={config.leftPanel?.editModalScreenId}
|
||||||
|
onValueChange={(value) => updateConfig("leftPanel.editModalScreenId", value)}
|
||||||
|
placeholder="수정 모달 화면 선택"
|
||||||
|
open={leftEditModalOpen}
|
||||||
|
onOpenChange={setLeftEditModalOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">삭제 버튼</Label>
|
<Label className="text-xs">삭제 버튼</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1322,6 +1339,19 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
|
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */}
|
||||||
|
{config.rightPanel?.showEditButton && (
|
||||||
|
<div className="ml-4 mt-1">
|
||||||
|
<Label className="text-xs">수정 모달 화면</Label>
|
||||||
|
<ScreenSelect
|
||||||
|
value={config.rightPanel?.editModalScreenId}
|
||||||
|
onValueChange={(value) => updateConfig("rightPanel.editModalScreenId", value)}
|
||||||
|
placeholder="수정 모달 화면 선택"
|
||||||
|
open={rightEditModalOpen}
|
||||||
|
onOpenChange={setRightEditModalOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">삭제 버튼</Label>
|
<Label className="text-xs">삭제 버튼</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
|
||||||
|
|
@ -967,11 +967,11 @@ export class ButtonActionExecutor {
|
||||||
deletedItemIds,
|
deletedItemIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 삭제 API 호출
|
// 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
for (const itemId of deletedItemIds) {
|
for (const itemId of deletedItemIds) {
|
||||||
try {
|
try {
|
||||||
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
|
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable);
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable, context.screenId);
|
||||||
if (deleteResult.success) {
|
if (deleteResult.success) {
|
||||||
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
|
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1967,7 +1967,8 @@ export class ButtonActionExecutor {
|
||||||
for (const deletedItem of deletedItems) {
|
for (const deletedItem of deletedItems) {
|
||||||
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
|
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
|
||||||
|
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName, context.screenId);
|
||||||
|
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(deleteResult.message || "품목 삭제 실패");
|
throw new Error(deleteResult.message || "품목 삭제 실패");
|
||||||
|
|
@ -2434,7 +2435,8 @@ export class ButtonActionExecutor {
|
||||||
if (deleteId) {
|
if (deleteId) {
|
||||||
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
||||||
|
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName, screenId);
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
|
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -2469,8 +2471,8 @@ export class ButtonActionExecutor {
|
||||||
if (tableName && screenId && formData.id) {
|
if (tableName && screenId && formData.id) {
|
||||||
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
|
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
|
||||||
|
|
||||||
// 실제 삭제 API 호출
|
// 실제 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName);
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName, screenId);
|
||||||
|
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
|
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
|
||||||
|
|
@ -4251,7 +4253,8 @@ export class ButtonActionExecutor {
|
||||||
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
|
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName);
|
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||||
|
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName, context.screenId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log("✅ 삭제 성공:", result);
|
console.log("✅ 삭제 성공:", result);
|
||||||
|
|
@ -4481,8 +4484,67 @@ export class ButtonActionExecutor {
|
||||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||||
|
|
||||||
let dataToExport: any[] = [];
|
let dataToExport: any[] = [];
|
||||||
|
let visibleColumns: string[] | undefined = undefined;
|
||||||
|
let columnLabels: Record<string, string> | undefined = undefined;
|
||||||
|
|
||||||
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
|
// 🆕 마스터-디테일 구조 확인 및 처리
|
||||||
|
if (context.screenId) {
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId);
|
||||||
|
|
||||||
|
if (relationResponse.success && relationResponse.data) {
|
||||||
|
// 마스터-디테일 구조인 경우 전용 API 사용
|
||||||
|
console.log("📊 마스터-디테일 엑셀 다운로드:", relationResponse.data);
|
||||||
|
|
||||||
|
const downloadResponse = await DynamicFormApi.getMasterDetailDownloadData(
|
||||||
|
context.screenId,
|
||||||
|
context.filterConditions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (downloadResponse.success && downloadResponse.data) {
|
||||||
|
dataToExport = downloadResponse.data.data;
|
||||||
|
visibleColumns = downloadResponse.data.columns;
|
||||||
|
|
||||||
|
// 헤더와 컬럼 매핑
|
||||||
|
columnLabels = {};
|
||||||
|
downloadResponse.data.columns.forEach((col: string, index: number) => {
|
||||||
|
columnLabels![col] = downloadResponse.data.headers[index] || col;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${dataToExport.length}행`);
|
||||||
|
} else {
|
||||||
|
toast.error("마스터-디테일 데이터 조회에 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 데이터 변환 및 다운로드
|
||||||
|
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||||
|
dataToExport = dataToExport.map((row: any) => {
|
||||||
|
const filteredRow: Record<string, any> = {};
|
||||||
|
visibleColumns!.forEach((columnName: string) => {
|
||||||
|
const label = columnLabels?.[columnName] || columnName;
|
||||||
|
filteredRow[label] = row[columnName];
|
||||||
|
});
|
||||||
|
return filteredRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일명 생성
|
||||||
|
let defaultFileName = relationResponse.data.masterTable || "데이터";
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const menuName = localStorage.getItem("currentMenuName");
|
||||||
|
if (menuName) defaultFileName = menuName;
|
||||||
|
}
|
||||||
|
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
|
|
||||||
|
await exportToExcel(dataToExport, fileName, sheetName, true);
|
||||||
|
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 기존 로직: 단일 테이블 처리
|
||||||
if (context.tableName) {
|
if (context.tableName) {
|
||||||
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||||
const storedData = tableDisplayStore.getTableData(context.tableName);
|
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||||
|
|
@ -4574,8 +4636,7 @@ export class ButtonActionExecutor {
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||||
let visibleColumns: string[] | undefined = undefined;
|
// visibleColumns, columnLabels는 함수 상단에서 이미 선언됨
|
||||||
let columnLabels: Record<string, string> | undefined = undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||||
|
|
@ -4776,8 +4837,53 @@ export class ButtonActionExecutor {
|
||||||
context,
|
context,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
tableName: context.tableName,
|
tableName: context.tableName,
|
||||||
|
screenId: context.screenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
|
||||||
|
let isMasterDetail = false;
|
||||||
|
let masterDetailRelation: any = null;
|
||||||
|
let masterDetailExcelConfig: any = undefined;
|
||||||
|
|
||||||
|
// 화면 레이아웃에서 분할 패널 자동 감지
|
||||||
|
if (context.screenId) {
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId);
|
||||||
|
|
||||||
|
if (relationResponse.success && relationResponse.data) {
|
||||||
|
isMasterDetail = true;
|
||||||
|
masterDetailRelation = relationResponse.data;
|
||||||
|
|
||||||
|
// 버튼 설정에서 채번 규칙 등 추가 설정 가져오기
|
||||||
|
if (config.masterDetailExcel) {
|
||||||
|
masterDetailExcelConfig = {
|
||||||
|
...config.masterDetailExcel,
|
||||||
|
// 분할 패널에서 감지한 테이블 정보로 덮어쓰기
|
||||||
|
masterTable: relationResponse.data.masterTable,
|
||||||
|
detailTable: relationResponse.data.detailTable,
|
||||||
|
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||||
|
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 버튼 설정이 없으면 분할 패널 정보만 사용
|
||||||
|
masterDetailExcelConfig = {
|
||||||
|
masterTable: relationResponse.data.masterTable,
|
||||||
|
detailTable: relationResponse.data.detailTable,
|
||||||
|
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||||
|
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||||
|
simpleMode: true, // 기본값으로 간단 모드 사용
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📊 마스터-디테일 구조 자동 감지:", {
|
||||||
|
masterTable: relationResponse.data.masterTable,
|
||||||
|
detailTable: relationResponse.data.detailTable,
|
||||||
|
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||||
|
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 동적 import로 모달 컴포넌트 로드
|
// 동적 import로 모달 컴포넌트 로드
|
||||||
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
||||||
const { createRoot } = await import("react-dom/client");
|
const { createRoot } = await import("react-dom/client");
|
||||||
|
|
@ -4820,6 +4926,11 @@ export class ButtonActionExecutor {
|
||||||
uploadMode: config.excelUploadMode || "insert",
|
uploadMode: config.excelUploadMode || "insert",
|
||||||
keyColumn: config.excelKeyColumn,
|
keyColumn: config.excelKeyColumn,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
|
// 🆕 마스터-디테일 관련 props
|
||||||
|
screenId: context.screenId,
|
||||||
|
isMasterDetail,
|
||||||
|
masterDetailRelation,
|
||||||
|
masterDetailExcelConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
||||||
context.onRefresh?.();
|
context.onRefresh?.();
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"docx-preview": "^0.3.6",
|
"docx-preview": "^0.3.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"isomorphic-dompurify": "^2.28.0",
|
"isomorphic-dompurify": "^2.28.0",
|
||||||
|
|
@ -542,6 +543,47 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fast-csv/format": {
|
||||||
|
"version": "4.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz",
|
||||||
|
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^14.0.1",
|
||||||
|
"lodash.escaperegexp": "^4.1.2",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.isfunction": "^3.0.9",
|
||||||
|
"lodash.isnil": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fast-csv/format/node_modules/@types/node": {
|
||||||
|
"version": "14.18.63",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
|
||||||
|
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@fast-csv/parse": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^14.0.1",
|
||||||
|
"lodash.escaperegexp": "^4.1.2",
|
||||||
|
"lodash.groupby": "^4.6.0",
|
||||||
|
"lodash.isfunction": "^3.0.9",
|
||||||
|
"lodash.isnil": "^4.0.0",
|
||||||
|
"lodash.isundefined": "^3.0.1",
|
||||||
|
"lodash.uniq": "^4.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fast-csv/parse/node_modules/@types/node": {
|
||||||
|
"version": "14.18.63",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
|
||||||
|
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||||
|
|
@ -6963,6 +7005,59 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/archiver": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"archiver-utils": "^2.1.0",
|
||||||
|
"async": "^3.2.4",
|
||||||
|
"buffer-crc32": "^0.2.1",
|
||||||
|
"readable-stream": "^3.6.0",
|
||||||
|
"readdir-glob": "^1.1.2",
|
||||||
|
"tar-stream": "^2.2.0",
|
||||||
|
"zip-stream": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/archiver-utils": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^7.1.4",
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"lazystream": "^1.0.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.difference": "^4.5.0",
|
||||||
|
"lodash.flatten": "^4.4.0",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.union": "^4.6.0",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/archiver/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
|
@ -7158,6 +7253,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/async-function": {
|
"node_modules/async-function": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
||||||
|
|
@ -7225,7 +7326,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-arraybuffer": {
|
"node_modules/base64-arraybuffer": {
|
||||||
|
|
@ -7266,6 +7366,15 @@
|
||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/big-integer": {
|
||||||
|
"version": "1.6.52",
|
||||||
|
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
|
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bignumber.js": {
|
"node_modules/bignumber.js": {
|
||||||
"version": "9.3.1",
|
"version": "9.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
|
@ -7275,6 +7384,68 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/binary": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffers": "~0.1.1",
|
||||||
|
"chainsaw": "~0.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "^5.5.0",
|
||||||
|
"inherits": "^2.0.4",
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl/node_modules/buffer": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.1.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bluebird": {
|
"node_modules/bluebird": {
|
||||||
"version": "3.4.7",
|
"version": "3.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
|
@ -7285,7 +7456,6 @@
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
|
|
@ -7329,6 +7499,32 @@
|
||||||
"ieee754": "^1.2.1"
|
"ieee754": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-crc32": {
|
||||||
|
"version": "0.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
|
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-indexof-polyfill": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffers": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/c12": {
|
"node_modules/c12": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
|
|
@ -7501,6 +7697,18 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chainsaw": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
|
||||||
|
"license": "MIT/X11",
|
||||||
|
"dependencies": {
|
||||||
|
"traverse": ">=0.3.0 <0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -7665,11 +7873,39 @@
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compress-commons": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-crc32": "^0.2.13",
|
||||||
|
"crc32-stream": "^4.0.2",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compress-commons/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concaveman": {
|
"node_modules/concaveman": {
|
||||||
|
|
@ -7731,6 +7967,33 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc32-stream": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crc-32": "^1.2.0",
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc32-stream/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crelt": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
|
@ -8323,6 +8586,12 @@
|
||||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -8605,6 +8874,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexer2": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/earcut": {
|
"node_modules/earcut": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||||
|
|
@ -8639,6 +8917,15 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.3",
|
"version": "5.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||||
|
|
@ -9338,6 +9625,61 @@
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/exceljs": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"archiver": "^5.0.0",
|
||||||
|
"dayjs": "^1.8.34",
|
||||||
|
"fast-csv": "^4.3.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"readable-stream": "^3.6.0",
|
||||||
|
"saxes": "^5.0.1",
|
||||||
|
"tmp": "^0.2.0",
|
||||||
|
"unzipper": "^0.10.11",
|
||||||
|
"uuid": "^8.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/exceljs/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/exceljs/node_modules/saxes": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/exceljs/node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exit-on-epipe": {
|
"node_modules/exit-on-epipe": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||||
|
|
@ -9377,6 +9719,19 @@
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-csv": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fast-csv/format": "4.3.5",
|
||||||
|
"@fast-csv/parse": "4.3.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -9586,6 +9941,34 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-constants": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fs.realpath": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/fstream": {
|
||||||
|
"version": "1.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
|
||||||
|
"integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.1.2",
|
||||||
|
"inherits": "~2.0.0",
|
||||||
|
"mkdirp": ">=0.5 0",
|
||||||
|
"rimraf": "2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|
@ -9773,6 +10156,27 @@
|
||||||
"giget": "dist/cli.mjs"
|
"giget": "dist/cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "7.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||||
|
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||||
|
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"fs.realpath": "^1.0.0",
|
||||||
|
"inflight": "^1.0.4",
|
||||||
|
"inherits": "2",
|
||||||
|
"minimatch": "^3.1.1",
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"path-is-absolute": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
|
|
@ -9847,7 +10251,6 @@
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/graphemer": {
|
"node_modules/graphemer": {
|
||||||
|
|
@ -10121,6 +10524,17 @@
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inflight": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||||
|
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
|
@ -10843,6 +11257,18 @@
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lazystream": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/leaflet": {
|
"node_modules/leaflet": {
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
|
@ -11142,6 +11568,12 @@
|
||||||
"uc.micro": "^2.0.0"
|
"uc.micro": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/listenercount": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -11158,6 +11590,73 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.difference": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.escaperegexp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.flatten": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.groupby": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isfunction": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnil": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isundefined": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|
@ -11165,6 +11664,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.union": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.uniq": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
|
@ -11386,7 +11897,6 @@
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
|
|
@ -11399,12 +11909,23 @@
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "0.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -11557,6 +12078,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-path": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||||
|
|
@ -11707,6 +12237,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/option": {
|
"node_modules/option": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||||
|
|
@ -12829,6 +13368,36 @@
|
||||||
"util-deprecate": "~1.0.1"
|
"util-deprecate": "~1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdir-glob": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
|
"version": "5.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
|
@ -13086,6 +13655,19 @@
|
||||||
"node": ">= 0.8.15"
|
"node": ">= 0.8.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rimraf": {
|
||||||
|
"version": "2.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||||
|
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||||
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^7.1.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rimraf": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/robust-predicates": {
|
"node_modules/robust-predicates": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
||||||
|
|
@ -13891,6 +14473,36 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-stream": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bl": "^4.0.3",
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"fs-constants": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar-stream/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/text-segmentation": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
|
@ -14032,6 +14644,15 @@
|
||||||
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
|
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tmp": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|
@ -14107,6 +14728,15 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/traverse": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
|
||||||
|
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
|
||||||
|
"license": "MIT/X11",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/troika-three-text": {
|
"node_modules/troika-three-text": {
|
||||||
"version": "0.52.4",
|
"version": "0.52.4",
|
||||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
||||||
|
|
@ -14402,6 +15032,24 @@
|
||||||
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
|
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unzipper": {
|
||||||
|
"version": "0.10.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz",
|
||||||
|
"integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.17",
|
||||||
|
"binary": "~0.3.0",
|
||||||
|
"bluebird": "~3.4.1",
|
||||||
|
"buffer-indexof-polyfill": "~1.0.0",
|
||||||
|
"duplexer2": "~0.1.4",
|
||||||
|
"fstream": "^1.0.12",
|
||||||
|
"graceful-fs": "^4.2.2",
|
||||||
|
"listenercount": "~1.0.1",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "~1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|
@ -14754,6 +15402,12 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
|
@ -14974,6 +15628,55 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zip-stream": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"archiver-utils": "^3.0.4",
|
||||||
|
"compress-commons": "^4.1.2",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zip-stream/node_modules/archiver-utils": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^7.2.3",
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"lazystream": "^1.0.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.difference": "^4.5.0",
|
||||||
|
"lodash.flatten": "^4.4.0",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.union": "^4.6.0",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zip-stream/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"docx-preview": "^0.3.6",
|
"docx-preview": "^0.3.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"isomorphic-dompurify": "^2.28.0",
|
"isomorphic-dompurify": "^2.28.0",
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,11 @@ export interface InsertActionNodeData {
|
||||||
targetField: string;
|
targetField: string;
|
||||||
targetFieldLabel?: string;
|
targetFieldLabel?: string;
|
||||||
staticValue?: any;
|
staticValue?: any;
|
||||||
|
// 🔥 값 생성 유형 추가
|
||||||
|
valueType?: "source" | "static" | "autoGenerate"; // 소스 필드 / 고정값 / 자동 생성
|
||||||
|
// 자동 생성 옵션 (valueType === "autoGenerate" 일 때)
|
||||||
|
numberingRuleId?: string; // 채번 규칙 ID
|
||||||
|
numberingRuleName?: string; // 채번 규칙명 (표시용)
|
||||||
}>;
|
}>;
|
||||||
options: {
|
options: {
|
||||||
batchSize?: number;
|
batchSize?: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue