Merge pull request 'lhj' (#343) from lhj into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/343
This commit is contained in:
hjlee 2026-01-08 17:15:03 +09:00
commit 583c6c8c79
18 changed files with 3547 additions and 78 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

View File

@ -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 });
}
});

View File

@ -124,17 +124,17 @@ export function PopProductionPanel({
return (
<div className="pop-step-form-section">
<h4 className="pop-step-form-title"> </h4>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-sm)" }}>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
<input type="checkbox" disabled={isCompleted} />
<div className="pop-checkbox-list">
<label className="pop-checkbox-label">
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
<span> </span>
</label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
<input type="checkbox" disabled={isCompleted} />
<label className="pop-checkbox-label">
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
<span> </span>
</label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
<input type="checkbox" disabled={isCompleted} />
<label className="pop-checkbox-label">
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
<span> </span>
</label>
</div>
@ -177,14 +177,12 @@ export function PopProductionPanel({
<span className="pop-badge pop-badge-primary">{workOrder.processName}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
<div style={{ fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}>
<span>{formatDate(currentDateTime)}</span>
<span style={{ marginLeft: "var(--spacing-sm)", color: "rgb(var(--neon-cyan))", fontWeight: 700 }}>
{formatTime(currentDateTime)}
</span>
<div className="pop-panel-datetime">
<span className="pop-panel-date">{formatDate(currentDateTime)}</span>
<span className="pop-panel-time">{formatTime(currentDateTime)}</span>
</div>
<button className="pop-icon-btn" onClick={onClose}>
<X size={16} />
<X size={20} />
</button>
</div>
</div>

View File

@ -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);
}
}

View File

@ -761,10 +761,74 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// INSERT 모드
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
// 🆕 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
const dataToSave = { ...formData };
const fieldsWithNumbering: Record<string, string> = {};
// 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) {

View File

@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
// 🆕 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
// 🆕 피벗 그리드 컴포넌트
import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블
/**
*
*/

View File

@ -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<RowHeaderCellProps> = ({
row,
rowFields,
onToggleExpand,
}) => {
const indentSize = row.level * 20;
return (
<td
className={cn(
"border-r border-b border-border bg-muted/50",
"px-2 py-1.5 text-left text-sm",
"whitespace-nowrap font-medium",
row.isExpanded && "bg-muted/70"
)}
style={{ paddingLeft: `${8 + indentSize}px` }}
>
<div className="flex items-center gap-1">
{row.hasChildren && (
<button
onClick={() => onToggleExpand(row.path)}
className="p-0.5 hover:bg-accent rounded"
>
{row.isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
)}
{!row.hasChildren && <span className="w-4" />}
<span>{row.caption}</span>
</div>
</td>
);
};
// 데이터 셀
interface DataCellProps {
values: PivotCellValue[];
isTotal?: boolean;
onClick?: () => void;
}
const DataCell: React.FC<DataCellProps> = ({
values,
isTotal = false,
onClick,
}) => {
if (!values || values.length === 0) {
return (
<td
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-right text-sm",
isTotal && "bg-primary/5 font-medium"
)}
>
-
</td>
);
}
// 단일 데이터 필드인 경우
if (values.length === 1) {
return (
<td
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
onClick && "cursor-pointer hover:bg-accent/50"
)}
onClick={onClick}
>
{values[0].formattedValue}
</td>
);
}
// 다중 데이터 필드인 경우
return (
<>
{values.map((val, idx) => (
<td
key={idx}
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
onClick && "cursor-pointer hover:bg-accent/50"
)}
onClick={onClick}
>
{val.formattedValue}
</td>
))}
</>
);
};
// ==================== 메인 컴포넌트 ====================
export const PivotGridComponent: React.FC<PivotGridProps> = ({
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<PivotGridState>({
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<PivotResult | null>(() => {
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 (
<div
className={cn(
"flex flex-col items-center justify-center",
"p-8 text-center text-muted-foreground",
"border border-dashed border-border rounded-lg"
)}
>
<RefreshCw className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm"> </p>
<p className="text-xs mt-1"> </p>
</div>
);
}
// 필드 미설정
if (fields.length === 0) {
return (
<div
className={cn(
"flex flex-col items-center justify-center",
"p-8 text-center text-muted-foreground",
"border border-dashed border-border rounded-lg"
)}
>
<Settings className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm"> </p>
<p className="text-xs mt-1">
, ,
</p>
</div>
);
}
// 피벗 결과 없음
if (!pivotResult) {
return (
<div className="flex items-center justify-center p-8">
<RefreshCw className="h-5 w-5 animate-spin" />
</div>
);
}
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
return (
<div
className={cn(
"flex flex-col",
"border border-border rounded-lg overflow-hidden",
"bg-background",
isFullscreen && "fixed inset-4 z-50 shadow-2xl"
)}
style={{
height: isFullscreen ? "auto" : height,
maxHeight: isFullscreen ? "none" : maxHeight,
}}
>
{/* 헤더 툴바 */}
<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">
{title && <h3 className="text-sm font-medium">{title}</h3>}
<span className="text-xs text-muted-foreground">
({data.length})
</span>
</div>
<div className="flex items-center gap-1">
{allowExpandAll && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExpandAll}
title="전체 확장"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleCollapseAll}
title="전체 축소"
>
<ChevronRight className="h-4 w-4" />
</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={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? "원래 크기" : "전체 화면"}
>
{isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* 피벗 테이블 */}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead>
{/* 열 헤더 */}
<tr className="bg-muted/50">
{/* 좌상단 코너 (행 필드 라벨) */}
<th
className={cn(
"border-r border-b border-border",
"px-2 py-2 text-left text-xs font-medium",
"bg-muted sticky left-0 top-0 z-20"
)}
rowSpan={columnFields.length > 0 ? 2 : 1}
>
{rowFields.map((f) => f.caption).join(" / ") || "항목"}
</th>
{/* 열 헤더 셀 */}
{flatColumns.map((col, idx) => (
<th
key={idx}
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-muted/70 sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
>
{col.caption || "(전체)"}
</th>
))}
{/* 행 총계 헤더 */}
{totals?.showRowGrandTotals && (
<th
className={cn(
"border-b border-border",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-primary/10 sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
>
</th>
)}
</tr>
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
{dataFields.length > 1 && (
<tr className="bg-muted/30">
{flatColumns.map((col, colIdx) => (
<React.Fragment key={colIdx}>
{dataFields.map((df, dfIdx) => (
<th
key={`${colIdx}-${dfIdx}`}
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-center text-xs font-normal",
"text-muted-foreground"
)}
>
{df.caption}
</th>
))}
</React.Fragment>
))}
{totals?.showRowGrandTotals &&
dataFields.map((df, dfIdx) => (
<th
key={`total-${dfIdx}`}
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-center text-xs font-normal",
"bg-primary/5 text-muted-foreground"
)}
>
{df.caption}
</th>
))}
</tr>
)}
</thead>
<tbody>
{flatRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className={cn(
style?.alternateRowColors &&
rowIdx % 2 === 1 &&
"bg-muted/20"
)}
>
{/* 행 헤더 */}
<RowHeaderCell
row={row}
rowFields={rowFields}
onToggleExpand={handleToggleRowExpand}
/>
{/* 데이터 셀 */}
{flatColumns.map((col, colIdx) => {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey) || [];
return (
<DataCell
key={colIdx}
values={values}
onClick={
onCellClick
? () => handleCellClick(row.path, col.path, values)
: undefined
}
/>
);
})}
{/* 행 총계 */}
{totals?.showRowGrandTotals && (
<DataCell
values={grandTotals.row.get(pathToKey(row.path)) || []}
isTotal
/>
)}
</tr>
))}
{/* 열 총계 행 */}
{totals?.showColumnGrandTotals && (
<tr className="bg-primary/5 font-medium">
<td
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-left text-sm",
"bg-primary/10 sticky left-0"
)}
>
</td>
{flatColumns.map((col, colIdx) => (
<DataCell
key={colIdx}
values={grandTotals.column.get(pathToKey(col.path)) || []}
isTotal
/>
))}
{/* 대총합 */}
{totals?.showRowGrandTotals && (
<DataCell values={grandTotals.grand} isTotal />
)}
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default PivotGridComponent;

