Compare commits
No commits in common. "ee3a648917ca06420d4d2692d7ca26dec764275b" and "150a40e2a851de40c59bcf73a1e581633e46774a" have entirely different histories.
ee3a648917
...
150a40e2a8
|
|
@ -42,7 +42,6 @@
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|
@ -3215,16 +3214,6 @@
|
||||||
"@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,7 +56,6 @@
|
||||||
},
|
},
|
||||||
"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, screenId } = req.body;
|
const { tableName } = req.body;
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -240,16 +240,7 @@ export const deleteFormData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
||||||
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
|
||||||
|
|
||||||
await dynamicFormService.deleteFormData(
|
|
||||||
id,
|
|
||||||
tableName,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
parsedScreenId // screenId 추가 (제어관리 실행용)
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -1192,18 +1192,12 @@ 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("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||||
|
|
@ -1316,19 +1310,14 @@ export class DynamicFormService {
|
||||||
const recordCompanyCode =
|
const recordCompanyCode =
|
||||||
deletedRecord?.company_code || companyCode || "*";
|
deletedRecord?.company_code || companyCode || "*";
|
||||||
|
|
||||||
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
await this.executeDataflowControlIfConfigured(
|
||||||
if (screenId && screenId > 0) {
|
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
await this.executeDataflowControlIfConfigured(
|
tableName,
|
||||||
screenId,
|
deletedRecord,
|
||||||
tableName,
|
"delete",
|
||||||
deletedRecord,
|
userId || "system",
|
||||||
"delete",
|
recordCompanyCode
|
||||||
userId || "system",
|
);
|
||||||
recordCompanyCode
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1673,16 +1662,10 @@ 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" &&
|
||||||
isMatchingAction &&
|
properties?.componentConfig?.action?.type === "save" &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||||
|
|
|
||||||
|
|
@ -969,56 +969,21 @@ 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);
|
||||||
let value: any;
|
const value =
|
||||||
|
mapping.staticValue !== undefined
|
||||||
// 🔥 값 생성 유형에 따른 처리
|
? mapping.staticValue
|
||||||
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
: data[mapping.sourceField];
|
||||||
|
|
||||||
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
|
||||||
// 자동 생성 (채번 규칙)
|
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
|
@ -1563,24 +1528,16 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 여부 결정:
|
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
let finalWhereConditions: any[];
|
whereConditions,
|
||||||
if (whereConditions && whereConditions.length > 0) {
|
data,
|
||||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
targetTable
|
||||||
finalWhereConditions = whereConditions;
|
);
|
||||||
} else {
|
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
|
||||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
|
||||||
whereConditions,
|
|
||||||
data,
|
|
||||||
targetTable
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
finalWhereConditions,
|
enhancedWhereConditions,
|
||||||
data,
|
data,
|
||||||
paramIndex
|
paramIndex
|
||||||
);
|
);
|
||||||
|
|
@ -1950,30 +1907,22 @@ export class NodeFlowExecutionService {
|
||||||
return deletedDataArray;
|
return deletedDataArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 context-data 모드: 개별 삭제
|
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
||||||
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 자동 추가 여부 결정:
|
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
let finalWhereConditions: any[];
|
whereConditions,
|
||||||
if (whereConditions && whereConditions.length > 0) {
|
data,
|
||||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
targetTable
|
||||||
finalWhereConditions = whereConditions;
|
);
|
||||||
} else {
|
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
|
||||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
|
||||||
whereConditions,
|
|
||||||
data,
|
|
||||||
targetTable
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
finalWhereConditions,
|
enhancedWhereConditions,
|
||||||
data,
|
data,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -2916,11 +2865,10 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
|
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
|
||||||
);
|
);
|
||||||
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
|
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
|
||||||
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
|
return operator === "NOT_EXISTS_IN";
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* DELETE 액션 노드 속성 편집
|
* DELETE 액션 노드 속성 편집
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState } 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,12 +24,6 @@ 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: "≠" },
|
||||||
|
|
@ -40,7 +34,7 @@ const OPERATORS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||||||
const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore();
|
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
|
||||||
|
|
||||||
// 🔥 타겟 타입 상태
|
// 🔥 타겟 타입 상태
|
||||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||||
|
|
@ -49,10 +43,6 @@ 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);
|
||||||
|
|
@ -134,106 +124,8 @@ 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);
|
||||||
|
|
@ -347,41 +239,22 @@ 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 = () => {
|
||||||
|
|
@ -967,125 +840,14 @@ 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.staticValue || condition.value || ""}
|
value={condition.value as string}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||||
handleConditionChange(index, "staticValue", e.target.value || undefined);
|
placeholder="비교 값"
|
||||||
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, Sparkles } from "lucide-react";
|
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } 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,8 +18,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -91,11 +89,6 @@ 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);
|
||||||
|
|
@ -135,33 +128,8 @@ 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) {
|
||||||
|
|
@ -572,7 +540,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
sourceField: null,
|
sourceField: null,
|
||||||
targetField: "",
|
targetField: "",
|
||||||
staticValue: undefined,
|
staticValue: undefined,
|
||||||
valueType: "source" as const, // 🔥 기본값: 소스 필드
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
|
|
@ -581,7 +548,6 @@ 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) => {
|
||||||
|
|
@ -592,7 +558,6 @@ 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) => {
|
||||||
|
|
@ -621,24 +586,6 @@ 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],
|
||||||
|
|
@ -1218,203 +1165,54 @@ 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>
|
<Label className="text-xs text-gray-600">
|
||||||
<div className="mt-1 grid grid-cols-3 gap-1">
|
소스 필드
|
||||||
<button
|
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||||||
type="button"
|
</Label>
|
||||||
onClick={() => handleMappingChange(index, "valueType", "source")}
|
{hasRestAPISource ? (
|
||||||
className={cn(
|
// REST API 소스인 경우: 직접 입력
|
||||||
"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.staticValue || ""}
|
value={mapping.sourceField || ""}
|
||||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||||
placeholder="고정값 입력"
|
placeholder="필드명 입력 (예: userId, userName)"
|
||||||
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={mappingNumberingRulesOpenState[index]}
|
open={mappingSourceFieldsOpenState[index]}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
const newState = [...mappingNumberingRulesOpenState];
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
newState[index] = open;
|
newState[index] = open;
|
||||||
setMappingNumberingRulesOpenState(newState);
|
setMappingSourceFieldsOpenState(newState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={mappingNumberingRulesOpenState[index]}
|
aria-expanded={mappingSourceFieldsOpenState[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.numberingRuleId
|
{mapping.sourceField
|
||||||
? (() => {
|
? (() => {
|
||||||
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId);
|
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 overflow-hidden">
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
<Sparkles className="h-3 w-3 text-purple-500" />
|
|
||||||
<span className="truncate font-medium">
|
<span className="truncate font-medium">
|
||||||
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId}
|
{field?.label || mapping.sourceField}
|
||||||
</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>
|
||||||
|
|
@ -1424,36 +1222,37 @@ 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>
|
||||||
{numberingRules.map((rule) => (
|
{sourceFields.map((field) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={rule.ruleId}
|
key={field.name}
|
||||||
value={rule.ruleId}
|
value={field.name}
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
handleMappingChange(index, "numberingRuleId", currentValue);
|
handleMappingChange(index, "sourceField", currentValue || null);
|
||||||
const newState = [...mappingNumberingRulesOpenState];
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
newState[index] = false;
|
newState[index] = false;
|
||||||
setMappingNumberingRulesOpenState(newState);
|
setMappingSourceFieldsOpenState(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.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0",
|
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{rule.ruleName}</span>
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
<span className="text-muted-foreground font-mono text-[10px]">
|
{field.label && field.label !== field.name && (
|
||||||
{rule.ruleId}
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
{rule.tableName && ` - ${rule.tableName}`}
|
{field.name}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1462,13 +1261,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{numberingRules.length === 0 && !numberingRulesLoading && (
|
)}
|
||||||
<p className="mt-1 text-xs text-orange-600">
|
{hasRestAPISource && (
|
||||||
등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요.
|
<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" />
|
||||||
|
|
@ -1603,6 +1400,18 @@ 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>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1619,8 +1428,9 @@ 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>
|
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
||||||
<p className="mt-1">값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)</p>
|
<br />
|
||||||
|
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -671,11 +671,9 @@ 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,8 +1676,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
try {
|
try {
|
||||||
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
||||||
|
|
||||||
// screenId 전달하여 제어관리 실행 가능하도록 함
|
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
||||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert("삭제되었습니다.");
|
alert("삭제되었습니다.");
|
||||||
|
|
|
||||||
|
|
@ -202,19 +202,14 @@ export class DynamicFormApi {
|
||||||
* 실제 테이블에서 폼 데이터 삭제
|
* 실제 테이블에서 폼 데이터 삭제
|
||||||
* @param id 레코드 ID
|
* @param id 레코드 ID
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
|
||||||
* @returns 삭제 결과
|
* @returns 삭제 결과
|
||||||
*/
|
*/
|
||||||
static async deleteFormDataFromTable(
|
static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
|
||||||
id: string | number,
|
|
||||||
tableName: string,
|
|
||||||
screenId?: number
|
|
||||||
): Promise<ApiResponse<void>> {
|
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId });
|
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
|
||||||
|
|
||||||
await apiClient.delete(`/dynamic-form/${id}`, {
|
await apiClient.delete(`/dynamic-form/${id}`, {
|
||||||
data: { tableName, screenId },
|
data: { tableName },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
||||||
// 🆕 연관 데이터 버튼 컴포넌트
|
// 🆕 연관 데이터 버튼 컴포넌트
|
||||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||||
|
|
||||||
|
// 🆕 피벗 그리드 컴포넌트
|
||||||
|
import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
# 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, useEffect } from "react";
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PivotGridProps,
|
PivotGridProps,
|
||||||
|
|
@ -15,15 +15,8 @@ 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,
|
||||||
|
|
@ -32,9 +25,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -89,22 +79,13 @@ 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
|
||||||
|
|
@ -113,9 +94,6 @@ 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>
|
||||||
|
|
@ -127,29 +105,14 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-b border-border relative",
|
"border-r border-b border-border",
|
||||||
"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 || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
onClick && "cursor-pointer hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
style={cellStyle}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
|
||||||
>
|
>
|
||||||
{/* Data Bar */}
|
{values[0].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>}
|
|
||||||
{values[0].formattedValue}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -161,28 +124,14 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
<td
|
<td
|
||||||
key={idx}
|
key={idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-b border-border relative",
|
"border-r border-b border-border",
|
||||||
"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 || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
onClick && "cursor-pointer hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
style={cellStyle}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
|
||||||
>
|
>
|
||||||
{hasDataBar && (
|
{val.formattedValue}
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -193,7 +142,7 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
|
|
||||||
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
title,
|
title,
|
||||||
fields: initialFields = [],
|
fields = [],
|
||||||
totals = {
|
totals = {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
showColumnGrandTotals: true,
|
showColumnGrandTotals: true,
|
||||||
|
|
@ -208,49 +157,24 @@ 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 [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 변경 시 동기화
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
if (initialFields.length > 0) {
|
|
||||||
setFields(initialFields);
|
|
||||||
}
|
|
||||||
}, [initialFields]);
|
|
||||||
|
|
||||||
// 데이터
|
// 데이터
|
||||||
const data = externalData || [];
|
const data = externalData || [];
|
||||||
|
|
@ -281,43 +205,6 @@ 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>(() => {
|
||||||
|
|
@ -325,83 +212,16 @@ 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,
|
||||||
visibleFields,
|
fields,
|
||||||
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[]) => {
|
||||||
|
|
@ -436,6 +256,7 @@ 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);
|
||||||
|
|
@ -475,27 +296,6 @@ 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;
|
||||||
|
|
@ -554,20 +354,6 @@ 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]);
|
|
||||||
|
|
||||||
// ==================== 렌더링 ====================
|
// ==================== 렌더링 ====================
|
||||||
|
|
||||||
// 빈 상태
|
// 빈 상태
|
||||||
|
|
@ -588,51 +374,20 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필드 미설정
|
// 필드 미설정
|
||||||
const hasActiveFields = fields.some(
|
if (fields.length === 0) {
|
||||||
(f) => f.visible !== false && f.area !== "filter"
|
|
||||||
);
|
|
||||||
if (!hasActiveFields) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col",
|
"flex flex-col items-center justify-center",
|
||||||
"border border-border rounded-lg overflow-hidden bg-background"
|
"p-8 text-center text-muted-foreground",
|
||||||
|
"border border-dashed border-border rounded-lg"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 필드 패널 */}
|
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
||||||
<FieldPanel
|
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
||||||
fields={fields}
|
<p className="text-xs mt-1">
|
||||||
onFieldsChange={handleFieldsChange}
|
행, 열, 데이터 영역에 필드를 배치해주세요
|
||||||
collapsed={!showFieldPanel}
|
</p>
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -661,14 +416,6 @@ 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">
|
||||||
|
|
@ -679,30 +426,6 @@ 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
|
||||||
|
|
@ -727,43 +450,18 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 차트 토글 */}
|
{exportConfig?.excel && (
|
||||||
{chartConfig && (
|
|
||||||
<Button
|
<Button
|
||||||
variant={showChart ? "secondary" : "ghost"}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2"
|
className="h-7 px-2"
|
||||||
onClick={() => setShowChart(!showChart)}
|
onClick={handleExportCSV}
|
||||||
title={showChart ? "차트 숨기기" : "차트 보기"}
|
title="CSV 내보내기"
|
||||||
>
|
>
|
||||||
<BarChart3 className="h-4 w-4" />
|
<Download 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"
|
||||||
|
|
@ -886,25 +584,15 @@ 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)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -949,38 +637,6 @@ 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,9 +431,14 @@ 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}>
|
||||||
{col.column_comment
|
<div className="flex items-center gap-2">
|
||||||
? `${col.column_name} (${col.column_comment})`
|
<span>{col.column_name}</span>
|
||||||
: col.column_name}
|
{col.column_comment && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({col.column_comment})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -471,8 +476,7 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
setLoadingTables(true);
|
setLoadingTables(true);
|
||||||
try {
|
try {
|
||||||
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
|
const response = await apiClient.get("/api/table-management/list");
|
||||||
const response = await apiClient.get("/table-management/tables");
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setTables(response.data.data || []);
|
setTables(response.data.data || []);
|
||||||
}
|
}
|
||||||
|
|
@ -495,9 +499,8 @@ 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(
|
||||||
`/table-management/tables/${config.dataSource.tableName}/columns`
|
`/api/table-management/columns/${config.dataSource.tableName}`
|
||||||
);
|
);
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setColumns(response.data.data || []);
|
setColumns(response.data.data || []);
|
||||||
|
|
@ -547,9 +550,14 @@ 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}>
|
||||||
{table.table_comment
|
<div className="flex items-center gap-2">
|
||||||
? `${table.table_name} (${table.table_comment})`
|
<span>{table.table_name}</span>
|
||||||
: table.table_name}
|
{table.table_comment && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({table.table_comment})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -709,270 +717,6 @@ 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,160 +6,6 @@ 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 컴포넌트 정의
|
||||||
|
|
@ -171,15 +17,13 @@ const PivotGridDefinition = createComponentDefinition({
|
||||||
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
||||||
category: ComponentCategory.DISPLAY,
|
category: ComponentCategory.DISPLAY,
|
||||||
webType: "text",
|
webType: "text",
|
||||||
component: PivotGridWrapper, // 래퍼 컴포넌트 사용
|
component: PivotGridComponent,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
dataSource: {
|
dataSource: {
|
||||||
type: "table",
|
type: "table",
|
||||||
tableName: "",
|
tableName: "",
|
||||||
},
|
},
|
||||||
fields: SAMPLE_FIELDS,
|
fields: [],
|
||||||
// 미리보기용 샘플 데이터
|
|
||||||
sampleData: SAMPLE_DATA,
|
|
||||||
totals: {
|
totals: {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
showColumnGrandTotals: true,
|
showColumnGrandTotals: true,
|
||||||
|
|
@ -217,75 +61,9 @@ 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
|
||||||
title={finalTitle}
|
{...this.props}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,429 +0,0 @@
|
||||||
"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;
|
|
||||||
|
|
||||||
|
|
@ -1,441 +0,0 @@
|
||||||
"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;
|
|
||||||
|
|
||||||
|
|
@ -1,551 +0,0 @@
|
||||||
"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;
|
|
||||||
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
"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;
|
|
||||||
|
|
||||||
|
|
@ -1,386 +0,0 @@
|
||||||
"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;
|
|
||||||
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
/**
|
|
||||||
* PivotGrid 서브 컴포넌트 내보내기
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { FieldPanel } from "./FieldPanel";
|
|
||||||
export { FieldChooser } from "./FieldChooser";
|
|
||||||
export { DrillDownModal } from "./DrillDownModal";
|
|
||||||
export { FilterPopup } from "./FilterPopup";
|
|
||||||
export { PivotChart } from "./PivotChart";
|
|
||||||
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
"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;
|
|
||||||
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
"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,7 +8,6 @@ export type {
|
||||||
// 기본 타입
|
// 기본 타입
|
||||||
PivotAreaType,
|
PivotAreaType,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
SummaryDisplayMode,
|
|
||||||
SortDirection,
|
SortDirection,
|
||||||
DateGroupInterval,
|
DateGroupInterval,
|
||||||
FieldDataType,
|
FieldDataType,
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -61,8 +48,6 @@ export interface PivotFieldConfig {
|
||||||
|
|
||||||
// 집계 설정 (data 영역용)
|
// 집계 설정 (data 영역용)
|
||||||
summaryType?: AggregationType; // 집계 함수
|
summaryType?: AggregationType; // 집계 함수
|
||||||
summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드
|
|
||||||
showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭)
|
|
||||||
|
|
||||||
// 정렬 설정
|
// 정렬 설정
|
||||||
sortBy?: "value" | "caption"; // 정렬 기준
|
sortBy?: "value" | "caption"; // 정렬 기준
|
||||||
|
|
@ -166,45 +151,6 @@ 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";
|
||||||
|
|
@ -213,7 +159,6 @@ export interface PivotStyleConfig {
|
||||||
borderStyle: "none" | "light" | "heavy";
|
borderStyle: "none" | "light" | "heavy";
|
||||||
alternateRowColors?: boolean;
|
alternateRowColors?: boolean;
|
||||||
highlightTotals?: boolean; // 총합계 강조
|
highlightTotals?: boolean; // 총합계 강조
|
||||||
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 내보내기 설정 ====================
|
// ==================== 내보내기 설정 ====================
|
||||||
|
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
/**
|
|
||||||
* 조건부 서식 유틸리티
|
|
||||||
* 셀 값에 따른 스타일 계산
|
|
||||||
*/
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,6 +1,4 @@
|
||||||
export * from "./aggregation";
|
export * from "./aggregation";
|
||||||
export * from "./pivotEngine";
|
export * from "./pivotEngine";
|
||||||
export * from "./exportExcel";
|
|
||||||
export * from "./conditionalFormat";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
PivotCellValue,
|
PivotCellValue,
|
||||||
DateGroupInterval,
|
DateGroupInterval,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
SummaryDisplayMode,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
||||||
|
|
||||||
|
|
@ -419,185 +418,6 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 총합계 계산 ====================
|
// ==================== 총합계 계산 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -764,7 +584,7 @@ export function processPivotData(
|
||||||
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
||||||
|
|
||||||
// 데이터 매트릭스 생성
|
// 데이터 매트릭스 생성
|
||||||
let dataMatrix = buildDataMatrix(
|
const dataMatrix = buildDataMatrix(
|
||||||
filteredData,
|
filteredData,
|
||||||
rowFields,
|
rowFields,
|
||||||
columnFields,
|
columnFields,
|
||||||
|
|
@ -783,17 +603,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -967,11 +967,11 @@ export class ButtonActionExecutor {
|
||||||
deletedItemIds,
|
deletedItemIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함
|
// 삭제 API 호출
|
||||||
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, context.screenId);
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable);
|
||||||
if (deleteResult.success) {
|
if (deleteResult.success) {
|
||||||
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
|
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1967,8 +1967,7 @@ 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}`);
|
||||||
|
|
||||||
// screenId 전달하여 제어관리 실행 가능하도록 함
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName);
|
||||||
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 || "품목 삭제 실패");
|
||||||
|
|
@ -2435,8 +2434,7 @@ export class ButtonActionExecutor {
|
||||||
if (deleteId) {
|
if (deleteId) {
|
||||||
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
||||||
|
|
||||||
// screenId 전달하여 제어관리 실행 가능하도록 함
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName);
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|
@ -2471,8 +2469,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 호출 - screenId 전달하여 제어관리 실행 가능하도록 함
|
// 실제 삭제 API 호출
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName, screenId);
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName);
|
||||||
|
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
|
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
|
||||||
|
|
@ -4253,8 +4251,7 @@ export class ButtonActionExecutor {
|
||||||
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
|
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// screenId 전달하여 제어관리 실행 가능하도록 함
|
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName);
|
||||||
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName, context.screenId);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log("✅ 삭제 성공:", result);
|
console.log("✅ 삭제 성공:", result);
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@
|
||||||
"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",
|
||||||
|
|
@ -543,47 +542,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7005,59 +6963,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7253,12 +7158,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7326,6 +7225,7 @@
|
||||||
"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": {
|
||||||
|
|
@ -7366,15 +7266,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7384,68 +7275,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7456,6 +7285,7 @@
|
||||||
"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",
|
||||||
|
|
@ -7499,32 +7329,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7697,18 +7501,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7873,39 +7665,11 @@
|
||||||
"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": {
|
||||||
|
|
@ -7967,33 +7731,6 @@
|
||||||
"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",
|
||||||
|
|
@ -8586,12 +8323,6 @@
|
||||||
"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",
|
||||||
|
|
@ -8874,15 +8605,6 @@
|
||||||
"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",
|
||||||
|
|
@ -8917,15 +8639,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9625,61 +9338,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9719,19 +9377,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9941,34 +9586,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10156,27 +9773,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10251,6 +9847,7 @@
|
||||||
"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": {
|
||||||
|
|
@ -10524,17 +10121,6 @@
|
||||||
"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",
|
||||||
|
|
@ -11257,18 +10843,6 @@
|
||||||
"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",
|
||||||
|
|
@ -11568,12 +11142,6 @@
|
||||||
"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",
|
||||||
|
|
@ -11590,73 +11158,6 @@
|
||||||
"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",
|
||||||
|
|
@ -11664,18 +11165,6 @@
|
||||||
"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",
|
||||||
|
|
@ -11897,6 +11386,7 @@
|
||||||
"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"
|
||||||
|
|
@ -11909,23 +11399,12 @@
|
||||||
"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",
|
||||||
|
|
@ -12078,15 +11557,6 @@
|
||||||
"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",
|
||||||
|
|
@ -12237,15 +11707,6 @@
|
||||||
"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",
|
||||||
|
|
@ -13368,36 +12829,6 @@
|
||||||
"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",
|
||||||
|
|
@ -13655,19 +13086,6 @@
|
||||||
"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",
|
||||||
|
|
@ -14473,36 +13891,6 @@
|
||||||
"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",
|
||||||
|
|
@ -14644,15 +14032,6 @@
|
||||||
"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",
|
||||||
|
|
@ -14728,15 +14107,6 @@
|
||||||
"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",
|
||||||
|
|
@ -15032,24 +14402,6 @@
|
||||||
"@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",
|
||||||
|
|
@ -15402,12 +14754,6 @@
|
||||||
"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",
|
||||||
|
|
@ -15628,55 +14974,6 @@
|
||||||
"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,7 +67,6 @@
|
||||||
"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,11 +344,6 @@ 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