diff --git a/.playwright-mcp/pivotgrid-demo.png b/.playwright-mcp/pivotgrid-demo.png new file mode 100644 index 00000000..0fad6fa6 Binary files /dev/null and b/.playwright-mcp/pivotgrid-demo.png differ diff --git a/.playwright-mcp/pivotgrid-table.png b/.playwright-mcp/pivotgrid-table.png new file mode 100644 index 00000000..79041f47 Binary files /dev/null and b/.playwright-mcp/pivotgrid-table.png differ diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 031a1506..ab7114a5 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq const companyCode = req.user!.companyCode; const { ruleId } = req.params; + logger.info("코드 할당 요청", { ruleId, companyCode }); + try { const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + logger.info("코드 할당 성공", { ruleId, allocatedCode }); return res.json({ success: true, data: { generatedCode: allocatedCode } }); } catch (error: any) { - logger.error("코드 할당 실패", { error: error.message }); + logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); diff --git a/frontend/components/pop/PopProductionPanel.tsx b/frontend/components/pop/PopProductionPanel.tsx index 6d61bd9b..ceb1d902 100644 --- a/frontend/components/pop/PopProductionPanel.tsx +++ b/frontend/components/pop/PopProductionPanel.tsx @@ -124,17 +124,17 @@ export function PopProductionPanel({ return (

점검 항목

-
-
-
- {formatDate(currentDateTime)} - - {formatTime(currentDateTime)} - +
+ {formatDate(currentDateTime)} + {formatTime(currentDateTime)}
diff --git a/frontend/components/pop/styles.css b/frontend/components/pop/styles.css index a12b80fa..c2913c8e 100644 --- a/frontend/components/pop/styles.css +++ b/frontend/components/pop/styles.css @@ -142,9 +142,10 @@ line-height: 1.5; color: rgb(var(--text-primary)); background: rgb(var(--bg-deepest)); - min-height: 100vh; - min-height: 100dvh; + height: 100vh; + height: 100dvh; overflow-x: hidden; + overflow-y: auto; -webkit-font-smoothing: antialiased; position: relative; } @@ -197,8 +198,7 @@ .pop-app { display: flex; flex-direction: column; - min-height: 100vh; - min-height: 100dvh; + min-height: 100%; padding: var(--spacing-sm); padding-bottom: calc(60px + var(--spacing-sm) + env(safe-area-inset-bottom, 0px)); } @@ -209,7 +209,9 @@ border: 1px solid rgb(var(--border)); border-radius: var(--radius-lg); margin-bottom: var(--spacing-sm); - position: relative; + position: sticky; + top: 0; + z-index: 100; overflow: hidden; } @@ -227,8 +229,8 @@ .pop-top-bar { display: flex; align-items: center; - padding: var(--spacing-sm) var(--spacing-md); - gap: var(--spacing-md); + padding: var(--spacing-xs) var(--spacing-sm); + gap: var(--spacing-sm); } .pop-top-bar.row-1 { @@ -243,8 +245,8 @@ .pop-datetime { display: flex; align-items: center; - gap: var(--spacing-sm); - font-size: var(--text-xs); + gap: var(--spacing-xs); + font-size: var(--text-2xs); } .pop-date { @@ -254,7 +256,7 @@ .pop-time { color: rgb(var(--neon-cyan)); font-weight: 700; - font-size: var(--text-sm); + font-size: var(--text-xs); } /* 생산유형 버튼 */ @@ -264,12 +266,12 @@ } .pop-type-btn { - padding: var(--spacing-xs) var(--spacing-md); + padding: 4px var(--spacing-sm); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-muted)); - font-size: var(--text-xs); + font-size: var(--text-2xs); font-weight: 500; cursor: pointer; transition: all var(--transition-fast); @@ -291,12 +293,13 @@ .pop-filter-btn { display: flex; align-items: center; - gap: var(--spacing-xs); - padding: var(--spacing-sm) var(--spacing-md); + gap: 2px; + padding: 4px var(--spacing-sm); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-secondary)); + font-size: var(--text-2xs); font-size: var(--text-xs); font-weight: 500; cursor: pointer; @@ -343,6 +346,9 @@ border-radius: var(--radius-lg); overflow: hidden; margin-bottom: var(--spacing-sm); + position: sticky; + top: 90px; + z-index: 99; } .pop-status-tab { @@ -395,7 +401,7 @@ /* ==================== 메인 콘텐츠 ==================== */ .pop-main-content { flex: 1; - overflow-y: auto; + min-height: 0; } .pop-work-list { @@ -675,6 +681,7 @@ align-items: center; gap: 4px; padding: 4px 8px; + font-size: var(--text-2xs); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-sm); @@ -851,10 +858,10 @@ align-items: center; justify-content: center; gap: var(--spacing-sm); - padding: var(--spacing-sm) var(--spacing-lg); + padding: var(--spacing-md) var(--spacing-lg); border: 1px solid transparent; border-radius: var(--radius-md); - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: all var(--transition-fast); @@ -1105,8 +1112,9 @@ top: 0; right: 0; bottom: 0; + left: 0; width: 100%; - max-width: 600px; + max-width: none; background: rgb(var(--bg-primary)); display: flex; flex-direction: column; @@ -1133,11 +1141,29 @@ } .pop-slide-panel-title { - font-size: var(--text-lg); + font-size: var(--text-xl); font-weight: 700; color: rgb(var(--text-primary)); } +/* 슬라이드 패널 날짜/시간 */ +.pop-panel-datetime { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.pop-panel-date { + font-size: var(--text-sm); + color: rgb(var(--text-muted)); +} + +.pop-panel-time { + font-size: var(--text-base); + font-weight: 700; + color: rgb(var(--neon-cyan)); +} + .pop-badge { padding: 2px 8px; border-radius: var(--radius-full); @@ -1174,7 +1200,7 @@ .pop-work-steps-header { padding: var(--spacing-md); - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 600; color: rgb(var(--text-muted)); border-bottom: 1px solid rgb(var(--border)); @@ -1239,7 +1265,7 @@ } .pop-work-step-name { - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 500; color: rgb(var(--text-primary)); white-space: nowrap; @@ -1248,13 +1274,13 @@ } .pop-work-step-time { - font-size: var(--text-2xs); + font-size: var(--text-xs); color: rgb(var(--text-muted)); } .pop-work-step-status { - font-size: var(--text-2xs); - padding: 2px 6px; + font-size: var(--text-xs); + padding: 3px 8px; border-radius: var(--radius-sm); } @@ -1288,14 +1314,14 @@ } .pop-step-title { - font-size: var(--text-lg); + font-size: var(--text-xl); font-weight: 700; color: rgb(var(--text-primary)); margin-bottom: var(--spacing-xs); } .pop-step-description { - font-size: var(--text-sm); + font-size: var(--text-base); color: rgb(var(--text-muted)); } @@ -1311,20 +1337,20 @@ align-items: center; justify-content: center; gap: var(--spacing-sm); - padding: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-lg); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-secondary)); - font-size: var(--text-sm); + font-size: var(--text-base); font-weight: 500; cursor: pointer; transition: all var(--transition-fast); } .pop-time-control-btn svg { - width: 16px; - height: 16px; + width: 20px; + height: 20px; } .pop-time-control-btn:hover:not(:disabled) { @@ -1358,7 +1384,7 @@ } .pop-step-form-title { - font-size: var(--text-sm); + font-size: var(--text-base); font-weight: 600; color: rgb(var(--text-primary)); margin-bottom: var(--spacing-md); @@ -1374,20 +1400,53 @@ .pop-form-label { display: block; - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 500; color: rgb(var(--text-secondary)); margin-bottom: var(--spacing-xs); } +/* 체크박스 리스트 */ +.pop-checkbox-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.pop-checkbox-label { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--text-base); + color: rgb(var(--text-primary)); + cursor: pointer; +} + +.pop-checkbox { + width: 20px; + height: 20px; + accent-color: rgb(var(--neon-cyan)); + cursor: pointer; +} + +.pop-checkbox:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pop-checkbox-label:has(.pop-checkbox:disabled) { + opacity: 0.6; + cursor: not-allowed; +} + .pop-input { width: 100%; - padding: var(--spacing-sm) var(--spacing-md); + padding: var(--spacing-md); background: rgba(var(--bg-tertiary), 0.5); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-primary)); - font-size: var(--text-sm); + font-size: var(--text-base); transition: all var(--transition-fast); } @@ -1441,12 +1500,12 @@ } .pop-work-order-info-item .label { - font-size: var(--text-2xs); + font-size: var(--text-xs); color: rgb(var(--text-muted)); } .pop-work-order-info-item .value { - font-size: var(--text-sm); + font-size: var(--text-base); font-weight: 500; color: rgb(var(--text-primary)); } @@ -1634,9 +1693,9 @@ /* 헤더 인라인 테마 토글 버튼 */ .pop-theme-toggle-inline { - width: 32px; - height: 32px; - margin-left: var(--spacing-sm); + width: 26px; + height: 26px; + margin-left: var(--spacing-xs); border-radius: var(--radius-md); background: rgba(255, 255, 255, 0.05); border: 1px solid rgb(var(--border)); @@ -1656,8 +1715,8 @@ } .pop-theme-toggle-inline svg { - width: 18px; - height: 18px; + width: 14px; + height: 14px; } /* ==================== 아이콘 버튼 ==================== */ @@ -1722,7 +1781,144 @@ } .pop-slide-panel-content { - max-width: 100%; + max-width: none; + left: 0; + } +} + +/* 태블릿 이상 (768px+) - 폰트 크기 증가 */ +@media (min-width: 768px) { + /* 상태 탭 sticky 위치 조정 */ + .pop-status-tabs { + top: 100px; + } + + /* 헤더 */ + .pop-top-bar { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + gap: var(--spacing-md); + } + + .pop-datetime { + font-size: var(--text-xs); + } + + .pop-time { + font-size: var(--text-sm); + } + + .pop-type-btn { + font-size: var(--text-xs); + padding: var(--spacing-xs) var(--spacing-md); + } + + .pop-filter-btn { + font-size: var(--text-xs); + padding: var(--spacing-xs) var(--spacing-md); + } + + .pop-theme-toggle-inline { + width: 32px; + height: 32px; + } + + .pop-theme-toggle-inline svg { + width: 16px; + height: 16px; + } + + .pop-equipment-name, + .pop-process-name { + font-size: var(--text-sm); + } + + /* 상태 탭 */ + .pop-status-tab-label { + font-size: var(--text-base); + } + + .pop-status-tab-count { + font-size: var(--text-xl); + } + + .pop-status-tab-detail { + font-size: var(--text-sm); + } + + /* 작업 카드 */ + .pop-work-card-id { + font-size: var(--text-base); + } + + .pop-work-card-item { + font-size: var(--text-lg); + } + + .pop-work-card-spec { + font-size: var(--text-base); + } + + .pop-work-card-info-item .label { + font-size: var(--text-sm); + } + + .pop-work-card-info-item .value { + font-size: var(--text-base); + } + + /* 작업 카드 내부 - 태블릿 */ + .pop-work-number { + font-size: var(--text-base); + } + + .pop-work-status { + font-size: var(--text-xs); + padding: 3px 10px; + } + + .pop-work-info-label { + font-size: var(--text-xs); + } + + .pop-work-info-value { + font-size: var(--text-sm); + } + + .pop-process-bar-label, + .pop-process-bar-count { + font-size: var(--text-xs); + } + + .pop-process-chip { + font-size: var(--text-xs); + padding: 5px 10px; + } + + .pop-progress-text, + .pop-progress-percent { + font-size: var(--text-sm); + } + + .pop-btn-sm { + font-size: var(--text-xs); + padding: var(--spacing-xs) var(--spacing-md); + } + + /* 공정 타임라인 */ + .pop-process-step-label { + font-size: var(--text-sm); + } + + /* 하단 네비게이션 */ + .pop-nav-btn { + font-size: var(--text-base); + } + + /* 배지 */ + .pop-badge { + font-size: var(--text-sm); + padding: 4px 10px; } } @@ -1734,6 +1930,11 @@ padding-bottom: calc(64px + var(--spacing-lg) + env(safe-area-inset-bottom, 0px)); } + /* 상태 탭 sticky 위치 조정 - 데스크톱 */ + .pop-status-tabs { + top: 110px; + } + .pop-work-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); @@ -1741,11 +1942,217 @@ } .pop-slide-panel-content { - max-width: 700px; + max-width: none; + left: 0; } .pop-work-steps-sidebar { - width: 240px; + width: 280px; + } + + /* 데스크톱 (1024px+) - 폰트 크기 더 증가 */ + + /* 헤더 */ + .pop-top-bar { + font-size: var(--text-base); + padding: var(--spacing-sm) var(--spacing-lg); + } + + .pop-datetime { + font-size: var(--text-sm); + } + + .pop-time { + font-size: var(--text-base); + } + + .pop-type-btn { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + } + + .pop-filter-btn { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + } + + .pop-theme-toggle-inline { + width: 36px; + height: 36px; + } + + .pop-theme-toggle-inline svg { + width: 18px; + height: 18px; + } + + .pop-equipment-name, + .pop-process-name { + font-size: var(--text-base); + } + + /* 상태 탭 */ + .pop-status-tab-label { + font-size: var(--text-lg); + } + + .pop-status-tab-count { + font-size: var(--text-2xl); + } + + .pop-status-tab-detail { + font-size: var(--text-base); + } + + /* 작업 카드 */ + .pop-work-card-id { + font-size: var(--text-lg); + } + + .pop-work-card-item { + font-size: var(--text-xl); + } + + .pop-work-card-spec { + font-size: var(--text-lg); + } + + .pop-work-card-info-item .label { + font-size: var(--text-base); + } + + .pop-work-card-info-item .value { + font-size: var(--text-lg); + } + + /* 작업 카드 내부 - 데스크톱 */ + .pop-work-number { + font-size: var(--text-lg); + } + + .pop-work-status { + font-size: var(--text-sm); + padding: 4px 12px; + } + + .pop-work-info-label { + font-size: var(--text-sm); + } + + .pop-work-info-value { + font-size: var(--text-base); + } + + .pop-process-bar-label, + .pop-process-bar-count { + font-size: var(--text-sm); + } + + .pop-process-chip { + font-size: var(--text-sm); + padding: 6px 12px; + } + + .pop-progress-text, + .pop-progress-percent { + font-size: var(--text-base); + } + + .pop-btn-sm { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + } + + /* 공정 타임라인 */ + .pop-process-step-label { + font-size: var(--text-base); + } + + /* 하단 네비게이션 */ + .pop-nav-btn { + font-size: var(--text-lg); + } + + /* 배지 */ + .pop-badge { + font-size: var(--text-base); + padding: 5px 12px; + } + + /* 슬라이드 패널 - 데스크톱 */ + .pop-slide-panel-title { + font-size: var(--text-2xl); + } + + .pop-panel-date { + font-size: var(--text-base); + } + + .pop-panel-time { + font-size: var(--text-lg); + } + + .pop-work-steps-header { + font-size: var(--text-base); + } + + .pop-work-step-name { + font-size: var(--text-base); + } + + .pop-work-step-time { + font-size: var(--text-sm); + } + + .pop-work-step-status { + font-size: var(--text-sm); + } + + .pop-step-title { + font-size: var(--text-2xl); + } + + .pop-step-description { + font-size: var(--text-lg); + } + + .pop-step-form-title { + font-size: var(--text-lg); + } + + .pop-form-label { + font-size: var(--text-base); + } + + .pop-checkbox-label { + font-size: var(--text-lg); + } + + .pop-checkbox { + width: 24px; + height: 24px; + } + + .pop-input { + font-size: var(--text-lg); + padding: var(--spacing-md) var(--spacing-lg); + } + + .pop-work-order-info-item .label { + font-size: var(--text-sm); + } + + .pop-work-order-info-item .value { + font-size: var(--text-lg); + } + + .pop-btn { + font-size: var(--text-base); + padding: var(--spacing-md) var(--spacing-xl); + } + + .pop-time-control-btn { + font-size: var(--text-lg); } } diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 32451d18..c1b644cc 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -761,10 +761,74 @@ export const EditModal: React.FC = ({ className }) => { // INSERT 모드 console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData); + // 🆕 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) + const dataToSave = { ...formData }; + const fieldsWithNumbering: Record = {}; + + // formData에서 채번 규칙이 설정된 필드 찾기 + for (const [key, value] of Object.entries(formData)) { + if (key.endsWith("_numberingRuleId") && value) { + const fieldName = key.replace("_numberingRuleId", ""); + fieldsWithNumbering[fieldName] = value as string; + console.log(`🎯 [EditModal] 채번 규칙 발견: ${fieldName} → 규칙 ${value}`); + } + } + + // 채번 규칙이 있는 필드에 대해 allocateCode 호출 + if (Object.keys(fieldsWithNumbering).length > 0) { + console.log("🎯 [EditModal] 채번 규칙 할당 시작"); + const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + + let hasAllocationFailure = false; + const failedFields: string[] = []; + + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { + try { + console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); + const allocateResult = await allocateNumberingCode(ruleId); + + if (allocateResult.success && allocateResult.data?.generatedCode) { + const newCode = allocateResult.data.generatedCode; + console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]} → ${newCode}`); + dataToSave[fieldName] = newCode; + } else { + console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error); + if (!dataToSave[fieldName] || dataToSave[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } + } + } catch (allocateError) { + console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError); + if (!dataToSave[fieldName] || dataToSave[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } + } + } + + // 채번 규칙 할당 실패 시 저장 중단 + if (hasAllocationFailure) { + const fieldNames = failedFields.join(", "); + toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`); + console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`); + return; + } + + // _numberingRuleId 필드 제거 (실제 저장하지 않음) + for (const key of Object.keys(dataToSave)) { + if (key.endsWith("_numberingRuleId")) { + delete dataToSave[key]; + } + } + } + + console.log("[EditModal] 최종 저장 데이터:", dataToSave); + const response = await dynamicFormApi.saveFormData({ screenId: modalState.screenId!, tableName: screenData.screenInfo.tableName, - data: formData, + data: dataToSave, }); if (response.success) { diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index e28e1755..9ca202ed 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인 // 🆕 연관 데이터 버튼 컴포넌트 import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 +// 🆕 피벗 그리드 컴포넌트 +import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx new file mode 100644 index 00000000..b81057a3 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -0,0 +1,644 @@ +"use client"; + +/** + * PivotGrid 메인 컴포넌트 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ + +import React, { useState, useMemo, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridProps, + PivotResult, + PivotFieldConfig, + PivotCellData, + PivotFlatRow, + PivotCellValue, + PivotGridState, +} from "./types"; +import { processPivotData, pathToKey } from "./utils/pivotEngine"; +import { + ChevronRight, + ChevronDown, + Download, + Settings, + RefreshCw, + Maximize2, + Minimize2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ==================== 서브 컴포넌트 ==================== + +// 행 헤더 셀 +interface RowHeaderCellProps { + row: PivotFlatRow; + rowFields: PivotFieldConfig[]; + onToggleExpand: (path: string[]) => void; +} + +const RowHeaderCell: React.FC = ({ + row, + rowFields, + onToggleExpand, +}) => { + const indentSize = row.level * 20; + + return ( + +
+ {row.hasChildren && ( + + )} + {!row.hasChildren && } + {row.caption} +
+ + ); +}; + +// 데이터 셀 +interface DataCellProps { + values: PivotCellValue[]; + isTotal?: boolean; + onClick?: () => void; +} + +const DataCell: React.FC = ({ + values, + isTotal = false, + onClick, +}) => { + if (!values || values.length === 0) { + return ( + + - + + ); + } + + // 단일 데이터 필드인 경우 + if (values.length === 1) { + return ( + + {values[0].formattedValue} + + ); + } + + // 다중 데이터 필드인 경우 + return ( + <> + {values.map((val, idx) => ( + + {val.formattedValue} + + ))} + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotGridComponent: React.FC = ({ + title, + fields = [], + totals = { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style = { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + allowExpandAll = true, + height = "auto", + maxHeight, + exportConfig, + data: externalData, + onCellClick, + onExpandChange, +}) => { + // ==================== 상태 ==================== + + const [pivotState, setPivotState] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + + const [isFullscreen, setIsFullscreen] = useState(false); + + // 데이터 + const data = externalData || []; + + // ==================== 필드 분류 ==================== + + const rowFields = useMemo( + () => + fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const columnFields = useMemo( + () => + fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const dataFields = useMemo( + () => + fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + // ==================== 피벗 처리 ==================== + + const pivotResult = useMemo(() => { + if (!data || data.length === 0 || fields.length === 0) { + return null; + } + + return processPivotData( + data, + fields, + pivotState.expandedRowPaths, + pivotState.expandedColumnPaths + ); + }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + + // ==================== 이벤트 핸들러 ==================== + + // 행 확장/축소 + const handleToggleRowExpand = useCallback( + (path: string[]) => { + setPivotState((prev) => { + const pathKey = pathToKey(path); + const existingIndex = prev.expandedRowPaths.findIndex( + (p) => pathToKey(p) === pathKey + ); + + let newPaths: string[][]; + if (existingIndex >= 0) { + newPaths = prev.expandedRowPaths.filter( + (_, i) => i !== existingIndex + ); + } else { + newPaths = [...prev.expandedRowPaths, path]; + } + + onExpandChange?.(newPaths); + + return { + ...prev, + expandedRowPaths: newPaths, + }; + }); + }, + [onExpandChange] + ); + + // 전체 확장 + const handleExpandAll = useCallback(() => { + if (!pivotResult) return; + + const allRowPaths: string[][] = []; + + pivotResult.flatRows.forEach((row) => { + if (row.hasChildren) { + allRowPaths.push(row.path); + } + }); + + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + }, [pivotResult]); + + // 전체 축소 + const handleCollapseAll = useCallback(() => { + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: [], + expandedColumnPaths: [], + })); + }, []); + + // 셀 클릭 + const handleCellClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + if (!onCellClick) return; + + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + onCellClick(cellData); + }, + [onCellClick] + ); + + // CSV 내보내기 + const handleExportCSV = useCallback(() => { + if (!pivotResult) return; + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + let csv = ""; + + // 헤더 행 + const headerRow = [""].concat( + flatColumns.map((col) => col.caption || "총계") + ); + if (totals?.showRowGrandTotals) { + headerRow.push("총계"); + } + csv += headerRow.join(",") + "\n"; + + // 데이터 행 + flatRows.forEach((row) => { + const rowData = [row.caption]; + + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + rowData.push(values?.[0]?.value?.toString() || ""); + }); + + if (totals?.showRowGrandTotals) { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + rowData.push(rowTotal?.[0]?.value?.toString() || ""); + } + + csv += rowData.join(",") + "\n"; + }); + + // 열 총계 행 + if (totals?.showColumnGrandTotals) { + const totalRow = ["총계"]; + flatColumns.forEach((col) => { + const colTotal = grandTotals.column.get(pathToKey(col.path)); + totalRow.push(colTotal?.[0]?.value?.toString() || ""); + }); + if (totals?.showRowGrandTotals) { + totalRow.push(grandTotals.grand[0]?.value?.toString() || ""); + } + csv += totalRow.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 = `${title || "pivot"}_export.csv`; + link.click(); + }, [pivotResult, totals, title]); + + // ==================== 렌더링 ==================== + + // 빈 상태 + if (!data || data.length === 0) { + return ( +
+ +