View File

@ -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<PivotAreaType, { label: string; icon: React.ReactNode }> = {
row: { label: "행 영역", icon: <Rows className="h-4 w-4" /> },
column: { label: "열 영역", icon: <Columns className="h-4 w-4" /> },
data: { label: "데이터 영역", icon: <Database className="h-4 w-4" /> },
filter: { label: "필터 영역", icon: <Filter className="h-4 w-4" /> },
};
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<FieldConfigItemProps> = ({
field,
index,
onChange,
onRemove,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}) => {
return (
<div className="flex items-start gap-2 p-2 rounded border border-border bg-background">
{/* 드래그 핸들 & 순서 버튼 */}
<div className="flex flex-col items-center gap-0.5 pt-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onMoveUp}
disabled={isFirst}
>
<ChevronUp className="h-3 w-3" />
</Button>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onMoveDown}
disabled={isLast}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* 필드 설정 */}
<div className="flex-1 space-y-2">
{/* 필드명 & 라벨 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-xs"></Label>
<Input
value={field.field}
onChange={(e) => onChange({ ...field, field: e.target.value })}
placeholder="column_name"
className="h-8 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input
value={field.caption}
onChange={(e) => onChange({ ...field, caption: e.target.value })}
placeholder="표시명"
className="h-8 text-xs"
/>
</div>
</div>
{/* 데이터 타입 & 집계 함수 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Select
value={field.dataType || "string"}
onValueChange={(v) =>
onChange({ ...field, dataType: v as FieldDataType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{field.area === "data" && (
<div className="flex-1">
<Label className="text-xs"> </Label>
<Select
value={field.summaryType || "sum"}
onValueChange={(v) =>
onChange({ ...field, summaryType: v as AggregationType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGGREGATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{field.dataType === "date" &&
(field.area === "row" || field.area === "column") && (
<div className="flex-1">
<Label className="text-xs"> </Label>
<Select
value={field.groupInterval || "__none__"}
onValueChange={(v) =>
onChange({
...field,
groupInterval:
v === "__none__" ? undefined : (v as DateGroupInterval),
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{DATE_GROUP_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
};
// ==================== 영역별 필드 목록 ====================
interface AreaFieldListProps {
area: PivotAreaType;
fields: PivotFieldConfig[];
allColumns: ColumnInfo[];
onFieldsChange: (fields: PivotFieldConfig[]) => void;
}
const AreaFieldList: React.FC<AreaFieldListProps> = ({
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 (
<AccordionItem value={area}>
<AccordionTrigger className="py-2">
<div className="flex items-center gap-2">
{icon}
<span>{label}</span>
<Badge variant="secondary" className="ml-2">
{areaFields.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-2 pt-2">
{/* 필드 목록 */}
{areaFields
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
.map((field, idx) => (
<FieldConfigItem
key={`${field.field}-${idx}`}
field={field}
index={field.areaIndex || idx}
onChange={(f) => 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}
/>
))}
{/* 필드 추가 */}
<div className="flex gap-2">
<Select onValueChange={(v) => {
const col = allColumns.find(c => c.column_name === v);
if (col) handleAddFromColumn(col);
}}>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.length === 0 ? (
<SelectItem value="__none__" disabled>
</SelectItem>
) : (
availableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
<div className="flex items-center gap-2">
<span>{col.column_name}</span>
{col.column_comment && (
<span className="text-muted-foreground">
({col.column_comment})
</span>
)}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={handleAddField}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
);
};
// ==================== 메인 컴포넌트 ====================
export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
config,
onChange,
}) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
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<PivotGridComponentConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange]
);
return (
<div className="space-y-4">
{/* 데이터 소스 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.dataSource?.tableName || "__none__"}
onValueChange={(v) =>
updateConfig({
dataSource: {
...config.dataSource,
type: "table",
tableName: v === "__none__" ? undefined : v,
},
fields: [], // 테이블 변경 시 필드 초기화
})
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{tables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
<div className="flex items-center gap-2">
<span>{table.table_name}</span>
{table.table_comment && (
<span className="text-muted-foreground">
({table.table_comment})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* 필드 설정 */}
{config.dataSource?.tableName && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"> </Label>
<Badge variant="outline">
{columns.length}
</Badge>
</div>
{loadingColumns ? (
<div className="text-sm text-muted-foreground">
...
</div>
) : (
<Accordion
type="multiple"
defaultValue={["row", "column", "data"]}
className="w-full"
>
{(["row", "column", "data", "filter"] as PivotAreaType[]).map(
(area) => (
<AreaFieldList
key={area}
area={area}
fields={config.fields || []}
allColumns={columns}
onFieldsChange={(fields) => updateConfig({ fields })}
/>
)
)}
</Accordion>
)}
</div>
)}
<Separator />
{/* 표시 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.totals?.showRowGrandTotals !== false}
onCheckedChange={(v) =>
updateConfig({
totals: { ...config.totals, showRowGrandTotals: v },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.totals?.showColumnGrandTotals !== false}
onCheckedChange={(v) =>
updateConfig({
totals: { ...config.totals, showColumnGrandTotals: v },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.totals?.showRowTotals !== false}
onCheckedChange={(v) =>
updateConfig({
totals: { ...config.totals, showRowTotals: v },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.totals?.showColumnTotals !== false}
onCheckedChange={(v) =>
updateConfig({
totals: { ...config.totals, showColumnTotals: v },
})
}
/>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.style?.alternateRowColors !== false}
onCheckedChange={(v) =>
updateConfig({
style: { ...config.style, alternateRowColors: v },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.style?.highlightTotals !== false}
onCheckedChange={(v) =>
updateConfig({
style: { ...config.style, highlightTotals: v },
})
}
/>
</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.allowExpandAll !== false}
onCheckedChange={(v) =>
updateConfig({ allowExpandAll: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">CSV </Label>
<Switch
checked={config.exportConfig?.excel === true}
onCheckedChange={(v) =>
updateConfig({
exportConfig: { ...config.exportConfig, excel: v },
})
}
/>
</div>
</div>
</div>
<Separator />
{/* 크기 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input
value={config.height || ""}
onChange={(e) => updateConfig({ height: e.target.value })}
placeholder="auto 또는 400px"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.maxHeight || ""}
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
placeholder="600px"
className="h-8 text-xs"
/>
</div>
</div>
</div>
</div>
);
};
export default PivotGridConfigPanel;

View File

@ -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 (
<PivotGridComponent
{...this.props}
/>
);
}
}
// 자동 등록 실행
PivotGridRenderer.registerSelf();
// 강제 등록 (디버깅용)
if (typeof window !== "undefined") {
setTimeout(() => {
try {
PivotGridRenderer.registerSelf();
} catch (error) {
console.error("❌ PivotGrid 강제 등록 실패:", error);
}
}, 1000);
}

View File

@ -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 },
// ...
];
<PivotGridComponent
title="매출 분석"
data={salesData}
fields={[
{ field: "region", caption: "지역", area: "row", areaIndex: 0 },
{ field: "city", caption: "도시", area: "row", areaIndex: 1 },
{ field: "year", caption: "연도", area: "column", areaIndex: 0 },
{ field: "quarter", caption: "분기", area: "column", areaIndex: 1 },
{ field: "amount", caption: "매출액", area: "data", summaryType: "sum" },
]}
/>
```
### 날짜 그룹화
```tsx
<PivotGridComponent
data={orderData}
fields={[
{ field: "customer", caption: "거래처", area: "row" },
{
field: "orderDate",
caption: "주문일",
area: "column",
dataType: "date",
groupInterval: "month", // 월별 그룹화
},
{ field: "totalAmount", caption: "주문금액", area: "data", summaryType: "sum" },
]}
/>
```
### 포맷 설정
```tsx
<PivotGridComponent
data={salesData}
fields={[
{ field: "region", caption: "지역", area: "row" },
{ field: "year", caption: "연도", area: "column" },
{
field: "amount",
caption: "매출액",
area: "data",
summaryType: "sum",
format: {
type: "currency",
prefix: "₩",
thousandSeparator: true,
},
},
{
field: "ratio",
caption: "비율",
area: "data",
summaryType: "avg",
format: {
type: "percent",
precision: 1,
suffix: "%",
},
},
]}
/>
```
### 화면 관리에서 사용
설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다.
```tsx
import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid";
<PivotGridRenderer
id="pivot1"
config={{
dataSource: {
type: "table",
tableName: "sales_data",
},
fields: [...],
totals: {
showRowGrandTotals: true,
showColumnGrandTotals: true,
},
exportConfig: {
excel: true,
},
}}
autoFilter={{ companyCode: "COMPANY_A" }}
/>
```
## 설정 옵션
### 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`를 통해 영역 내 필드 순서를 지정하세요.

View File

@ -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";

View File

@ -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<string, PivotCellValue[]>;
// 플랫 행 목록 (렌더링용)
flatRows: PivotFlatRow[];
// 플랫 열 목록 (렌더링용)
flatColumns: PivotFlatColumn[];
// 총합계
grandTotals: {
row: Map<string, PivotCellValue[]>; // 행별 총합
column: Map<string, PivotCellValue[]>; // 열별 총합
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<string, any[]>; // 필드별 필터값
}
// ==================== 컴포넌트 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;
}

View File

@ -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<AggregationType, string> = {
sum: "합계",
count: "개수",
avg: "평균",
min: "최소",
max: "최대",
countDistinct: "고유값",
};
return labels[type] || "합계";
}

View File

@ -0,0 +1,4 @@
export * from "./aggregation";
export * from "./pivotEngine";

View File

@ -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<string, any>,
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<string, any>[],
fields: PivotFieldConfig[],
expandedPaths: Set<string>
): PivotHeaderNode[] {
if (fields.length === 0) return [];
// 첫 번째 필드로 그룹화
const firstField = fields[0];
const groups = new Map<string, Record<string, any>[]>();
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<string, any>[],
fields: PivotFieldConfig[],
parentPath: string[],
expandedPaths: Set<string>,
level: number
): PivotHeaderNode[] {
if (fields.length === 0) return [];
const field = fields[0];
const groups = new Map<string, Record<string, any>[]>();
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<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): Map<string, PivotCellValue[]> {
const matrix = new Map<string, PivotCellValue[]>();
// 각 셀에 대해 해당하는 데이터 집계
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<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): {
row: Map<string, PivotCellValue[]>;
column: Map<string, PivotCellValue[]>;
grand: PivotCellValue[];
} {
const rowTotals = new Map<string, PivotCellValue[]>();
const columnTotals = new Map<string, PivotCellValue[]>();
// 행별 총합 (각 행의 모든 열 합계)
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<string, any>[],
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,
};
}

View File

@ -104,6 +104,20 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
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<TextInputComponentProps> = ({
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") {

View File

@ -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<void> {
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<string, string> = {};
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);