데이터가 없습니다

+

데이터를 로드하거나 필드를 설정해주세요

+
+ ); + } + + // 필드 미설정 + if (fields.length === 0) { + return ( +
+ +

필드가 설정되지 않았습니다

+

+ 행, 열, 데이터 영역에 필드를 배치해주세요 +

+
+ ); + } + + // 피벗 결과 없음 + if (!pivotResult) { + return ( +
+ +
+ ); + } + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + return ( +
+ {/* 헤더 툴바 */} +
+
+ {title &&

{title}

} + + ({data.length}건) + +
+ +
+ {allowExpandAll && ( + <> + + + + + )} + + {exportConfig?.excel && ( + + )} + + +
+
+ + {/* 피벗 테이블 */} +
+ + + {/* 열 헤더 */} + + {/* 좌상단 코너 (행 필드 라벨) */} + + + {/* 열 헤더 셀 */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} + + + {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} + {dataFields.length > 1 && ( + + {flatColumns.map((col, colIdx) => ( + + {dataFields.map((df, dfIdx) => ( + + ))} + + ))} + {totals?.showRowGrandTotals && + dataFields.map((df, dfIdx) => ( + + ))} + + )} + + + + {flatRows.map((row, rowIdx) => ( + + {/* 행 헤더 */} + + + {/* 데이터 셀 */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + return ( + handleCellClick(row.path, col.path, values) + : undefined + } + /> + ); + })} + + {/* 행 총계 */} + {totals?.showRowGrandTotals && ( + + )} + + ))} + + {/* 열 총계 행 */} + {totals?.showColumnGrandTotals && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* 대총합 */} + {totals?.showRowGrandTotals && ( + + )} + + )} + +
0 ? 2 : 1} + > + {rowFields.map((f) => f.caption).join(" / ") || "항목"} + + {col.caption || "(전체)"} + + 총계 +
+ {df.caption} + + {df.caption} +
+ 총계 +
+
+
+ ); +}; + +export default PivotGridComponent; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx new file mode 100644 index 00000000..a0e322d9 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -0,0 +1,751 @@ +"use client"; + +/** + * PivotGrid 설정 패널 + * 화면 관리에서 PivotGrid 컴포넌트를 설정하는 UI + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridComponentConfig, + PivotFieldConfig, + PivotAreaType, + AggregationType, + DateGroupInterval, + FieldDataType, +} from "./types"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Plus, + Trash2, + GripVertical, + Settings2, + Rows, + Columns, + Database, + Filter, + ChevronUp, + ChevronDown, +} from "lucide-react"; +import { apiClient } from "@/lib/api/client"; + +// ==================== 타입 ==================== + +interface TableInfo { + table_name: string; + table_comment?: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; + is_nullable: string; +} + +interface PivotGridConfigPanelProps { + config: PivotGridComponentConfig; + onChange: (config: PivotGridComponentConfig) => void; +} + +// ==================== 유틸리티 ==================== + +const AREA_LABELS: Record = { + row: { label: "행 영역", icon: }, + column: { label: "열 영역", icon: }, + data: { label: "데이터 영역", icon: }, + filter: { label: "필터 영역", icon: }, +}; + +const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [ + { value: "sum", label: "합계" }, + { value: "count", label: "개수" }, + { value: "avg", label: "평균" }, + { value: "min", label: "최소" }, + { value: "max", label: "최대" }, + { value: "countDistinct", label: "고유값 개수" }, +]; + +const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [ + { value: "year", label: "연도" }, + { value: "quarter", label: "분기" }, + { value: "month", label: "월" }, + { value: "week", label: "주" }, + { value: "day", label: "일" }, +]; + +const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [ + { value: "string", label: "문자열" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "boolean", label: "부울" }, +]; + +// DB 타입을 FieldDataType으로 변환 +function mapDbTypeToFieldType(dbType: string): FieldDataType { + const type = dbType.toLowerCase(); + if ( + type.includes("int") || + type.includes("numeric") || + type.includes("decimal") || + type.includes("float") || + type.includes("double") || + type.includes("real") + ) { + return "number"; + } + if ( + type.includes("date") || + type.includes("time") || + type.includes("timestamp") + ) { + return "date"; + } + if (type.includes("bool")) { + return "boolean"; + } + return "string"; +} + +// ==================== 필드 설정 컴포넌트 ==================== + +interface FieldConfigItemProps { + field: PivotFieldConfig; + index: number; + onChange: (field: PivotFieldConfig) => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +} + +const FieldConfigItem: React.FC = ({ + field, + index, + onChange, + onRemove, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}) => { + return ( +
+ {/* 드래그 핸들 & 순서 버튼 */} +
+ + + +
+ + {/* 필드 설정 */} +
+ {/* 필드명 & 라벨 */} +
+
+ + onChange({ ...field, field: e.target.value })} + placeholder="column_name" + className="h-8 text-xs" + /> +
+
+ + onChange({ ...field, caption: e.target.value })} + placeholder="표시명" + className="h-8 text-xs" + /> +
+
+ + {/* 데이터 타입 & 집계 함수 */} +
+
+ + +
+ + {field.area === "data" && ( +
+ + +
+ )} + + {field.dataType === "date" && + (field.area === "row" || field.area === "column") && ( +
+ + +
+ )} +
+
+ + {/* 삭제 버튼 */} + +
+ ); +}; + +// ==================== 영역별 필드 목록 ==================== + +interface AreaFieldListProps { + area: PivotAreaType; + fields: PivotFieldConfig[]; + allColumns: ColumnInfo[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; +} + +const AreaFieldList: React.FC = ({ + area, + fields, + allColumns, + onFieldsChange, +}) => { + const areaFields = fields.filter((f) => f.area === area); + const { label, icon } = AREA_LABELS[area]; + + const handleAddField = () => { + const newField: PivotFieldConfig = { + field: "", + caption: "", + area, + areaIndex: areaFields.length, + dataType: "string", + visible: true, + }; + if (area === "data") { + newField.summaryType = "sum"; + } + onFieldsChange([...fields, newField]); + }; + + const handleAddFromColumn = (column: ColumnInfo) => { + const dataType = mapDbTypeToFieldType(column.data_type); + const newField: PivotFieldConfig = { + field: column.column_name, + caption: column.column_comment || column.column_name, + area, + areaIndex: areaFields.length, + dataType, + visible: true, + }; + if (area === "data") { + newField.summaryType = "sum"; + } + onFieldsChange([...fields, newField]); + }; + + const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => { + const newFields = [...fields]; + const globalIndex = fields.findIndex( + (f) => f.area === area && f.areaIndex === index + ); + if (globalIndex >= 0) { + newFields[globalIndex] = updatedField; + onFieldsChange(newFields); + } + }; + + const handleRemoveField = (index: number) => { + const newFields = fields.filter( + (f) => !(f.area === area && f.areaIndex === index) + ); + // 인덱스 재정렬 + let idx = 0; + newFields.forEach((f) => { + if (f.area === area) { + f.areaIndex = idx++; + } + }); + onFieldsChange(newFields); + }; + + const handleMoveField = (fromIndex: number, direction: "up" | "down") => { + const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; + if (toIndex < 0 || toIndex >= areaFields.length) return; + + const newAreaFields = [...areaFields]; + const [moved] = newAreaFields.splice(fromIndex, 1); + newAreaFields.splice(toIndex, 0, moved); + + // 인덱스 재정렬 + newAreaFields.forEach((f, idx) => { + f.areaIndex = idx; + }); + + // 전체 필드 업데이트 + const newFields = fields.filter((f) => f.area !== area); + onFieldsChange([...newFields, ...newAreaFields]); + }; + + // 이미 추가된 컬럼 제외 + const availableColumns = allColumns.filter( + (col) => !fields.some((f) => f.field === col.column_name) + ); + + return ( + + +
+ {icon} + {label} + + {areaFields.length} + +
+
+ + {/* 필드 목록 */} + {areaFields + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) + .map((field, idx) => ( + handleFieldChange(field.areaIndex || idx, f)} + onRemove={() => handleRemoveField(field.areaIndex || idx)} + onMoveUp={() => handleMoveField(idx, "up")} + onMoveDown={() => handleMoveField(idx, "down")} + isFirst={idx === 0} + isLast={idx === areaFields.length - 1} + /> + ))} + + {/* 필드 추가 */} +
+ + + +
+
+
+ ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotGridConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await apiClient.get("/api/table-management/list"); + if (response.data.success) { + setTables(response.data.data || []); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 테이블 선택 시 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.dataSource?.tableName) { + setColumns([]); + return; + } + + setLoadingColumns(true); + try { + const response = await apiClient.get( + `/api/table-management/columns/${config.dataSource.tableName}` + ); + if (response.data.success) { + setColumns(response.data.data || []); + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.dataSource?.tableName]); + + // 설정 업데이트 헬퍼 + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange] + ); + + return ( +
+ {/* 데이터 소스 설정 */} +
+ + +
+ + +
+
+ + + + {/* 필드 설정 */} + {config.dataSource?.tableName && ( +
+
+ + + {columns.length}개 컬럼 + +
+ + {loadingColumns ? ( +
+ 컬럼 로딩 중... +
+ ) : ( + + {(["row", "column", "data", "filter"] as PivotAreaType[]).map( + (area) => ( + updateConfig({ fields })} + /> + ) + )} + + )} +
+ )} + + + + {/* 표시 설정 */} +
+ + +
+
+ + + updateConfig({ + totals: { ...config.totals, showRowGrandTotals: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + totals: { ...config.totals, showColumnGrandTotals: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + totals: { ...config.totals, showRowTotals: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + totals: { ...config.totals, showColumnTotals: v }, + }) + } + /> +
+
+ +
+ + + updateConfig({ + style: { ...config.style, alternateRowColors: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + style: { ...config.style, highlightTotals: v }, + }) + } + /> +
+
+ + + + {/* 기능 설정 */} +
+ + +
+
+ + + updateConfig({ allowExpandAll: v }) + } + /> +
+ +
+ + + updateConfig({ + exportConfig: { ...config.exportConfig, excel: v }, + }) + } + /> +
+
+
+ + + + {/* 크기 설정 */} +
+ + +
+
+ + updateConfig({ height: e.target.value })} + placeholder="auto 또는 400px" + className="h-8 text-xs" + /> +
+ +
+ + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-8 text-xs" + /> +
+
+
+
+ ); +}; + +export default PivotGridConfigPanel; + diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx new file mode 100644 index 00000000..7c34192a --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -0,0 +1,84 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { PivotGridComponent } from "./PivotGridComponent"; +import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; + +/** + * PivotGrid 컴포넌트 정의 + */ +const PivotGridDefinition = createComponentDefinition({ + id: "pivot-grid", + name: "피벗 그리드", + nameEng: "PivotGrid Component", + description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: PivotGridComponent, + defaultConfig: { + dataSource: { + type: "table", + tableName: "", + }, + fields: [], + totals: { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style: { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + allowExpandAll: true, + exportConfig: { + excel: true, + }, + height: "400px", + }, + defaultSize: { width: 800, height: 500 }, + configPanel: PivotGridConfigPanel, + icon: "BarChart3", + tags: ["피벗", "분석", "집계", "그리드", "데이터"], + version: "1.0.0", + author: "개발팀", + documentation: "", +}); + +/** + * PivotGrid 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class PivotGridRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = PivotGridDefinition; + + render(): React.ReactElement { + return ( + + ); + } +} + +// 자동 등록 실행 +PivotGridRenderer.registerSelf(); + +// 강제 등록 (디버깅용) +if (typeof window !== "undefined") { + setTimeout(() => { + try { + PivotGridRenderer.registerSelf(); + } catch (error) { + console.error("❌ PivotGrid 강제 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/pivot-grid/README.md b/frontend/lib/registry/components/pivot-grid/README.md new file mode 100644 index 00000000..bc6fba52 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/README.md @@ -0,0 +1,239 @@ +# PivotGrid 컴포넌트 + +다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다. + +## 주요 기능 + +### 1. 다차원 데이터 배치 + +- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시) +- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기) +- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량) +- **필터 영역(Filter Area)**: 전체 데이터 필터링 + +### 2. 집계 함수 + +| 함수 | 설명 | 사용 예 | +|------|------|---------| +| `sum` | 합계 | 매출 합계 | +| `count` | 개수 | 건수 | +| `avg` | 평균 | 평균 단가 | +| `min` | 최소값 | 최저가 | +| `max` | 최대값 | 최고가 | +| `countDistinct` | 고유값 개수 | 거래처 수 | + +### 3. 날짜 그룹화 + +날짜 필드를 다양한 단위로 그룹화할 수 있습니다: + +- `year`: 연도별 +- `quarter`: 분기별 +- `month`: 월별 +- `week`: 주별 +- `day`: 일별 + +### 4. 드릴다운 + +계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다. + +### 5. 총합계/소계 + +- 행 총합계 (Row Grand Total) +- 열 총합계 (Column Grand Total) +- 행 소계 (Row Subtotal) +- 열 소계 (Column Subtotal) + +### 6. 내보내기 + +CSV 형식으로 데이터를 내보낼 수 있습니다. + +## 사용법 + +### 기본 사용 + +```tsx +import { PivotGridComponent } from "@/lib/registry/components/pivot-grid"; + +const salesData = [ + { region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 }, + { region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 }, + // ... +]; + + +``` + +### 날짜 그룹화 + +```tsx + +``` + +### 포맷 설정 + +```tsx + +``` + +### 화면 관리에서 사용 + +설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다. + +```tsx +import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid"; + + +``` + +## 설정 옵션 + +### PivotGridProps + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `title` | `string` | - | 피벗 테이블 제목 | +| `data` | `any[]` | `[]` | 원본 데이터 배열 | +| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 | +| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 | +| `style` | `PivotStyleConfig` | - | 스타일 설정 | +| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 | +| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 | +| `height` | `string | number` | `"auto"` | 높이 | +| `maxHeight` | `string` | - | 최대 높이 | + +### PivotFieldConfig + +| 속성 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `field` | `string` | O | 데이터 필드명 | +| `caption` | `string` | O | 표시 라벨 | +| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 | +| `areaIndex` | `number` | - | 영역 내 순서 | +| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 | +| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) | +| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 | +| `format` | `PivotFieldFormat` | - | 값 포맷 | +| `visible` | `boolean` | - | 표시 여부 | + +### PivotTotalsConfig + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 | +| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 | +| `showRowTotals` | `boolean` | `true` | 행 소계 표시 | +| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 | + +## 파일 구조 + +``` +pivot-grid/ +├── index.ts # 모듈 진입점 +├── types.ts # 타입 정의 +├── PivotGridComponent.tsx # 메인 컴포넌트 +├── PivotGridRenderer.tsx # 화면 관리 렌더러 +├── PivotGridConfigPanel.tsx # 설정 패널 +├── README.md # 문서 +└── utils/ + ├── index.ts # 유틸리티 모듈 진입점 + ├── aggregation.ts # 집계 함수 + └── pivotEngine.ts # 피벗 데이터 처리 엔진 +``` + +## 사용 시나리오 + +### 1. 매출 분석 + +지역별/기간별/제품별 매출 현황을 분석합니다. + +### 2. 재고 현황 + +창고별/품목별 재고 수량을 한눈에 파악합니다. + +### 3. 생산 실적 + +생산라인별/일자별 생산량을 분석합니다. + +### 4. 비용 분석 + +부서별/계정별 비용을 집계하여 분석합니다. + +### 5. 수주 현황 + +거래처별/품목별/월별 수주 현황을 분석합니다. + +## 주의사항 + +1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요. +2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다. +3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요. + + diff --git a/frontend/lib/registry/components/pivot-grid/index.ts b/frontend/lib/registry/components/pivot-grid/index.ts new file mode 100644 index 00000000..16044dbc --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/index.ts @@ -0,0 +1,61 @@ +/** + * PivotGrid 컴포넌트 모듈 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ + +// 타입 내보내기 +export type { + // 기본 타입 + PivotAreaType, + AggregationType, + SortDirection, + DateGroupInterval, + FieldDataType, + DataSourceType, + // 필드 설정 + PivotFieldFormat, + PivotFieldConfig, + // 데이터 소스 + PivotFilterCondition, + PivotJoinConfig, + PivotDataSourceConfig, + // 표시 설정 + PivotTotalsConfig, + FieldChooserConfig, + PivotChartConfig, + PivotStyleConfig, + PivotExportConfig, + // Props + PivotGridProps, + // 결과 데이터 + PivotCellData, + PivotHeaderNode, + PivotCellValue, + PivotResult, + PivotFlatRow, + PivotFlatColumn, + // 상태 + PivotGridState, + // Config + PivotGridComponentConfig, +} from "./types"; + +// 컴포넌트 내보내기 +export { PivotGridComponent } from "./PivotGridComponent"; +export { PivotGridConfigPanel } from "./PivotGridConfigPanel"; + +// 유틸리티 +export { + aggregate, + sum, + count, + avg, + min, + max, + countDistinct, + formatNumber, + formatDate, + getAggregationLabel, +} from "./utils/aggregation"; + +export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine"; diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts new file mode 100644 index 00000000..e0ea3199 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -0,0 +1,346 @@ +/** + * PivotGrid 컴포넌트 타입 정의 + * 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트 + */ + +// ==================== 기본 타입 ==================== + +// 필드 영역 타입 +export type PivotAreaType = "row" | "column" | "data" | "filter"; + +// 집계 함수 타입 +export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; + +// 정렬 방향 +export type SortDirection = "asc" | "desc" | "none"; + +// 날짜 그룹 간격 +export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day"; + +// 필드 데이터 타입 +export type FieldDataType = "string" | "number" | "date" | "boolean"; + +// 데이터 소스 타입 +export type DataSourceType = "table" | "api" | "static"; + +// ==================== 필드 설정 ==================== + +// 필드 포맷 설정 +export interface PivotFieldFormat { + type: "number" | "currency" | "percent" | "date" | "text"; + precision?: number; // 소수점 자릿수 + thousandSeparator?: boolean; // 천단위 구분자 + prefix?: string; // 접두사 (예: "$", "₩") + suffix?: string; // 접미사 (예: "%", "원") + dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD") +} + +// 필드 설정 +export interface PivotFieldConfig { + // 기본 정보 + field: string; // 데이터 필드명 + caption: string; // 표시 라벨 + area: PivotAreaType; // 배치 영역 + areaIndex?: number; // 영역 내 순서 + + // 데이터 타입 + dataType?: FieldDataType; // 데이터 타입 + + // 집계 설정 (data 영역용) + summaryType?: AggregationType; // 집계 함수 + + // 정렬 설정 + sortBy?: "value" | "caption"; // 정렬 기준 + sortOrder?: SortDirection; // 정렬 방향 + sortBySummary?: string; // 요약값 기준 정렬 (data 필드명) + + // 날짜 그룹화 설정 + groupInterval?: DateGroupInterval; // 날짜 그룹 간격 + groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성) + + // 표시 설정 + visible?: boolean; // 표시 여부 + width?: number; // 컬럼 너비 + expanded?: boolean; // 기본 확장 상태 + + // 포맷 설정 + format?: PivotFieldFormat; // 값 포맷 + + // 필터 설정 + filterValues?: any[]; // 선택된 필터 값 + filterType?: "include" | "exclude"; // 필터 타입 + allowFiltering?: boolean; // 필터링 허용 + allowSorting?: boolean; // 정렬 허용 + + // 계층 관련 + displayFolder?: string; // 필드 선택기에서 폴더 구조 + isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능) +} + +// ==================== 데이터 소스 설정 ==================== + +// 필터 조건 +export interface PivotFilterCondition { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN"; + value?: any; + valueFromField?: string; // formData에서 값 가져오기 +} + +// 조인 설정 +export interface PivotJoinConfig { + joinType: "INNER" | "LEFT" | "RIGHT"; + targetTable: string; + sourceColumn: string; + targetColumn: string; + columns: string[]; // 가져올 컬럼들 +} + +// 데이터 소스 설정 +export interface PivotDataSourceConfig { + type: DataSourceType; + + // 테이블 기반 + tableName?: string; // 테이블명 + + // API 기반 + apiEndpoint?: string; // API 엔드포인트 + apiMethod?: "GET" | "POST"; // HTTP 메서드 + + // 정적 데이터 + staticData?: any[]; // 정적 데이터 + + // 필터 조건 + filterConditions?: PivotFilterCondition[]; + + // 조인 설정 + joinConfigs?: PivotJoinConfig[]; +} + +// ==================== 표시 설정 ==================== + +// 총합계 표시 설정 +export interface PivotTotalsConfig { + // 행 총합계 + showRowGrandTotals?: boolean; // 행 총합계 표시 + showRowTotals?: boolean; // 행 소계 표시 + rowTotalsPosition?: "first" | "last"; // 소계 위치 + + // 열 총합계 + showColumnGrandTotals?: boolean; // 열 총합계 표시 + showColumnTotals?: boolean; // 열 소계 표시 + columnTotalsPosition?: "first" | "last"; // 소계 위치 +} + +// 필드 선택기 설정 +export interface FieldChooserConfig { + enabled: boolean; // 활성화 여부 + allowSearch?: boolean; // 검색 허용 + layout?: "default" | "simplified"; // 레이아웃 + height?: number; // 높이 + applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점 +} + +// 차트 연동 설정 +export interface PivotChartConfig { + enabled: boolean; // 차트 표시 여부 + type: "bar" | "line" | "area" | "pie" | "stackedBar"; + position: "top" | "bottom" | "left" | "right"; + height?: number; + showLegend?: boolean; + animate?: boolean; +} + +// 스타일 설정 +export interface PivotStyleConfig { + theme: "default" | "compact" | "modern"; + headerStyle: "default" | "dark" | "light"; + cellPadding: "compact" | "normal" | "comfortable"; + borderStyle: "none" | "light" | "heavy"; + alternateRowColors?: boolean; + highlightTotals?: boolean; // 총합계 강조 +} + +// ==================== 내보내기 설정 ==================== + +export interface PivotExportConfig { + excel?: boolean; + pdf?: boolean; + fileName?: string; +} + +// ==================== 메인 Props ==================== + +export interface PivotGridProps { + // 기본 설정 + id?: string; + title?: string; + + // 데이터 소스 + dataSource?: PivotDataSourceConfig; + + // 필드 설정 + fields?: PivotFieldConfig[]; + + // 표시 설정 + totals?: PivotTotalsConfig; + style?: PivotStyleConfig; + + // 필드 선택기 + fieldChooser?: FieldChooserConfig; + + // 차트 연동 + chart?: PivotChartConfig; + + // 기능 설정 + allowSortingBySummary?: boolean; // 요약값 기준 정렬 + allowFiltering?: boolean; // 필터링 허용 + allowExpandAll?: boolean; // 전체 확장/축소 허용 + wordWrapEnabled?: boolean; // 텍스트 줄바꿈 + + // 크기 설정 + height?: string | number; + maxHeight?: string; + + // 상태 저장 + stateStoring?: { + enabled: boolean; + storageKey?: string; // localStorage 키 + }; + + // 내보내기 + exportConfig?: PivotExportConfig; + + // 데이터 (외부 주입용) + data?: any[]; + + // 이벤트 + onCellClick?: (cellData: PivotCellData) => void; + onCellDoubleClick?: (cellData: PivotCellData) => void; + onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void; + onExpandChange?: (expandedPaths: string[][]) => void; + onDataChange?: (data: any[]) => void; +} + +// ==================== 결과 데이터 구조 ==================== + +// 셀 데이터 +export interface PivotCellData { + value: any; // 셀 값 + rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"]) + columnPath: string[]; // 열 경로 (예: ["2024", "Q1"]) + field?: string; // 데이터 필드명 + aggregationType?: AggregationType; + isTotal?: boolean; // 총합계 여부 + isGrandTotal?: boolean; // 대총합 여부 +} + +// 헤더 노드 (트리 구조) +export interface PivotHeaderNode { + value: any; // 원본 값 + caption: string; // 표시 텍스트 + level: number; // 깊이 + children?: PivotHeaderNode[]; // 자식 노드 + isExpanded: boolean; // 확장 상태 + path: string[]; // 경로 (드릴다운용) + subtotal?: PivotCellValue[]; // 소계 + span?: number; // colspan/rowspan +} + +// 셀 값 +export interface PivotCellValue { + field: string; // 데이터 필드 + value: number | null; // 집계 값 + formattedValue: string; // 포맷된 값 +} + +// 피벗 결과 데이터 구조 +export interface PivotResult { + // 행 헤더 트리 + rowHeaders: PivotHeaderNode[]; + + // 열 헤더 트리 + columnHeaders: PivotHeaderNode[]; + + // 데이터 매트릭스 (rowPath + columnPath → values) + dataMatrix: Map; + + // 플랫 행 목록 (렌더링용) + flatRows: PivotFlatRow[]; + + // 플랫 열 목록 (렌더링용) + flatColumns: PivotFlatColumn[]; + + // 총합계 + grandTotals: { + row: Map; // 행별 총합 + column: Map; // 열별 총합 + grand: PivotCellValue[]; // 대총합 + }; +} + +// 플랫 행 (렌더링용) +export interface PivotFlatRow { + path: string[]; + level: number; + caption: string; + isExpanded: boolean; + hasChildren: boolean; + isTotal?: boolean; +} + +// 플랫 열 (렌더링용) +export interface PivotFlatColumn { + path: string[]; + level: number; + caption: string; + span: number; + isTotal?: boolean; +} + +// ==================== 상태 관리 ==================== + +export interface PivotGridState { + expandedRowPaths: string[][]; // 확장된 행 경로들 + expandedColumnPaths: string[][]; // 확장된 열 경로들 + sortConfig: { + field: string; + direction: SortDirection; + } | null; + filterConfig: Record; // 필드별 필터값 +} + +// ==================== 컴포넌트 Config (화면관리용) ==================== + +export interface PivotGridComponentConfig { + // 데이터 소스 + dataSource?: PivotDataSourceConfig; + + // 필드 설정 + fields?: PivotFieldConfig[]; + + // 표시 설정 + totals?: PivotTotalsConfig; + style?: PivotStyleConfig; + + // 필드 선택기 + fieldChooser?: FieldChooserConfig; + + // 차트 연동 + chart?: PivotChartConfig; + + // 기능 설정 + allowSortingBySummary?: boolean; + allowFiltering?: boolean; + allowExpandAll?: boolean; + wordWrapEnabled?: boolean; + + // 크기 설정 + height?: string | number; + maxHeight?: string; + + // 내보내기 + exportConfig?: PivotExportConfig; +} + + diff --git a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts new file mode 100644 index 00000000..39aa1c5f --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts @@ -0,0 +1,176 @@ +/** + * PivotGrid 집계 함수 유틸리티 + * 다양한 집계 연산을 수행합니다. + */ + +import { AggregationType, PivotFieldFormat } from "../types"; + +// ==================== 집계 함수 ==================== + +/** + * 합계 계산 + */ +export function sum(values: number[]): number { + return values.reduce((acc, val) => acc + (val || 0), 0); +} + +/** + * 개수 계산 + */ +export function count(values: any[]): number { + return values.length; +} + +/** + * 평균 계산 + */ +export function avg(values: number[]): number { + if (values.length === 0) return 0; + return sum(values) / values.length; +} + +/** + * 최소값 계산 + */ +export function min(values: number[]): number { + if (values.length === 0) return 0; + return Math.min(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * 최대값 계산 + */ +export function max(values: number[]): number { + if (values.length === 0) return 0; + return Math.max(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * 고유값 개수 계산 + */ +export function countDistinct(values: any[]): number { + return new Set(values.filter((v) => v !== null && v !== undefined)).size; +} + +/** + * 집계 타입에 따른 집계 수행 + */ +export function aggregate( + values: any[], + type: AggregationType = "sum" +): number { + const numericValues = values + .map((v) => (typeof v === "number" ? v : parseFloat(v))) + .filter((v) => !isNaN(v)); + + switch (type) { + case "sum": + return sum(numericValues); + case "count": + return count(values); + case "avg": + return avg(numericValues); + case "min": + return min(numericValues); + case "max": + return max(numericValues); + case "countDistinct": + return countDistinct(values); + default: + return sum(numericValues); + } +} + +// ==================== 포맷 함수 ==================== + +/** + * 숫자 포맷팅 + */ +export function formatNumber( + value: number | null | undefined, + format?: PivotFieldFormat +): string { + if (value === null || value === undefined) return "-"; + + const { + type = "number", + precision = 0, + thousandSeparator = true, + prefix = "", + suffix = "", + } = format || {}; + + let formatted: string; + + switch (type) { + case "currency": + formatted = value.toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "percent": + formatted = (value * 100).toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "number": + default: + if (thousandSeparator) { + formatted = value.toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + } else { + formatted = value.toFixed(precision); + } + break; + } + + return `${prefix}${formatted}${suffix}`; +} + +/** + * 날짜 포맷팅 + */ +export function formatDate( + value: Date | string | null | undefined, + format: string = "YYYY-MM-DD" +): string { + if (!value) return "-"; + + const date = typeof value === "string" ? new Date(value) : value; + + if (isNaN(date.getTime())) return "-"; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const quarter = Math.ceil((date.getMonth() + 1) / 3); + + return format + .replace("YYYY", String(year)) + .replace("MM", month) + .replace("DD", day) + .replace("Q", `Q${quarter}`); +} + +/** + * 집계 타입 라벨 반환 + */ +export function getAggregationLabel(type: AggregationType): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유값", + }; + return labels[type] || "합계"; +} + + diff --git a/frontend/lib/registry/components/pivot-grid/utils/index.ts b/frontend/lib/registry/components/pivot-grid/utils/index.ts new file mode 100644 index 00000000..f832187e --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./aggregation"; +export * from "./pivotEngine"; + + diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts new file mode 100644 index 00000000..18113066 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -0,0 +1,621 @@ +/** + * PivotGrid 데이터 처리 엔진 + * 원시 데이터를 피벗 구조로 변환합니다. + */ + +import { + PivotFieldConfig, + PivotResult, + PivotHeaderNode, + PivotFlatRow, + PivotFlatColumn, + PivotCellValue, + DateGroupInterval, + AggregationType, +} from "../types"; +import { aggregate, formatNumber, formatDate } from "./aggregation"; + +// ==================== 헬퍼 함수 ==================== + +/** + * 필드 값 추출 (날짜 그룹핑 포함) + */ +function getFieldValue( + row: Record, + field: PivotFieldConfig +): string { + const rawValue = row[field.field]; + + if (rawValue === null || rawValue === undefined) { + return "(빈 값)"; + } + + // 날짜 그룹핑 처리 + if (field.groupInterval && field.dataType === "date") { + const date = new Date(rawValue); + if (isNaN(date.getTime())) return String(rawValue); + + switch (field.groupInterval) { + case "year": + return String(date.getFullYear()); + case "quarter": + return `Q${Math.ceil((date.getMonth() + 1) / 3)}`; + case "month": + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + case "week": + const weekNum = getWeekNumber(date); + return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; + case "day": + return formatDate(date, "YYYY-MM-DD"); + default: + return String(rawValue); + } + } + + return String(rawValue); +} + +/** + * 주차 계산 + */ +function getWeekNumber(date: Date): number { + const d = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) + ); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +/** + * 경로를 키로 변환 + */ +export function pathToKey(path: string[]): string { + return path.join("||"); +} + +/** + * 키를 경로로 변환 + */ +export function keyToPath(key: string): string[] { + return key.split("||"); +} + +// ==================== 헤더 생성 ==================== + +/** + * 계층적 헤더 노드 생성 + */ +function buildHeaderTree( + data: Record[], + fields: PivotFieldConfig[], + expandedPaths: Set +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + // 첫 번째 필드로 그룹화 + const firstField = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, firstField); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + // 정렬 + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (firstField.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + // 노드 생성 + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: 0, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + // 자식 노드 생성 (확장된 경우만) + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + 1 + ); + // span 계산 + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * 자식 노드 재귀 생성 + */ +function buildChildNodes( + data: Record[], + fields: PivotFieldConfig[], + parentPath: string[], + expandedPaths: Set, + level: number +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + const field = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, field); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (field.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [...parentPath, key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: level, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + level + 1 + ); + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * span 계산 (colspan/rowspan) + */ +function calculateSpan(children?: PivotHeaderNode[]): number { + if (!children || children.length === 0) return 1; + return children.reduce((sum, child) => sum + child.span, 0); +} + +// ==================== 플랫 구조 변환 ==================== + +/** + * 헤더 트리를 플랫 행으로 변환 + */ +function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { + const result: PivotFlatRow[] = []; + + function traverse(node: PivotHeaderNode) { + result.push({ + path: node.path, + level: node.level, + caption: node.caption, + isExpanded: node.isExpanded, + hasChildren: !!(node.children && node.children.length > 0), + }); + + if (node.isExpanded && node.children) { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + return result; +} + +/** + * 헤더 트리를 플랫 열로 변환 (각 레벨별) + */ +function flattenColumns( + nodes: PivotHeaderNode[], + maxLevel: number +): PivotFlatColumn[][] { + const levels: PivotFlatColumn[][] = Array.from( + { length: maxLevel + 1 }, + () => [] + ); + + function traverse(node: PivotHeaderNode, currentLevel: number) { + levels[currentLevel].push({ + path: node.path, + level: currentLevel, + caption: node.caption, + span: node.span, + }); + + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, currentLevel + 1); + } + } else if (currentLevel < maxLevel) { + // 확장되지 않은 노드는 다음 레벨들에서 span으로 처리 + for (let i = currentLevel + 1; i <= maxLevel; i++) { + levels[i].push({ + path: node.path, + level: i, + caption: "", + span: node.span, + }); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return levels; +} + +/** + * 열 헤더의 최대 깊이 계산 + */ +function getMaxColumnLevel( + nodes: PivotHeaderNode[], + totalFields: number +): number { + let maxLevel = 0; + + function traverse(node: PivotHeaderNode, level: number) { + maxLevel = Math.max(maxLevel, level); + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, level + 1); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return Math.min(maxLevel, totalFields - 1); +} + +// ==================== 데이터 매트릭스 생성 ==================== + +/** + * 데이터 매트릭스 생성 + */ +function buildDataMatrix( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): Map { + const matrix = new Map(); + + // 각 셀에 대해 해당하는 데이터 집계 + for (const row of flatRows) { + for (const colPath of flatColumnLeaves) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + + // 해당 행/열 경로에 맞는 데이터 필터링 + const filteredData = data.filter((record) => { + // 행 조건 확인 + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + + // 열 조건 확인 + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + + return true; + }); + + // 데이터 필드별 집계 + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate( + values, + dataField.summaryType || "sum" + ); + const formattedValue = formatNumber( + aggregatedValue, + dataField.format + ); + + return { + field: dataField.field, + value: aggregatedValue, + formattedValue, + }; + }); + + matrix.set(cellKey, cellValues); + } + } + + return matrix; +} + +/** + * 열 leaf 노드 경로 추출 + */ +function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { + const leaves: string[][] = []; + + function traverse(node: PivotHeaderNode) { + if (!node.isExpanded || !node.children || node.children.length === 0) { + leaves.push(node.path); + } else { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + // 열 필드가 없을 경우 빈 경로 추가 + if (leaves.length === 0) { + leaves.push([]); + } + + return leaves; +} + +// ==================== 총합계 계산 ==================== + +/** + * 총합계 계산 + */ +function calculateGrandTotals( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): { + row: Map; + column: Map; + grand: PivotCellValue[]; +} { + const rowTotals = new Map(); + const columnTotals = new Map(); + + // 행별 총합 (각 행의 모든 열 합계) + for (const row of flatRows) { + const filteredData = data.filter((record) => { + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + rowTotals.set(pathToKey(row.path), cellValues); + } + + // 열별 총합 (각 열의 모든 행 합계) + for (const colPath of flatColumnLeaves) { + const filteredData = data.filter((record) => { + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + columnTotals.set(pathToKey(colPath), cellValues); + } + + // 대총합 + const grandValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = data.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + return { + row: rowTotals, + column: columnTotals, + grand: grandValues, + }; +} + +// ==================== 메인 함수 ==================== + +/** + * 피벗 데이터 처리 + */ +export function processPivotData( + data: Record[], + fields: PivotFieldConfig[], + expandedRowPaths: string[][] = [], + expandedColumnPaths: string[][] = [] +): PivotResult { + // 영역별 필드 분리 + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const columnFields = fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const dataFields = fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const filterFields = fields.filter( + (f) => f.area === "filter" && f.visible !== false + ); + + // 필터 적용 + let filteredData = data; + for (const filterField of filterFields) { + if (filterField.filterValues && filterField.filterValues.length > 0) { + filteredData = filteredData.filter((row) => { + const value = getFieldValue(row, filterField); + if (filterField.filterType === "exclude") { + return !filterField.filterValues!.includes(value); + } + return filterField.filterValues!.includes(value); + }); + } + } + + // 확장 경로 Set 변환 + const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); + const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); + + // 기본 확장: 첫 번째 레벨 모두 확장 + if (expandedRowPaths.length === 0 && rowFields.length > 0) { + const firstField = rowFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedRowSet.add(val)); + } + + if (expandedColumnPaths.length === 0 && columnFields.length > 0) { + const firstField = columnFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedColSet.add(val)); + } + + // 헤더 트리 생성 + const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet); + const columnHeaders = buildHeaderTree( + filteredData, + columnFields, + expandedColSet + ); + + // 플랫 구조 변환 + const flatRows = flattenRows(rowHeaders); + const flatColumnLeaves = getColumnLeaves(columnHeaders); + const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length); + const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); + + // 데이터 매트릭스 생성 + const dataMatrix = buildDataMatrix( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + // 총합계 계산 + const grandTotals = calculateGrandTotals( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + return { + rowHeaders, + columnHeaders, + dataMatrix, + flatRows, + flatColumns: flatColumnLeaves.map((path, idx) => ({ + path, + level: path.length - 1, + caption: path[path.length - 1] || "", + span: 1, + })), + grandTotals, + }; +} + + diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index 8ffa8afe..a101efaf 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -104,6 +104,20 @@ export const TextInputComponent: React.FC = ({ const currentFormValue = formData?.[component.columnName]; const currentComponentValue = component.value; + // 🆕 채번 규칙이 설정되어 있으면 항상 _numberingRuleId를 formData에 설정 + // (값 생성 성공 여부와 관계없이, 저장 시점에 allocateCode를 호출하기 위함) + if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) { + const ruleId = testAutoGeneration.options.numberingRuleId; + if (ruleId && ruleId !== "undefined" && ruleId !== "null" && ruleId !== "") { + const ruleIdKey = `${component.columnName}_numberingRuleId`; + // formData에 아직 설정되지 않은 경우에만 설정 + if (isInteractive && onFormDataChange && !formData?.[ruleIdKey]) { + onFormDataChange(ruleIdKey, ruleId); + console.log("📝 채번 규칙 ID 사전 설정:", ruleIdKey, ruleId); + } + } + } + // 자동생성된 값이 없고, 현재 값도 없을 때만 생성 if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { isGeneratingRef.current = true; // 생성 시작 플래그 @@ -144,13 +158,6 @@ export const TextInputComponent: React.FC = ({ if (isInteractive && onFormDataChange && component.columnName) { console.log("📝 formData 업데이트:", component.columnName, generatedValue); onFormDataChange(component.columnName, generatedValue); - - // 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함) - if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) { - const ruleIdKey = `${component.columnName}_numberingRuleId`; - onFormDataChange(ruleIdKey, testAutoGeneration.options.numberingRuleId); - console.log("📝 채번 규칙 ID 저장:", ruleIdKey, testAutoGeneration.options.numberingRuleId); - } } } } else if (!autoGeneratedValue && testAutoGeneration.type !== "none") { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index c86f306c..82cf68ff 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -806,6 +806,9 @@ export class ButtonActionExecutor { console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + let hasAllocationFailure = false; + const failedFields: string[] = []; + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); @@ -816,13 +819,31 @@ export class ButtonActionExecutor { console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`); formData[fieldName] = newCode; } else { - console.warn(`⚠️ ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error); + console.warn(`⚠️ ${fieldName} 코드 할당 실패:`, allocateResult.error); + // 🆕 기존 값이 빈 문자열이면 실패로 표시 + if (!formData[fieldName] || formData[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } } } catch (allocateError) { console.error(`❌ ${fieldName} 코드 할당 오류:`, allocateError); - // 오류 시 기존 값 유지 + // 🆕 기존 값이 빈 문자열이면 실패로 표시 + if (!formData[fieldName] || formData[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } } } + + // 🆕 채번 규칙 할당 실패 시 저장 중단 + if (hasAllocationFailure) { + const fieldNames = failedFields.join(", "); + toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`); + console.error(`❌ 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`); + console.error("💡 해결 방법: 화면관리에서 해당 필드의 채번 규칙 설정을 확인하세요."); + return false; + } } console.log("✅ 채번 규칙 할당 완료"); @@ -3053,6 +3074,7 @@ export class ButtonActionExecutor { config: ButtonActionConfig, rowData: any, context: ButtonActionContext, + isCreateMode: boolean = false, // 🆕 복사 모드에서 true로 전달 ): Promise { const { groupByColumns = [] } = config; @@ -3126,10 +3148,11 @@ export class ButtonActionExecutor { const modalEvent = new CustomEvent("openEditModal", { detail: { screenId: config.targetScreenId, - title: config.editModalTitle || "데이터 수정", + title: isCreateMode ? (config.editModalTitle || "데이터 복사") : (config.editModalTitle || "데이터 수정"), description: description, modalSize: config.modalSize || "lg", editData: rowData, + isCreateMode: isCreateMode, // 🆕 복사 모드에서 INSERT로 처리되도록 groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 tableName: context.tableName, // 🆕 테이블명 전달 buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용) @@ -3244,23 +3267,61 @@ export class ButtonActionExecutor { "code", ]; + // 🆕 화면 설정에서 채번 규칙 가져오기 + let screenNumberingRules: Record = {}; + if (config.targetScreenId) { + try { + const { screenApi } = await import("@/lib/api/screen"); + const layout = await screenApi.getLayout(config.targetScreenId); + + // 레이아웃에서 채번 규칙이 설정된 컴포넌트 찾기 + const findNumberingRules = (components: any[]): void => { + for (const comp of components) { + const compConfig = comp.componentConfig || {}; + // text-input 컴포넌트의 채번 규칙 확인 + if (compConfig.autoGeneration?.type === "numbering_rule" && compConfig.autoGeneration?.options?.numberingRuleId) { + const columnName = compConfig.columnName || comp.columnName; + if (columnName) { + screenNumberingRules[columnName] = compConfig.autoGeneration.options.numberingRuleId; + console.log(`📋 화면 설정에서 채번 규칙 발견: ${columnName} → ${compConfig.autoGeneration.options.numberingRuleId}`); + } + } + // 중첩된 컴포넌트 확인 + if (comp.children && Array.isArray(comp.children)) { + findNumberingRules(comp.children); + } + } + }; + + if (layout?.components) { + findNumberingRules(layout.components); + } + console.log("📋 화면 설정에서 찾은 채번 규칙:", screenNumberingRules); + } catch (error) { + console.warn("⚠️ 화면 레이아웃 조회 실패:", error); + } + } + // 품목코드 필드를 찾아서 무조건 공백으로 초기화 let resetFieldName = ""; for (const field of itemCodeFields) { if (copiedData[field] !== undefined) { const originalValue = copiedData[field]; const ruleIdKey = `${field}_numberingRuleId`; - const hasNumberingRule = - rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; + + // 1순위: 원본 데이터에서 채번 규칙 ID 확인 + // 2순위: 화면 설정에서 채번 규칙 ID 확인 + const numberingRuleId = rowData[ruleIdKey] || screenNumberingRules[field]; + const hasNumberingRule = numberingRuleId !== undefined && numberingRuleId !== null && numberingRuleId !== ""; // 품목코드를 무조건 공백으로 초기화 copiedData[field] = ""; // 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성) if (hasNumberingRule) { - copiedData[ruleIdKey] = rowData[ruleIdKey]; + copiedData[ruleIdKey] = numberingRuleId; console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`); - console.log(`📋 채번 규칙 ID 복사: ${ruleIdKey} = ${rowData[ruleIdKey]}`); + console.log(`📋 채번 규칙 ID 설정: ${ruleIdKey} = ${numberingRuleId}`); } else { console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`); } @@ -3317,9 +3378,9 @@ export class ButtonActionExecutor { switch (editMode) { case "modal": - // 모달로 복사 폼 열기 (편집 모달 재사용) - console.log("📋 모달로 복사 폼 열기"); - await this.openEditModal(config, rowData, context); + // 모달로 복사 폼 열기 (편집 모달 재사용, INSERT 모드로) + console.log("📋 모달로 복사 폼 열기 (INSERT 모드)"); + await this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true break; case "navigate": @@ -3330,8 +3391,8 @@ export class ButtonActionExecutor { default: // 기본값: 모달 - console.log("📋 기본 모달로 복사 폼 열기"); - this.openEditModal(config, rowData, context); + console.log("📋 기본 모달로 복사 폼 열기 (INSERT 모드)"); + this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true } } catch (error: any) { console.error("❌ openCopyForm 실행 중 오류:", error);