diff --git a/frontend/app/(pop)/layout.tsx b/frontend/app/(pop)/layout.tsx
new file mode 100644
index 00000000..1c41d1c0
--- /dev/null
+++ b/frontend/app/(pop)/layout.tsx
@@ -0,0 +1,10 @@
+import "@/app/globals.css";
+
+export const metadata = {
+ title: "POP - 생산실적관리",
+ description: "생산 현장 실적 관리 시스템",
+};
+
+export default function PopLayout({ children }: { children: React.ReactNode }) {
+ return <>{children}>;
+}
diff --git a/frontend/app/(pop)/pop/page.tsx b/frontend/app/(pop)/pop/page.tsx
new file mode 100644
index 00000000..3cf5de33
--- /dev/null
+++ b/frontend/app/(pop)/pop/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { PopDashboard } from "@/components/pop/dashboard";
+
+export default function PopPage() {
+ return
;
+}
diff --git a/frontend/app/(pop)/pop/work/page.tsx b/frontend/app/(pop)/pop/work/page.tsx
new file mode 100644
index 00000000..15608959
--- /dev/null
+++ b/frontend/app/(pop)/pop/work/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { PopApp } from "@/components/pop";
+
+export default function PopWorkPage() {
+ return
;
+}
+
diff --git a/frontend/app/(pop)/work/page.tsx b/frontend/app/(pop)/work/page.tsx
new file mode 100644
index 00000000..15608959
--- /dev/null
+++ b/frontend/app/(pop)/work/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { PopApp } from "@/components/pop";
+
+export default function PopWorkPage() {
+ return
;
+}
+
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 06b7bd27..1614c9b8 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -388,4 +388,249 @@ select {
border-spacing: 0 !important;
}
+/* ===== POP (Production Operation Panel) Styles ===== */
+
+/* POP 전용 다크 테마 변수 */
+.pop-dark {
+ /* 배경 색상 */
+ --pop-bg-deepest: 8 12 21;
+ --pop-bg-deep: 10 15 28;
+ --pop-bg-primary: 13 19 35;
+ --pop-bg-secondary: 18 26 47;
+ --pop-bg-tertiary: 25 35 60;
+ --pop-bg-elevated: 32 45 75;
+
+ /* 네온 강조색 */
+ --pop-neon-cyan: 0 212 255;
+ --pop-neon-cyan-bright: 0 240 255;
+ --pop-neon-cyan-dim: 0 150 190;
+ --pop-neon-pink: 255 0 102;
+ --pop-neon-purple: 138 43 226;
+
+ /* 상태 색상 */
+ --pop-success: 0 255 136;
+ --pop-success-dim: 0 180 100;
+ --pop-warning: 255 170 0;
+ --pop-warning-dim: 200 130 0;
+ --pop-danger: 255 51 51;
+ --pop-danger-dim: 200 40 40;
+
+ /* 텍스트 색상 */
+ --pop-text-primary: 255 255 255;
+ --pop-text-secondary: 180 195 220;
+ --pop-text-muted: 100 120 150;
+
+ /* 테두리 색상 */
+ --pop-border: 40 55 85;
+ --pop-border-light: 55 75 110;
+}
+
+/* POP 전용 라이트 테마 변수 */
+.pop-light {
+ --pop-bg-deepest: 245 247 250;
+ --pop-bg-deep: 240 243 248;
+ --pop-bg-primary: 250 251 253;
+ --pop-bg-secondary: 255 255 255;
+ --pop-bg-tertiary: 245 247 250;
+ --pop-bg-elevated: 235 238 245;
+
+ --pop-neon-cyan: 0 122 204;
+ --pop-neon-cyan-bright: 0 140 230;
+ --pop-neon-cyan-dim: 0 100 170;
+ --pop-neon-pink: 220 38 127;
+ --pop-neon-purple: 118 38 200;
+
+ --pop-success: 22 163 74;
+ --pop-success-dim: 21 128 61;
+ --pop-warning: 245 158 11;
+ --pop-warning-dim: 217 119 6;
+ --pop-danger: 220 38 38;
+ --pop-danger-dim: 185 28 28;
+
+ --pop-text-primary: 15 23 42;
+ --pop-text-secondary: 71 85 105;
+ --pop-text-muted: 148 163 184;
+
+ --pop-border: 226 232 240;
+ --pop-border-light: 203 213 225;
+}
+
+/* POP 배경 그리드 패턴 */
+.pop-bg-pattern::before {
+ content: "";
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background:
+ repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
+ radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
+ pointer-events: none;
+ z-index: 0;
+}
+
+.pop-light .pop-bg-pattern::before {
+ background:
+ repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
+ repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
+ radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
+}
+
+/* POP 글로우 효과 */
+.pop-glow-cyan {
+ box-shadow:
+ 0 0 20px rgba(0, 212, 255, 0.5),
+ 0 0 40px rgba(0, 212, 255, 0.3);
+}
+
+.pop-glow-cyan-strong {
+ box-shadow:
+ 0 0 10px rgba(0, 212, 255, 0.8),
+ 0 0 30px rgba(0, 212, 255, 0.5),
+ 0 0 50px rgba(0, 212, 255, 0.3);
+}
+
+.pop-glow-success {
+ box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
+}
+
+.pop-glow-warning {
+ box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
+}
+
+.pop-glow-danger {
+ box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
+}
+
+/* POP 펄스 글로우 애니메이션 */
+@keyframes pop-pulse-glow {
+ 0%,
+ 100% {
+ box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
+ }
+ 50% {
+ box-shadow:
+ 0 0 20px rgba(0, 212, 255, 0.8),
+ 0 0 30px rgba(0, 212, 255, 0.4);
+ }
+}
+
+.pop-animate-pulse-glow {
+ animation: pop-pulse-glow 2s ease-in-out infinite;
+}
+
+/* POP 프로그레스 바 샤인 애니메이션 */
+@keyframes pop-progress-shine {
+ 0% {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+}
+
+.pop-progress-shine::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 20px;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
+ animation: pop-progress-shine 1.5s ease-in-out infinite;
+}
+
+/* POP 스크롤바 스타일 */
+.pop-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.pop-scrollbar::-webkit-scrollbar-track {
+ background: rgb(var(--pop-bg-secondary));
+}
+
+.pop-scrollbar::-webkit-scrollbar-thumb {
+ background: rgb(var(--pop-border-light));
+ border-radius: 9999px;
+}
+
+.pop-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: rgb(var(--pop-neon-cyan-dim));
+}
+
+/* POP 스크롤바 숨기기 */
+.pop-hide-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+
+.pop-hide-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+/* ===== Marching Ants Animation (Excel Copy Border) ===== */
+@keyframes marching-ants-h {
+ 0% {
+ background-position: 0 0;
+ }
+ 100% {
+ background-position: 16px 0;
+ }
+}
+
+@keyframes marching-ants-v {
+ 0% {
+ background-position: 0 0;
+ }
+ 100% {
+ background-position: 0 16px;
+ }
+}
+
+.animate-marching-ants-h {
+ background: repeating-linear-gradient(
+ 90deg,
+ hsl(var(--primary)) 0,
+ hsl(var(--primary)) 4px,
+ transparent 4px,
+ transparent 8px
+ );
+ background-size: 16px 2px;
+ animation: marching-ants-h 0.4s linear infinite;
+}
+
+.animate-marching-ants-v {
+ background: repeating-linear-gradient(
+ 180deg,
+ hsl(var(--primary)) 0,
+ hsl(var(--primary)) 4px,
+ transparent 4px,
+ transparent 8px
+ );
+ background-size: 2px 16px;
+ animation: marching-ants-v 0.4s linear infinite;
+}
+
+/* ===== 저장 테이블 막대기 애니메이션 ===== */
+@keyframes saveBarDrop {
+ 0% {
+ transform: scaleY(0);
+ transform-origin: top;
+ opacity: 0;
+ }
+ 100% {
+ transform: scaleY(1);
+ transform-origin: top;
+ opacity: 1;
+ }
+}
+
/* ===== End of Global Styles ===== */
diff --git a/frontend/components/admin/multilang/CategoryTree.tsx b/frontend/components/admin/multilang/CategoryTree.tsx
new file mode 100644
index 00000000..2e1238cf
--- /dev/null
+++ b/frontend/components/admin/multilang/CategoryTree.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { LangCategory, getCategories } from "@/lib/api/multilang";
+
+interface CategoryTreeProps {
+ selectedCategoryId: number | null;
+ onSelectCategory: (category: LangCategory | null) => void;
+ onDoubleClickCategory?: (category: LangCategory) => void;
+}
+
+interface CategoryNodeProps {
+ category: LangCategory;
+ level: number;
+ selectedCategoryId: number | null;
+ onSelectCategory: (category: LangCategory) => void;
+ onDoubleClickCategory?: (category: LangCategory) => void;
+}
+
+function CategoryNode({
+ category,
+ level,
+ selectedCategoryId,
+ onSelectCategory,
+ onDoubleClickCategory,
+}: CategoryNodeProps) {
+ // 기본값: 접힌 상태로 시작
+ const [isExpanded, setIsExpanded] = useState(false);
+ const hasChildren = category.children && category.children.length > 0;
+ const isSelected = selectedCategoryId === category.categoryId;
+
+ return (
+
+
onSelectCategory(category)}
+ onDoubleClick={() => onDoubleClickCategory?.(category)}
+ >
+ {/* 확장/축소 아이콘 */}
+ {hasChildren ? (
+
+ ) : (
+
+ )}
+
+ {/* 폴더/태그 아이콘 */}
+ {hasChildren || level === 0 ? (
+ isExpanded ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+ {/* 카테고리 이름 */}
+ {category.categoryName}
+
+ {/* prefix 표시 */}
+
+ {category.keyPrefix}
+
+
+
+ {/* 자식 카테고리 */}
+ {hasChildren && isExpanded && (
+
+ {category.children!.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function CategoryTree({
+ selectedCategoryId,
+ onSelectCategory,
+ onDoubleClickCategory,
+}: CategoryTreeProps) {
+ const [categories, setCategories] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadCategories();
+ }, []);
+
+ const loadCategories = async () => {
+ try {
+ setLoading(true);
+ const response = await getCategories();
+ if (response.success && response.data) {
+ setCategories(response.data);
+ } else {
+ setError(response.error?.details || "카테고리 로드 실패");
+ }
+ } catch (err) {
+ setError("카테고리 로드 중 오류 발생");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (categories.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* 전체 선택 옵션 */}
+
onSelectCategory(null)}
+ >
+
+ 전체
+
+
+ {/* 카테고리 트리 */}
+ {categories.map((category) => (
+
+ ))}
+
+ );
+}
+
+export default CategoryTree;
+
+
diff --git a/frontend/components/admin/multilang/KeyGenerateModal.tsx b/frontend/components/admin/multilang/KeyGenerateModal.tsx
new file mode 100644
index 00000000..c595adbc
--- /dev/null
+++ b/frontend/components/admin/multilang/KeyGenerateModal.tsx
@@ -0,0 +1,497 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Loader2, AlertCircle, CheckCircle2, Info, Check, ChevronsUpDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ LangCategory,
+ Language,
+ generateKey,
+ previewKey,
+ createOverrideKey,
+ getLanguages,
+ getCategoryPath,
+ KeyPreview,
+} from "@/lib/api/multilang";
+import { apiClient } from "@/lib/api/client";
+
+interface Company {
+ companyCode: string;
+ companyName: string;
+}
+
+interface KeyGenerateModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ selectedCategory: LangCategory | null;
+ companyCode: string;
+ isSuperAdmin: boolean;
+ onSuccess: () => void;
+}
+
+export function KeyGenerateModal({
+ isOpen,
+ onClose,
+ selectedCategory,
+ companyCode,
+ isSuperAdmin,
+ onSuccess,
+}: KeyGenerateModalProps) {
+ // 상태
+ const [keyMeaning, setKeyMeaning] = useState("");
+ const [usageNote, setUsageNote] = useState("");
+ const [targetCompanyCode, setTargetCompanyCode] = useState(companyCode);
+ const [languages, setLanguages] = useState([]);
+ const [texts, setTexts] = useState>({});
+ const [categoryPath, setCategoryPath] = useState([]);
+ const [preview, setPreview] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [companies, setCompanies] = useState([]);
+ const [companySearchOpen, setCompanySearchOpen] = useState(false);
+
+ // 초기화
+ useEffect(() => {
+ if (isOpen) {
+ setKeyMeaning("");
+ setUsageNote("");
+ setTargetCompanyCode(isSuperAdmin ? "*" : companyCode);
+ setTexts({});
+ setPreview(null);
+ setError(null);
+ loadLanguages();
+ if (isSuperAdmin) {
+ loadCompanies();
+ }
+ if (selectedCategory) {
+ loadCategoryPath(selectedCategory.categoryId);
+ } else {
+ setCategoryPath([]);
+ }
+ }
+ }, [isOpen, selectedCategory, companyCode, isSuperAdmin]);
+
+ // 회사 목록 로드 (최고관리자 전용)
+ const loadCompanies = async () => {
+ try {
+ const response = await apiClient.get("/admin/companies");
+ if (response.data.success && response.data.data) {
+ // snake_case를 camelCase로 변환하고 공통(*)은 제외
+ const companyList = response.data.data
+ .filter((c: any) => c.company_code !== "*")
+ .map((c: any) => ({
+ companyCode: c.company_code,
+ companyName: c.company_name,
+ }));
+ setCompanies(companyList);
+ }
+ } catch (err) {
+ console.error("회사 목록 로드 실패:", err);
+ }
+ };
+
+ // 언어 목록 로드
+ const loadLanguages = async () => {
+ const response = await getLanguages();
+ if (response.success && response.data) {
+ const activeLanguages = response.data.filter((l) => l.isActive === "Y");
+ setLanguages(activeLanguages);
+ // 초기 텍스트 상태 설정
+ const initialTexts: Record = {};
+ activeLanguages.forEach((lang) => {
+ initialTexts[lang.langCode] = "";
+ });
+ setTexts(initialTexts);
+ }
+ };
+
+ // 카테고리 경로 로드
+ const loadCategoryPath = async (categoryId: number) => {
+ const response = await getCategoryPath(categoryId);
+ if (response.success && response.data) {
+ setCategoryPath(response.data);
+ }
+ };
+
+ // 키 미리보기 (디바운스)
+ const loadPreview = useCallback(async () => {
+ if (!selectedCategory || !keyMeaning.trim()) {
+ setPreview(null);
+ return;
+ }
+
+ setPreviewLoading(true);
+ try {
+ const response = await previewKey(
+ selectedCategory.categoryId,
+ keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
+ targetCompanyCode
+ );
+ if (response.success && response.data) {
+ setPreview(response.data);
+ }
+ } catch (err) {
+ console.error("키 미리보기 실패:", err);
+ } finally {
+ setPreviewLoading(false);
+ }
+ }, [selectedCategory, keyMeaning, targetCompanyCode]);
+
+ // keyMeaning 변경 시 디바운스로 미리보기 로드
+ useEffect(() => {
+ const timer = setTimeout(loadPreview, 500);
+ return () => clearTimeout(timer);
+ }, [loadPreview]);
+
+ // 텍스트 변경 핸들러
+ const handleTextChange = (langCode: string, value: string) => {
+ setTexts((prev) => ({ ...prev, [langCode]: value }));
+ };
+
+ // 저장 핸들러
+ const handleSave = async () => {
+ if (!selectedCategory) {
+ setError("카테고리를 선택해주세요");
+ return;
+ }
+
+ if (!keyMeaning.trim()) {
+ setError("키 의미를 입력해주세요");
+ return;
+ }
+
+ // 최소 하나의 텍스트 입력 검증
+ const hasText = Object.values(texts).some((t) => t.trim());
+ if (!hasText) {
+ setError("최소 하나의 언어에 대한 텍스트를 입력해주세요");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ // 오버라이드 모드인지 확인
+ if (preview?.isOverride && preview.baseKeyId) {
+ // 오버라이드 키 생성
+ const response = await createOverrideKey({
+ companyCode: targetCompanyCode,
+ baseKeyId: preview.baseKeyId,
+ texts: Object.entries(texts)
+ .filter(([_, text]) => text.trim())
+ .map(([langCode, langText]) => ({ langCode, langText })),
+ });
+
+ if (response.success) {
+ onSuccess();
+ onClose();
+ } else {
+ setError(response.error?.details || "오버라이드 키 생성 실패");
+ }
+ } else {
+ // 새 키 생성
+ const response = await generateKey({
+ companyCode: targetCompanyCode,
+ categoryId: selectedCategory.categoryId,
+ keyMeaning: keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
+ usageNote: usageNote.trim() || undefined,
+ texts: Object.entries(texts)
+ .filter(([_, text]) => text.trim())
+ .map(([langCode, langText]) => ({ langCode, langText })),
+ });
+
+ if (response.success) {
+ onSuccess();
+ onClose();
+ } else {
+ setError(response.error?.details || "키 생성 실패");
+ }
+ }
+ } catch (err: any) {
+ setError(err.message || "키 생성 중 오류 발생");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 생성될 키 미리보기
+ const generatedKeyPreview = categoryPath.length > 0 && keyMeaning.trim()
+ ? [...categoryPath.map((c) => c.keyPrefix), keyMeaning.trim().toLowerCase().replace(/\s+/g, "_")].join(".")
+ : "";
+
+ return (
+
+ );
+}
+
+export default KeyGenerateModal;
+
+
diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx
new file mode 100644
index 00000000..67e7f80b
--- /dev/null
+++ b/frontend/components/common/EditableSpreadsheet.tsx
@@ -0,0 +1,1209 @@
+"use client";
+
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import { cn } from "@/lib/utils";
+
+interface EditableSpreadsheetProps {
+ columns: string[];
+ data: Record[];
+ onColumnsChange: (columns: string[]) => void;
+ onDataChange: (data: Record[]) => void;
+ maxHeight?: string;
+}
+
+// 셀 범위 정의
+interface CellRange {
+ startRow: number;
+ startCol: number;
+ endRow: number;
+ endCol: number;
+}
+
+/**
+ * 엑셀처럼 편집 가능한 스프레드시트 컴포넌트
+ * - 셀 클릭으로 편집
+ * - Tab/Enter로 다음 셀 이동
+ * - 마지막 행/열에서 자동 추가
+ * - 헤더(컬럼명)도 편집 가능
+ * - 다중 셀 선택 (드래그)
+ * - 자동 채우기 (드래그 핸들) - 다중 셀 지원
+ */
+export const EditableSpreadsheet: React.FC = ({
+ columns,
+ data,
+ onColumnsChange,
+ onDataChange,
+ maxHeight = "350px",
+}) => {
+ // 현재 편집 중인 셀 (row: -1은 헤더)
+ const [editingCell, setEditingCell] = useState<{
+ row: number;
+ col: number;
+ } | null>(null);
+ const [editValue, setEditValue] = useState("");
+
+ // 선택 범위 (다중 셀 선택)
+ const [selection, setSelection] = useState(null);
+
+ // 셀 선택 드래그 중
+ const [isDraggingSelection, setIsDraggingSelection] = useState(false);
+
+ // 자동 채우기 드래그 상태
+ const [isDraggingFill, setIsDraggingFill] = useState(false);
+ const [fillPreviewEnd, setFillPreviewEnd] = useState(null);
+
+ // 복사된 범위 (점선 애니메이션 표시용)
+ const [copiedRange, setCopiedRange] = useState(null);
+
+ // Undo/Redo 히스토리
+ interface HistoryState {
+ columns: string[];
+ data: Record[];
+ }
+ const [history, setHistory] = useState([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const [isUndoRedo, setIsUndoRedo] = useState(false);
+
+ const inputRef = useRef(null);
+ const tableRef = useRef(null);
+
+ // 히스토리에 현재 상태 저장
+ const saveToHistory = useCallback(() => {
+ if (isUndoRedo) return;
+
+ const newState: HistoryState = {
+ columns: [...columns],
+ data: data.map(row => ({ ...row })),
+ };
+
+ setHistory(prev => {
+ // 현재 인덱스 이후의 히스토리는 삭제 (새로운 분기)
+ const newHistory = prev.slice(0, historyIndex + 1);
+ newHistory.push(newState);
+ // 최대 50개까지만 유지
+ if (newHistory.length > 50) {
+ newHistory.shift();
+ return newHistory;
+ }
+ return newHistory;
+ });
+ setHistoryIndex(prev => Math.min(prev + 1, 49));
+ }, [columns, data, historyIndex, isUndoRedo]);
+
+ // 초기 상태 저장
+ useEffect(() => {
+ if (history.length === 0 && (columns.length > 0 || data.length > 0)) {
+ setHistory([{ columns: [...columns], data: data.map(row => ({ ...row })) }]);
+ setHistoryIndex(0);
+ }
+ }, []);
+
+ // 데이터 변경 시 히스토리 저장 (Undo/Redo가 아닌 경우)
+ useEffect(() => {
+ if (!isUndoRedo && historyIndex >= 0) {
+ const currentState = history[historyIndex];
+ if (currentState) {
+ const columnsChanged = JSON.stringify(columns) !== JSON.stringify(currentState.columns);
+ const dataChanged = JSON.stringify(data) !== JSON.stringify(currentState.data);
+ if (columnsChanged || dataChanged) {
+ saveToHistory();
+ }
+ }
+ }
+ setIsUndoRedo(false);
+ }, [columns, data]);
+
+ // Undo 실행
+ const handleUndo = useCallback(() => {
+ if (historyIndex <= 0) return;
+
+ const prevIndex = historyIndex - 1;
+ const prevState = history[prevIndex];
+ if (prevState) {
+ setIsUndoRedo(true);
+ setHistoryIndex(prevIndex);
+ onColumnsChange([...prevState.columns]);
+ onDataChange(prevState.data.map(row => ({ ...row })));
+ }
+ }, [history, historyIndex, onColumnsChange, onDataChange]);
+
+ // Redo 실행
+ const handleRedo = useCallback(() => {
+ if (historyIndex >= history.length - 1) return;
+
+ const nextIndex = historyIndex + 1;
+ const nextState = history[nextIndex];
+ if (nextState) {
+ setIsUndoRedo(true);
+ setHistoryIndex(nextIndex);
+ onColumnsChange([...nextState.columns]);
+ onDataChange(nextState.data.map(row => ({ ...row })));
+ }
+ }, [history, historyIndex, onColumnsChange, onDataChange]);
+
+ // 범위 정규화 (시작이 끝보다 크면 교환)
+ const normalizeRange = (range: CellRange): CellRange => {
+ return {
+ startRow: Math.min(range.startRow, range.endRow),
+ startCol: Math.min(range.startCol, range.endCol),
+ endRow: Math.max(range.startRow, range.endRow),
+ endCol: Math.max(range.startCol, range.endCol),
+ };
+ };
+
+ // 셀이 선택 범위 내에 있는지 확인
+ const isCellInSelection = (row: number, col: number): boolean => {
+ if (!selection) return false;
+ const norm = normalizeRange(selection);
+ return (
+ row >= norm.startRow &&
+ row <= norm.endRow &&
+ col >= norm.startCol &&
+ col <= norm.endCol
+ );
+ };
+
+ // 셀이 선택 범위의 끝(우하단)인지 확인
+ const isCellSelectionEnd = (row: number, col: number): boolean => {
+ if (!selection) return false;
+ const norm = normalizeRange(selection);
+ return row === norm.endRow && col === norm.endCol;
+ };
+
+ // 셀 선택 시작 (클릭)
+ const handleCellMouseDown = useCallback((row: number, col: number, e: React.MouseEvent) => {
+ // 편집 중이면 종료
+ if (editingCell) {
+ setEditingCell(null);
+ setEditValue("");
+ }
+
+ // 새 선택 시작
+ setSelection({
+ startRow: row,
+ startCol: col,
+ endRow: row,
+ endCol: col,
+ });
+ setIsDraggingSelection(true);
+
+ // 테이블에 포커스 (키보드 이벤트 수신용)
+ tableRef.current?.focus();
+ }, [editingCell]);
+
+ // 셀 선택 드래그 중
+ const handleCellMouseEnter = useCallback((row: number, col: number) => {
+ if (isDraggingSelection && selection) {
+ setSelection((prev) => prev ? {
+ ...prev,
+ endRow: row,
+ endCol: col,
+ } : null);
+ }
+ }, [isDraggingSelection, selection]);
+
+ // 셀 선택 드래그 종료
+ useEffect(() => {
+ const handleMouseUp = () => {
+ if (isDraggingSelection) {
+ setIsDraggingSelection(false);
+ }
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isDraggingSelection]);
+
+ // 셀 편집 시작 (더블클릭)
+ const startEditing = useCallback(
+ (row: number, col: number) => {
+ setEditingCell({ row, col });
+ setSelection({
+ startRow: row,
+ startCol: col,
+ endRow: row,
+ endCol: col,
+ });
+ if (row === -1) {
+ // 헤더 편집
+ setEditValue(columns[col] || "");
+ } else {
+ // 데이터 셀 편집
+ const colName = columns[col];
+ setEditValue(String(data[row]?.[colName] ?? ""));
+ }
+ },
+ [columns, data]
+ );
+
+ // 편집 완료
+ const finishEditing = useCallback(() => {
+ if (!editingCell) return;
+
+ const { row, col } = editingCell;
+
+ if (row === -1) {
+ // 헤더(컬럼명) 변경
+ const newColumns = [...columns];
+ const oldColName = newColumns[col];
+ const newColName = editValue.trim() || `Column${col + 1}`;
+
+ if (oldColName !== newColName) {
+ newColumns[col] = newColName;
+ onColumnsChange(newColumns);
+
+ // 데이터의 키도 함께 변경
+ const newData = data.map((rowData) => {
+ const newRowData: Record = {};
+ Object.keys(rowData).forEach((key) => {
+ if (key === oldColName) {
+ newRowData[newColName] = rowData[key];
+ } else {
+ newRowData[key] = rowData[key];
+ }
+ });
+ return newRowData;
+ });
+ onDataChange(newData);
+ }
+ } else {
+ // 데이터 셀 변경
+ const colName = columns[col];
+ const newData = [...data];
+ if (!newData[row]) {
+ newData[row] = {};
+ }
+ newData[row] = { ...newData[row], [colName]: editValue };
+ onDataChange(newData);
+ }
+
+ setEditingCell(null);
+ setEditValue("");
+ }, [editingCell, editValue, columns, data, onColumnsChange, onDataChange]);
+
+ // 다음 셀로 이동
+ const moveToNextCell = useCallback(
+ (direction: "right" | "down" | "left" | "up") => {
+ if (!editingCell) return;
+
+ finishEditing();
+
+ const { row, col } = editingCell;
+ let nextRow = row;
+ let nextCol = col;
+
+ switch (direction) {
+ case "right":
+ if (col < columns.length - 1) {
+ nextCol = col + 1;
+ } else {
+ // 마지막 열에서 Tab → 새 열 추가 (빈 헤더로)
+ const tempColId = `__temp_${Date.now()}`;
+ const newColumns = [...columns, ""];
+ onColumnsChange(newColumns);
+
+ // 모든 행에 새 컬럼 추가 (임시 키 사용)
+ const newData = data.map((rowData) => ({
+ ...rowData,
+ [tempColId]: "",
+ }));
+ onDataChange(newData);
+
+ nextCol = columns.length;
+ }
+ break;
+
+ case "down":
+ if (row === -1) {
+ nextRow = 0;
+ } else if (row < data.length - 1) {
+ nextRow = row + 1;
+ } else {
+ // 마지막 행에서 Enter → 새 행 추가
+ const newRow: Record = {};
+ columns.forEach((c) => {
+ newRow[c] = "";
+ });
+ onDataChange([...data, newRow]);
+ nextRow = data.length;
+ }
+ break;
+
+ case "left":
+ if (col > 0) {
+ nextCol = col - 1;
+ }
+ break;
+
+ case "up":
+ if (row > -1) {
+ nextRow = row - 1;
+ }
+ break;
+ }
+
+ // 다음 셀 편집 시작
+ setTimeout(() => {
+ startEditing(nextRow, nextCol);
+ }, 0);
+ },
+ [editingCell, columns, data, onColumnsChange, onDataChange, finishEditing, startEditing]
+ );
+
+ // 키보드 이벤트 처리
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ switch (e.key) {
+ case "Tab":
+ e.preventDefault();
+ moveToNextCell(e.shiftKey ? "left" : "right");
+ break;
+ case "Enter":
+ e.preventDefault();
+ moveToNextCell("down");
+ break;
+ case "Escape":
+ setEditingCell(null);
+ setEditValue("");
+ break;
+ case "ArrowUp":
+ if (!e.shiftKey) {
+ e.preventDefault();
+ moveToNextCell("up");
+ }
+ break;
+ case "ArrowDown":
+ if (!e.shiftKey) {
+ e.preventDefault();
+ moveToNextCell("down");
+ }
+ break;
+ case "ArrowLeft":
+ // 커서가 맨 앞이면 왼쪽 셀로
+ if (inputRef.current?.selectionStart === 0) {
+ e.preventDefault();
+ moveToNextCell("left");
+ }
+ break;
+ case "ArrowRight":
+ // 커서가 맨 뒤면 오른쪽 셀로
+ if (inputRef.current?.selectionStart === editValue.length) {
+ e.preventDefault();
+ moveToNextCell("right");
+ }
+ break;
+ }
+ },
+ [moveToNextCell, editValue]
+ );
+
+ // 편집 모드일 때 input에 포커스
+ useEffect(() => {
+ if (editingCell && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [editingCell]);
+
+ // 외부 클릭 시 편집 종료
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (tableRef.current && !tableRef.current.contains(e.target as Node)) {
+ finishEditing();
+ setSelection(null);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [finishEditing]);
+
+ // ============ 복사/붙여넣기 ============
+
+ // 셀이 복사 범위 내에 있는지 확인
+ const isCellInCopiedRange = (row: number, col: number): boolean => {
+ if (!copiedRange) return false;
+ const norm = normalizeRange(copiedRange);
+ return (
+ row >= norm.startRow &&
+ row <= norm.endRow &&
+ col >= norm.startCol &&
+ col <= norm.endCol
+ );
+ };
+
+ // 복사 범위의 테두리 위치 확인
+ const getCopiedBorderPosition = (row: number, col: number): { top: boolean; right: boolean; bottom: boolean; left: boolean } => {
+ if (!copiedRange) return { top: false, right: false, bottom: false, left: false };
+ const norm = normalizeRange(copiedRange);
+
+ if (!isCellInCopiedRange(row, col)) {
+ return { top: false, right: false, bottom: false, left: false };
+ }
+
+ return {
+ top: row === norm.startRow,
+ right: col === norm.endCol,
+ bottom: row === norm.endRow,
+ left: col === norm.startCol,
+ };
+ };
+
+ // 선택 범위 복사 (Ctrl+C)
+ const handleCopy = useCallback(async () => {
+ if (!selection || editingCell) return;
+
+ const norm = normalizeRange(selection);
+ const rows: string[] = [];
+
+ for (let r = norm.startRow; r <= norm.endRow; r++) {
+ const rowValues: string[] = [];
+ for (let c = norm.startCol; c <= norm.endCol; c++) {
+ if (r === -1) {
+ // 헤더 복사
+ rowValues.push(columns[c] || "");
+ } else {
+ // 데이터 복사
+ const colName = columns[c];
+ rowValues.push(String(data[r]?.[colName] ?? ""));
+ }
+ }
+ rows.push(rowValues.join("\t"));
+ }
+
+ const text = rows.join("\n");
+
+ try {
+ await navigator.clipboard.writeText(text);
+ // 복사 범위 저장 (점선 애니메이션 표시)
+ setCopiedRange({ ...norm });
+ } catch (err) {
+ console.warn("클립보드 복사 실패:", err);
+ }
+ }, [selection, editingCell, columns, data]);
+
+ // 붙여넣기 (Ctrl+V)
+ const handlePaste = useCallback(async () => {
+ if (!selection || editingCell) return;
+
+ try {
+ const text = await navigator.clipboard.readText();
+ if (!text) return;
+
+ const norm = normalizeRange(selection);
+ const pasteRows = text.split(/\r?\n/).map((row) => row.split("\t"));
+
+ // 빈 행 제거
+ const filteredRows = pasteRows.filter((row) => row.some((cell) => cell.trim() !== ""));
+ if (filteredRows.length === 0) return;
+
+ const newData = [...data];
+ const newColumns = [...columns];
+ let columnsChanged = false;
+
+ for (let ri = 0; ri < filteredRows.length; ri++) {
+ const pasteRow = filteredRows[ri];
+ const targetRow = norm.startRow + ri;
+
+ for (let ci = 0; ci < pasteRow.length; ci++) {
+ const targetCol = norm.startCol + ci;
+ const value = pasteRow[ci];
+
+ if (targetRow === -1) {
+ // 헤더에 붙여넣기
+ if (targetCol < newColumns.length) {
+ newColumns[targetCol] = value;
+ columnsChanged = true;
+ }
+ } else {
+ // 데이터에 붙여넣기
+ if (targetCol < columns.length) {
+ // 필요시 행 추가
+ while (newData.length <= targetRow) {
+ const emptyRow: Record = {};
+ columns.forEach((c) => {
+ emptyRow[c] = "";
+ });
+ newData.push(emptyRow);
+ }
+
+ const colName = columns[targetCol];
+ newData[targetRow] = {
+ ...newData[targetRow],
+ [colName]: value,
+ };
+ }
+ }
+ }
+ }
+
+ if (columnsChanged) {
+ onColumnsChange(newColumns);
+ }
+ onDataChange(newData);
+
+ // 붙여넣기 범위로 선택 확장
+ setSelection({
+ startRow: norm.startRow,
+ startCol: norm.startCol,
+ endRow: Math.min(norm.startRow + filteredRows.length - 1, data.length - 1),
+ endCol: Math.min(norm.startCol + (filteredRows[0]?.length || 1) - 1, columns.length - 1),
+ });
+
+ // 붙여넣기 후 복사 범위 초기화
+ setCopiedRange(null);
+ } catch (err) {
+ console.warn("클립보드 붙여넣기 실패:", err);
+ }
+ }, [selection, editingCell, columns, data, onColumnsChange, onDataChange]);
+
+ // Delete 키로 선택 범위 삭제
+ const handleDelete = useCallback(() => {
+ if (!selection || editingCell) return;
+
+ const norm = normalizeRange(selection);
+ const newData = [...data];
+
+ for (let r = norm.startRow; r <= norm.endRow; r++) {
+ if (r >= 0 && r < newData.length) {
+ for (let c = norm.startCol; c <= norm.endCol; c++) {
+ if (c < columns.length) {
+ const colName = columns[c];
+ newData[r] = {
+ ...newData[r],
+ [colName]: "",
+ };
+ }
+ }
+ }
+ }
+
+ onDataChange(newData);
+ }, [selection, editingCell, columns, data, onDataChange]);
+
+ // 전역 키보드 이벤트 (복사/붙여넣기/삭제)
+ useEffect(() => {
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
+ // 편집 중이면 무시 (input에서 자체 처리)
+ if (editingCell) return;
+
+ // 선택이 없으면 무시
+ if (!selection) return;
+
+ // 다른 입력 필드에 포커스가 있으면 무시
+ const activeElement = document.activeElement;
+ const isInputFocused = activeElement instanceof HTMLInputElement ||
+ activeElement instanceof HTMLTextAreaElement ||
+ activeElement instanceof HTMLSelectElement;
+
+ // 테이블 내부의 input이 아닌 다른 input에 포커스가 있으면 무시
+ if (isInputFocused && !tableRef.current?.contains(activeElement)) {
+ return;
+ }
+
+ if ((e.ctrlKey || e.metaKey) && e.key === "z") {
+ // Ctrl+Z: Undo
+ e.preventDefault();
+ handleUndo();
+ } else if ((e.ctrlKey || e.metaKey) && e.key === "y") {
+ // Ctrl+Y: Redo
+ e.preventDefault();
+ handleRedo();
+ } else if ((e.ctrlKey || e.metaKey) && e.key === "c") {
+ e.preventDefault();
+ handleCopy();
+ } else if ((e.ctrlKey || e.metaKey) && e.key === "v") {
+ e.preventDefault();
+ handlePaste();
+ } else if (e.key === "Delete" || e.key === "Backspace") {
+ // 다른 곳에 포커스가 있으면 Delete 무시
+ if (isInputFocused) return;
+ e.preventDefault();
+ handleDelete();
+ } else if (e.key === "Escape") {
+ // Esc로 복사 범위 표시 취소
+ setCopiedRange(null);
+ } else if (e.key === "F2") {
+ // F2로 편집 모드 진입 (기존 값 유지)
+ const norm = normalizeRange(selection);
+ if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) {
+ e.preventDefault();
+ const colName = columns[norm.startCol];
+ setEditingCell({ row: norm.startRow, col: norm.startCol });
+ setEditValue(String(data[norm.startRow]?.[colName] ?? ""));
+ }
+ } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ // 일반 문자 키 입력 시 편집 모드 진입 (엑셀처럼)
+ const norm = normalizeRange(selection);
+ if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) {
+ // 단일 셀 선택 시에만
+ e.preventDefault();
+ setEditingCell({ row: norm.startRow, col: norm.startCol });
+ setEditValue(e.key); // 입력한 문자로 시작
+ }
+ }
+ };
+
+ document.addEventListener("keydown", handleGlobalKeyDown);
+ return () => document.removeEventListener("keydown", handleGlobalKeyDown);
+ }, [editingCell, selection, handleCopy, handlePaste, handleDelete, handleUndo, handleRedo]);
+
+ // 행 삭제
+ const handleDeleteRow = (rowIndex: number) => {
+ const newData = data.filter((_, i) => i !== rowIndex);
+ onDataChange(newData);
+ };
+
+ // 열 삭제
+ const handleDeleteColumn = (colIndex: number) => {
+ if (columns.length <= 1) return;
+
+ const colName = columns[colIndex];
+ const newColumns = columns.filter((_, i) => i !== colIndex);
+ onColumnsChange(newColumns);
+
+ const newData = data.map((row) => {
+ const { [colName]: removed, ...rest } = row;
+ return rest;
+ });
+ onDataChange(newData);
+ };
+
+ // 컬럼 문자 (A, B, C, ...)
+ const getColumnLetter = (index: number): string => {
+ let letter = "";
+ let i = index;
+ while (i >= 0) {
+ letter = String.fromCharCode(65 + (i % 26)) + letter;
+ i = Math.floor(i / 26) - 1;
+ }
+ return letter;
+ };
+
+ // ============ 자동 채우기 로직 ============
+
+ // 값에서 마지막 숫자 패턴 추출
+ const extractNumberPattern = (value: string): {
+ prefix: string;
+ number: number;
+ suffix: string;
+ numLength: number;
+ isZeroPadded: boolean;
+ } | null => {
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
+ const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes(".");
+ return {
+ prefix: "",
+ number: parseFloat(value),
+ suffix: "",
+ numLength: value.replace("-", "").split(".")[0].length,
+ isZeroPadded
+ };
+ }
+
+ const match = value.match(/^(.*)(\d+)(\D*)$/);
+ if (match) {
+ const numStr = match[2];
+ const isZeroPadded = numStr.startsWith("0") && numStr.length > 1;
+ return {
+ prefix: match[1],
+ number: parseInt(numStr, 10),
+ suffix: match[3],
+ numLength: numStr.length,
+ isZeroPadded
+ };
+ }
+
+ return null;
+ };
+
+ // 날짜 패턴 인식
+ const extractDatePattern = (value: string): Date | null => {
+ const dateMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (dateMatch) {
+ const date = new Date(parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3]));
+ if (!isNaN(date.getTime())) {
+ return date;
+ }
+ }
+ return null;
+ };
+
+ // 다음 값 생성
+ const generateNextValue = (sourceValue: string, step: number): string => {
+ if (!sourceValue || sourceValue.trim() === "") {
+ return "";
+ }
+
+ const datePattern = extractDatePattern(sourceValue);
+ if (datePattern) {
+ const newDate = new Date(datePattern);
+ newDate.setDate(newDate.getDate() + step);
+ const year = newDate.getFullYear();
+ const month = String(newDate.getMonth() + 1).padStart(2, "0");
+ const day = String(newDate.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ const numberPattern = extractNumberPattern(sourceValue);
+ if (numberPattern) {
+ const newNumber = numberPattern.number + step;
+ const absNumber = Math.max(0, newNumber);
+
+ let numStr: string;
+ if (numberPattern.isZeroPadded) {
+ numStr = String(absNumber).padStart(numberPattern.numLength, "0");
+ } else {
+ numStr = String(absNumber);
+ }
+
+ return numberPattern.prefix + numStr + numberPattern.suffix;
+ }
+
+ return sourceValue;
+ };
+
+ // 자동 채우기 드래그 시작
+ const handleFillDragStart = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // 편집 중이면 먼저 현재 편집 값을 저장
+ if (editingCell) {
+ const { row, col } = editingCell;
+ if (row === -1) {
+ // 헤더 변경
+ const newColumns = [...columns];
+ const oldColName = newColumns[col];
+ const newColName = editValue.trim() || `Column${col + 1}`;
+ if (oldColName !== newColName) {
+ newColumns[col] = newColName;
+ onColumnsChange(newColumns);
+ }
+ } else {
+ // 데이터 셀 변경
+ const colName = columns[col];
+ const newData = [...data];
+ if (!newData[row]) {
+ newData[row] = {};
+ }
+ newData[row] = { ...newData[row], [colName]: editValue };
+ onDataChange(newData);
+ }
+ setEditingCell(null);
+ setEditValue("");
+ }
+
+ if (!selection) return;
+ const norm = normalizeRange(selection);
+ if (norm.startRow < 0) return; // 헤더는 제외
+
+ setIsDraggingFill(true);
+ setFillPreviewEnd(norm.endRow);
+ };
+
+ // 자동 채우기 드래그 중
+ const handleFillDragMove = useCallback((e: MouseEvent) => {
+ if (!isDraggingFill || !selection || !tableRef.current) return;
+
+ const rows = tableRef.current.querySelectorAll("tbody tr");
+ const mouseY = e.clientY;
+
+ for (let i = 0; i < rows.length - 1; i++) {
+ const row = rows[i] as HTMLElement;
+ const rect = row.getBoundingClientRect();
+
+ if (mouseY >= rect.top && mouseY <= rect.bottom) {
+ setFillPreviewEnd(i);
+ break;
+ } else if (mouseY > rect.bottom && i === rows.length - 2) {
+ setFillPreviewEnd(i);
+ }
+ }
+ }, [isDraggingFill, selection]);
+
+ // 열의 숫자 패턴 간격 계산 (예: 201, 202 → 간격 1)
+ const calculateColumnIncrement = (colIndex: number, startRow: number, endRow: number): number | null => {
+ if (startRow === endRow) return 1; // 단일 행이면 기본 증가 1
+
+ const colName = columns[colIndex];
+ const increments: number[] = [];
+
+ for (let row = startRow; row < endRow; row++) {
+ const currentValue = String(data[row]?.[colName] ?? "");
+ const nextValue = String(data[row + 1]?.[colName] ?? "");
+
+ const currentPattern = extractNumberPattern(currentValue);
+ const nextPattern = extractNumberPattern(nextValue);
+
+ if (currentPattern && nextPattern) {
+ // 접두사와 접미사가 같은지 확인
+ if (currentPattern.prefix === nextPattern.prefix && currentPattern.suffix === nextPattern.suffix) {
+ increments.push(nextPattern.number - currentPattern.number);
+ } else {
+ return null; // 패턴이 다르면 복사 모드
+ }
+ } else {
+ return null; // 숫자 패턴이 없으면 복사 모드
+ }
+ }
+
+ // 모든 간격이 같은지 확인
+ if (increments.length > 0 && increments.every(inc => inc === increments[0])) {
+ return increments[0];
+ }
+
+ return null;
+ };
+
+ // 자동 채우기 드래그 종료 (다중 셀 지원)
+ // - 숫자 패턴이 있으면: 패턴 간격을 인식하여 증가 (201, 202 → 203, 204)
+ // - 숫자 패턴이 없으면: 선택된 패턴 그대로 반복 (복사)
+ const handleFillDragEnd = useCallback(() => {
+ if (!isDraggingFill || !selection || fillPreviewEnd === null) {
+ setIsDraggingFill(false);
+ setFillPreviewEnd(null);
+ return;
+ }
+
+ const norm = normalizeRange(selection);
+ const endRow = fillPreviewEnd;
+ const selectionHeight = norm.endRow - norm.startRow + 1;
+
+ if (endRow !== norm.endRow && norm.startRow >= 0) {
+ const newData = [...data];
+
+ // 각 열별로 증가 패턴 계산
+ const columnIncrements: Map = new Map();
+ for (let col = norm.startCol; col <= norm.endCol; col++) {
+ columnIncrements.set(col, calculateColumnIncrement(col, norm.startRow, norm.endRow));
+ }
+
+ if (endRow > norm.endRow) {
+ // 아래로 채우기
+ for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) {
+ if (!newData[targetRow]) {
+ newData[targetRow] = {};
+ columns.forEach((c) => {
+ newData[targetRow][c] = "";
+ });
+ }
+
+ // 선택된 모든 열에 대해 채우기
+ for (let col = norm.startCol; col <= norm.endCol; col++) {
+ const colName = columns[col];
+ const increment = columnIncrements.get(col);
+
+ if (increment !== null) {
+ // 숫자 패턴 증가 모드
+ // 마지막 선택 행의 값을 기준으로 증가
+ const lastValue = String(data[norm.endRow]?.[colName] ?? "");
+ const step = (targetRow - norm.endRow) * increment;
+ newData[targetRow] = {
+ ...newData[targetRow],
+ [colName]: generateNextValue(lastValue, step),
+ };
+ } else {
+ // 복사 모드 (패턴 반복)
+ const sourceRowOffset = (targetRow - norm.endRow - 1) % selectionHeight;
+ const sourceRow = norm.startRow + sourceRowOffset;
+ const sourceValue = String(data[sourceRow]?.[colName] ?? "");
+ newData[targetRow] = {
+ ...newData[targetRow],
+ [colName]: sourceValue,
+ };
+ }
+ }
+ }
+ } else if (endRow < norm.startRow) {
+ // 위로 채우기
+ for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) {
+ if (!newData[targetRow]) {
+ newData[targetRow] = {};
+ columns.forEach((c) => {
+ newData[targetRow][c] = "";
+ });
+ }
+
+ for (let col = norm.startCol; col <= norm.endCol; col++) {
+ const colName = columns[col];
+ const increment = columnIncrements.get(col);
+
+ if (increment !== null) {
+ // 숫자 패턴 감소 모드
+ const firstValue = String(data[norm.startRow]?.[colName] ?? "");
+ const step = (targetRow - norm.startRow) * increment;
+ newData[targetRow] = {
+ ...newData[targetRow],
+ [colName]: generateNextValue(firstValue, step),
+ };
+ } else {
+ // 복사 모드 (패턴 반복)
+ const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight;
+ const sourceRow = norm.endRow - sourceRowOffset;
+ const sourceValue = String(data[sourceRow]?.[colName] ?? "");
+ newData[targetRow] = {
+ ...newData[targetRow],
+ [colName]: sourceValue,
+ };
+ }
+ }
+ }
+ }
+
+ onDataChange(newData);
+ }
+
+ setIsDraggingFill(false);
+ setFillPreviewEnd(null);
+ }, [isDraggingFill, selection, fillPreviewEnd, columns, data, onDataChange]);
+
+ // 드래그 이벤트 리스너
+ useEffect(() => {
+ if (isDraggingFill) {
+ document.addEventListener("mousemove", handleFillDragMove);
+ document.addEventListener("mouseup", handleFillDragEnd);
+ return () => {
+ document.removeEventListener("mousemove", handleFillDragMove);
+ document.removeEventListener("mouseup", handleFillDragEnd);
+ };
+ }
+ }, [isDraggingFill, handleFillDragMove, handleFillDragEnd]);
+
+ // 셀이 자동 채우기 미리보기 범위에 있는지 확인
+ const isInFillPreview = (rowIndex: number, colIndex: number): boolean => {
+ if (!isDraggingFill || !selection || fillPreviewEnd === null) return false;
+
+ const norm = normalizeRange(selection);
+
+ // 열이 선택 범위 내에 있어야 함
+ if (colIndex < norm.startCol || colIndex > norm.endCol) return false;
+
+ if (fillPreviewEnd > norm.endRow) {
+ return rowIndex > norm.endRow && rowIndex <= fillPreviewEnd;
+ } else if (fillPreviewEnd < norm.startRow) {
+ return rowIndex >= fillPreviewEnd && rowIndex < norm.startRow;
+ }
+
+ return false;
+ };
+
+ return (
+
+
+ {/* 열 인덱스 헤더 (A, B, C, ...) */}
+
+
+ {/* 빈 코너 셀 */}
+ |
+
+ |
+ {columns.map((_, colIndex) => (
+
+
+ {getColumnLetter(colIndex)}
+ {columns.length > 1 && (
+
+ )}
+
+ |
+ ))}
+ {/* 새 열 추가 버튼 */}
+
+
+ |
+
+
+ {/* 컬럼명 헤더 (편집 가능) */}
+
+ |
+ 1
+ |
+ {columns.map((colName, colIndex) => (
+ handleCellMouseDown(-1, colIndex, e)}
+ onMouseEnter={() => handleCellMouseEnter(-1, colIndex)}
+ onDoubleClick={() => startEditing(-1, colIndex)}
+ >
+ {editingCell?.row === -1 && editingCell?.col === colIndex ? (
+ setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={finishEditing}
+ className="w-full bg-white px-2 py-1 text-xs font-medium text-primary outline-none"
+ />
+ ) : (
+ {colName || 빈 헤더}
+ )}
+ |
+ ))}
+ |
+
+
+
+
+ {data.map((row, rowIndex) => (
+
+ {/* 행 번호 */}
+ |
+
+ {rowIndex + 2}
+
+
+ |
+
+ {/* 데이터 셀 */}
+ {columns.map((colName, colIndex) => {
+ const isSelected = isCellInSelection(rowIndex, colIndex);
+ const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
+ const inFillPreview = isInFillPreview(rowIndex, colIndex);
+ const isSelectionEnd = isCellSelectionEnd(rowIndex, colIndex);
+ const copiedBorder = getCopiedBorderPosition(rowIndex, colIndex);
+ const isCopied = isCellInCopiedRange(rowIndex, colIndex);
+
+ return (
+ handleCellMouseDown(rowIndex, colIndex, e)}
+ onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)}
+ onDoubleClick={() => startEditing(rowIndex, colIndex)}
+ >
+ {isEditing ? (
+ setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={finishEditing}
+ className="w-full bg-white px-2 py-1 text-xs outline-none"
+ />
+ ) : (
+
+ {String(row[colName] ?? "")}
+
+ )}
+
+ {/* 복사 범위 점선 테두리 (Marching Ants) */}
+ {isCopied && (
+ <>
+ {copiedBorder.top && (
+
+ )}
+ {copiedBorder.right && (
+
+ )}
+ {copiedBorder.bottom && (
+
+ )}
+ {copiedBorder.left && (
+
+ )}
+ >
+ )}
+
+ {/* 자동 채우기 핸들 - 선택 범위의 우하단에서만 표시 (편집 중에도 표시) */}
+ {isSelectionEnd && selection && normalizeRange(selection).startRow >= 0 && (
+
+ )}
+ |
+ );
+ })}
+ |
+
+ ))}
+
+ {/* 새 행 추가 영역 */}
+
+ |
+
+ |
+ {
+ const newRow: Record = {};
+ columns.forEach((c) => {
+ newRow[c] = "";
+ });
+ onDataChange([...data, newRow]);
+ setTimeout(() => {
+ startEditing(data.length, 0);
+ }, 0);
+ }}
+ >
+ 클릭하여 새 행 추가...
+ |
+
+
+
+
+ );
+};
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx
index a4a17274..64fe38b8 100644
--- a/frontend/components/common/ExcelUploadModal.tsx
+++ b/frontend/components/common/ExcelUploadModal.tsx
@@ -18,16 +18,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { Input } from "@/components/ui/input";
-import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
- Plus,
- Minus,
ArrowRight,
Zap,
} from "lucide-react";
@@ -35,6 +31,37 @@ import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
import { cn } from "@/lib/utils";
+import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
+import { EditableSpreadsheet } from "./EditableSpreadsheet";
+
+// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
+export interface MasterDetailExcelConfig {
+ // 테이블 정보
+ masterTable?: string;
+ detailTable?: string;
+ masterKeyColumn?: string;
+ detailFkColumn?: string;
+ // 채번
+ numberingRuleId?: string;
+ // 업로드 전 사용자가 선택할 마스터 테이블 필드
+ masterSelectFields?: Array<{
+ columnName: string;
+ columnLabel: string;
+ required: boolean;
+ inputType: "entity" | "date" | "text" | "select";
+ referenceTable?: string;
+ referenceColumn?: string;
+ displayColumn?: string;
+ }>;
+ // 엑셀에서 매핑할 디테일 테이블 필드
+ detailExcelFields?: Array<{
+ columnName: string;
+ columnLabel: string;
+ required: boolean;
+ }>;
+ masterDefaults?: Record;
+ detailDefaults?: Record;
+}
export interface ExcelUploadModalProps {
open: boolean;
@@ -44,6 +71,24 @@ export interface ExcelUploadModalProps {
keyColumn?: string;
onSuccess?: () => void;
userId?: string;
+ // 마스터-디테일 지원
+ screenId?: number;
+ isMasterDetail?: boolean;
+ masterDetailRelation?: {
+ masterTable: string;
+ detailTable: string;
+ masterKeyColumn: string;
+ detailFkColumn: string;
+ masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
+ detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
+ };
+ // 🆕 마스터-디테일 엑셀 업로드 설정
+ masterDetailExcelConfig?: MasterDetailExcelConfig;
+ // 🆕 단일 테이블 채번 설정
+ numberingRuleId?: string;
+ numberingTargetColumn?: string;
+ // 🆕 업로드 후 제어 실행 설정
+ afterUploadFlows?: Array<{ flowId: string; order: number }>;
}
interface ColumnMapping {
@@ -59,37 +104,156 @@ export const ExcelUploadModal: React.FC = ({
keyColumn,
onSuccess,
userId = "guest",
+ screenId,
+ isMasterDetail = false,
+ masterDetailRelation,
+ masterDetailExcelConfig,
+ // 단일 테이블 채번 설정
+ numberingRuleId,
+ numberingTargetColumn,
+ // 업로드 후 제어 실행 설정
+ afterUploadFlows,
}) => {
const [currentStep, setCurrentStep] = useState(1);
- // 1단계: 파일 선택
+ // 1단계: 파일 선택 & 미리보기
const [file, setFile] = useState(null);
const [sheetNames, setSheetNames] = useState([]);
const [selectedSheet, setSelectedSheet] = useState("");
+ const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef(null);
-
- // 2단계: 범위 지정
- const [autoCreateColumn, setAutoCreateColumn] = useState(false);
- const [selectedCompany, setSelectedCompany] = useState("");
- const [selectedDataType, setSelectedDataType] = useState("");
const [detectedRange, setDetectedRange] = useState("");
- const [previewData, setPreviewData] = useState[]>([]);
const [allData, setAllData] = useState[]>([]);
const [displayData, setDisplayData] = useState[]>([]);
- // 3단계: 컬럼 매핑
+ // 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
+ const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
const [excelColumns, setExcelColumns] = useState([]);
const [systemColumns, setSystemColumns] = useState([]);
const [columnMappings, setColumnMappings] = useState([]);
- // 4단계: 확인
+ // 3단계: 확인
const [isUploading, setIsUploading] = useState(false);
+ // 🆕 마스터-디테일 모드: 마스터 필드 입력값
+ const [masterFieldValues, setMasterFieldValues] = useState>({});
+ const [entitySearchData, setEntitySearchData] = useState>({});
+ const [entitySearchLoading, setEntitySearchLoading] = useState>({});
+ const [entityDisplayColumns, setEntityDisplayColumns] = useState>({});
+
+ // 🆕 엔티티 참조 데이터 로드
+ useEffect(() => {
+ console.log("🔍 엔티티 데이터 로드 체크:", {
+ masterSelectFields: masterDetailExcelConfig?.masterSelectFields,
+ open,
+ isMasterDetail,
+ });
+
+ if (!masterDetailExcelConfig?.masterSelectFields) return;
+
+ const loadEntityData = async () => {
+ const { apiClient } = await import("@/lib/api/client");
+ const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
+
+ for (const field of masterDetailExcelConfig.masterSelectFields!) {
+ console.log("🔍 필드 처리:", field);
+
+ if (field.inputType === "entity") {
+ setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true }));
+ try {
+ let refTable = field.referenceTable;
+ console.log("🔍 초기 refTable:", refTable);
+
+ let displayCol = field.displayColumn;
+
+ // referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회
+ if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) {
+ console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable);
+ const colResponse = await apiClient.get(
+ `/table-management/tables/${masterDetailExcelConfig.masterTable}/columns`
+ );
+ console.log("🔍 컬럼 조회 응답:", colResponse.data);
+
+ if (colResponse.data?.success && colResponse.data?.data?.columns) {
+ const colInfo = colResponse.data.data.columns.find(
+ (c: any) => (c.columnName || c.column_name) === field.columnName
+ );
+ console.log("🔍 찾은 컬럼 정보:", colInfo);
+ if (colInfo) {
+ if (!refTable) {
+ refTable = colInfo.referenceTable || colInfo.reference_table;
+ console.log("🔍 DB에서 가져온 refTable:", refTable);
+ }
+ if (!displayCol) {
+ displayCol = colInfo.displayColumn || colInfo.display_column;
+ console.log("🔍 DB에서 가져온 displayColumn:", displayCol);
+ }
+ }
+ }
+ }
+
+ // displayColumn 저장 (Select 렌더링 시 사용)
+ if (displayCol) {
+ setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol }));
+ }
+
+ if (refTable) {
+ console.log("🔍 엔티티 데이터 조회:", refTable);
+ const response = await DynamicFormApi.getTableData(refTable, {
+ page: 1,
+ pageSize: 1000,
+ });
+ console.log("🔍 엔티티 데이터 응답:", response);
+ // getTableData는 { success, data: [...] } 형식으로 반환
+ const rows = response.data?.rows || response.data;
+ if (response.success && rows && Array.isArray(rows)) {
+ setEntitySearchData((prev) => ({
+ ...prev,
+ [field.columnName]: rows,
+ }));
+ console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개");
+ }
+ } else {
+ console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName);
+ }
+ } catch (error) {
+ console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error);
+ } finally {
+ setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false }));
+ }
+ }
+ }
+ };
+
+ if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) {
+ loadEntityData();
+ }
+ }, [open, isMasterDetail, masterDetailExcelConfig]);
+
+ // 마스터-디테일 모드에서 마스터 필드 입력 여부 확인
+ const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig;
+ const hasMasterSelectFields = isSimpleMasterDetailMode &&
+ (masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0;
+
+ // 마스터 필드가 모두 입력되었는지 확인
+ const isMasterFieldsValid = () => {
+ if (!hasMasterSelectFields) return true;
+ return masterDetailExcelConfig!.masterSelectFields!.every((field) => {
+ if (!field.required) return true;
+ const value = masterFieldValues[field.columnName];
+ return value !== undefined && value !== null && value !== "";
+ });
+ };
+
// 파일 선택 핸들러
const handleFileChange = async (e: React.ChangeEvent) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
+ await processFile(selectedFile);
+ };
+ // 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유)
+ const processFile = async (selectedFile: File) => {
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
@@ -105,7 +269,7 @@ export const ExcelUploadModal: React.FC = ({
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
- setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
+ setDisplayData(data);
if (data.length > 0) {
const columns = Object.keys(data[0]);
@@ -122,6 +286,30 @@ export const ExcelUploadModal: React.FC = ({
}
};
+ // 드래그 앤 드롭 핸들러
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(false);
+
+ const droppedFile = e.dataTransfer.files?.[0];
+ if (droppedFile) {
+ await processFile(droppedFile);
+ }
+ };
+
// 시트 변경 핸들러
const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
@@ -130,7 +318,7 @@ export const ExcelUploadModal: React.FC = ({
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
- setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
+ setDisplayData(data);
if (data.length > 0) {
const columns = Object.keys(data[0]);
@@ -144,82 +332,156 @@ export const ExcelUploadModal: React.FC = ({
}
};
- // 행 추가
- const handleAddRow = () => {
- const newRow: Record = {};
- excelColumns.forEach((col) => {
- newRow[col] = "";
- });
- setDisplayData([...displayData, newRow]);
- toast.success("행이 추가되었습니다.");
- };
-
- // 행 삭제
- const handleRemoveRow = () => {
- if (displayData.length > 1) {
- setDisplayData(displayData.slice(0, -1));
- toast.success("마지막 행이 삭제되었습니다.");
- } else {
- toast.error("최소 1개의 행이 필요합니다.");
- }
- };
-
- // 열 추가
- const handleAddColumn = () => {
- const newColName = `Column${excelColumns.length + 1}`;
- setExcelColumns([...excelColumns, newColName]);
- setDisplayData(
- displayData.map((row) => ({
- ...row,
- [newColName]: "",
- }))
- );
- toast.success("열이 추가되었습니다.");
- };
-
- // 열 삭제
- const handleRemoveColumn = () => {
- if (excelColumns.length > 1) {
- const lastCol = excelColumns[excelColumns.length - 1];
- setExcelColumns(excelColumns.slice(0, -1));
- setDisplayData(
- displayData.map((row) => {
- const { [lastCol]: removed, ...rest } = row;
- return rest;
- })
- );
- toast.success("마지막 열이 삭제되었습니다.");
- } else {
- toast.error("최소 1개의 열이 필요합니다.");
- }
- };
-
- // 테이블 스키마 가져오기
+ // 테이블 스키마 가져오기 (2단계 진입 시)
useEffect(() => {
- if (currentStep === 3 && tableName) {
+ if (currentStep === 2 && tableName) {
loadTableSchema();
}
}, [currentStep, tableName]);
+ // 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외)
+ const AUTO_GENERATED_COLUMNS = [
+ "id",
+ "created_date",
+ "updated_date",
+ "writer",
+ "company_code",
+ ];
+
const loadTableSchema = async () => {
try {
- console.log("🔍 테이블 스키마 로드 시작:", { tableName });
-
- const response = await getTableSchema(tableName);
-
- console.log("📊 테이블 스키마 응답:", response);
-
- if (response.success && response.data) {
- console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns);
- setSystemColumns(response.data.columns);
+ console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
+ let allColumns: TableColumn[] = [];
+
+ // 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택)
+ if (isSimpleMasterDetailMode && masterDetailRelation) {
+ const { detailTable, detailFkColumn } = masterDetailRelation;
+
+ console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
+
+ // 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택)
+ const detailResponse = await getTableSchema(detailTable);
+ if (detailResponse.success && detailResponse.data) {
+ // 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체
+ const configuredFields = masterDetailExcelConfig?.detailExcelFields;
+
+ const detailCols = detailResponse.data.columns
+ .filter((col) => {
+ // 자동 생성 컬럼, FK 컬럼 제외
+ if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
+ if (col.name === detailFkColumn) return false;
+
+ // 설정된 필드가 있으면 해당 필드만
+ if (configuredFields && configuredFields.length > 0) {
+ return configuredFields.some((f) => f.columnName === col.name);
+ }
+ return true;
+ })
+ .map((col) => {
+ // 설정에서 라벨 찾기
+ const configField = configuredFields?.find((f) => f.columnName === col.name);
+ return {
+ ...col,
+ label: configField?.columnLabel || col.label || col.name,
+ originalName: col.name,
+ sourceTable: detailTable,
+ };
+ });
+ allColumns = detailCols;
+ }
+
+ console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length);
+ }
+ // 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기
+ else if (isMasterDetail && masterDetailRelation) {
+ const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
+
+ console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
+
+ // 마스터 테이블 스키마
+ const masterResponse = await getTableSchema(masterTable);
+ if (masterResponse.success && masterResponse.data) {
+ const masterCols = masterResponse.data.columns
+ .filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
+ .map((col) => ({
+ ...col,
+ // 유니크 키를 위해 테이블명 접두사 추가
+ name: `${masterTable}.${col.name}`,
+ label: `[마스터] ${col.label || col.name}`,
+ originalName: col.name,
+ sourceTable: masterTable,
+ }));
+ allColumns = [...allColumns, ...masterCols];
+ }
+
+ // 디테일 테이블 스키마 (FK 컬럼 제외)
+ const detailResponse = await getTableSchema(detailTable);
+ if (detailResponse.success && detailResponse.data) {
+ const detailCols = detailResponse.data.columns
+ .filter((col) =>
+ !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) &&
+ col.name !== detailFkColumn // FK 컬럼 제외
+ )
+ .map((col) => ({
+ ...col,
+ // 유니크 키를 위해 테이블명 접두사 추가
+ name: `${detailTable}.${col.name}`,
+ label: `[디테일] ${col.label || col.name}`,
+ originalName: col.name,
+ sourceTable: detailTable,
+ }));
+ allColumns = [...allColumns, ...detailCols];
+ }
+
+ console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length);
+ } else {
+ // 기존 단일 테이블 모드
+ const response = await getTableSchema(tableName);
+
+ console.log("📊 테이블 스키마 응답:", response);
+
+ if (response.success && response.data) {
+ // 자동 생성 컬럼 제외
+ allColumns = response.data.columns.filter(
+ (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
+ );
+ } else {
+ console.error("❌ 테이블 스키마 로드 실패:", response);
+ return;
+ }
+ }
+
+ console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
+ setSystemColumns(allColumns);
+
+ // 기존 매핑 템플릿 조회
+ console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
+ const mappingResponse = await findMappingByColumns(tableName, excelColumns);
+
+ if (mappingResponse.success && mappingResponse.data) {
+ // 저장된 매핑 템플릿이 있으면 자동 적용
+ console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
+ const savedMappings = mappingResponse.data.columnMappings;
+
+ const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
+ excelColumn: col,
+ systemColumn: savedMappings[col] || null,
+ }));
+ setColumnMappings(appliedMappings);
+ setIsAutoMappingLoaded(true);
+
+ const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
+ toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
+ } else {
+ // 매핑 템플릿이 없으면 초기 상태로 설정
+ console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
- } else {
- console.error("❌ 테이블 스키마 로드 실패:", response);
+ setIsAutoMappingLoaded(false);
}
} catch (error) {
console.error("❌ 테이블 스키마 로드 실패:", error);
@@ -231,17 +493,35 @@ export const ExcelUploadModal: React.FC = ({
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const normalizedExcelCol = excelCol.toLowerCase().trim();
-
- // 1. 먼저 라벨로 매칭 시도
- let matchedSystemCol = systemColumns.find(
- (sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
- );
+ // [마스터], [디테일] 접두사 제거 후 비교
+ const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
+
+ // 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
+ let matchedSystemCol = systemColumns.find((sysCol) => {
+ if (!sysCol.label) return false;
+ // [마스터], [디테일] 접두사 제거 후 비교
+ const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
+ return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
+ });
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) {
- matchedSystemCol = systemColumns.find(
- (sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
- );
+ matchedSystemCol = systemColumns.find((sysCol) => {
+ // 마스터-디테일 모드: originalName이 있으면 사용
+ const originalName = (sysCol as any).originalName;
+ const colName = originalName || sysCol.name;
+ return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
+ });
+ }
+
+ // 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
+ if (!matchedSystemCol) {
+ matchedSystemCol = systemColumns.find((sysCol) => {
+ // 테이블.컬럼 형식에서 컬럼만 추출
+ const nameParts = sysCol.name.split(".");
+ const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
+ return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
+ });
}
return {
@@ -259,9 +539,7 @@ export const ExcelUploadModal: React.FC = ({
const handleMappingChange = (excelColumn: string, systemColumn: string | null) => {
setColumnMappings((prev) =>
prev.map((mapping) =>
- mapping.excelColumn === excelColumn
- ? { ...mapping, systemColumn }
- : mapping
+ mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping
)
);
};
@@ -273,12 +551,54 @@ export const ExcelUploadModal: React.FC = ({
return;
}
- if (currentStep === 2 && displayData.length === 0) {
+ if (currentStep === 1 && displayData.length === 0) {
toast.error("데이터가 없습니다.");
return;
}
- setCurrentStep((prev) => Math.min(prev + 1, 4));
+ // 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
+ if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
+ toast.error("마스터 정보를 모두 입력해주세요.");
+ return;
+ }
+
+ // 1단계 → 2단계 전환 시: 빈 헤더 열 제외
+ if (currentStep === 1) {
+ // 빈 헤더가 아닌 열만 필터링
+ const validColumnIndices: number[] = [];
+ const validColumns: string[] = [];
+
+ excelColumns.forEach((col, index) => {
+ if (col && col.trim() !== "") {
+ validColumnIndices.push(index);
+ validColumns.push(col);
+ }
+ });
+
+ // 빈 헤더 열이 있었다면 데이터에서도 해당 열 제거
+ if (validColumns.length < excelColumns.length) {
+ const removedCount = excelColumns.length - validColumns.length;
+
+ // 새로운 데이터: 유효한 열만 포함
+ const cleanedData = displayData.map((row) => {
+ const newRow: Record = {};
+ validColumns.forEach((colName) => {
+ newRow[colName] = row[colName];
+ });
+ return newRow;
+ });
+
+ setExcelColumns(validColumns);
+ setDisplayData(cleanedData);
+ setAllData(cleanedData);
+
+ if (removedCount > 0) {
+ toast.info(`빈 헤더 ${removedCount}개 열이 제외되었습니다.`);
+ }
+ }
+ }
+
+ setCurrentStep((prev) => Math.min(prev + 1, 3));
};
// 이전 단계
@@ -296,43 +616,163 @@ export const ExcelUploadModal: React.FC = ({
setIsUploading(true);
try {
- // allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
+ // allData를 사용하여 전체 데이터 업로드
const mappedData = allData.map((row) => {
const mappedRow: Record = {};
columnMappings.forEach((mapping) => {
if (mapping.systemColumn) {
- mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
+ // 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
+ let colName = mapping.systemColumn;
+ if (isMasterDetail && colName.includes(".")) {
+ colName = colName.split(".")[1];
+ }
+ mappedRow[colName] = row[mapping.excelColumn];
}
});
return mappedRow;
});
- let successCount = 0;
- let failCount = 0;
+ // 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외
+ const filteredData = mappedData.filter((row) => {
+ const values = Object.values(row);
+ return values.some((value) => {
+ if (value === undefined || value === null) return false;
+ if (typeof value === "string" && value.trim() === "") return false;
+ return true;
+ });
+ });
- for (const row of mappedData) {
- try {
- if (uploadMode === "insert") {
- const formData = { screenId: 0, tableName, data: row };
- const result = await DynamicFormApi.saveFormData(formData);
- if (result.success) {
- successCount++;
- } else {
- failCount++;
- }
- }
- } catch (error) {
- failCount++;
+ console.log(
+ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
+ );
+
+ // 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
+ if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
+ console.log("📊 마스터-디테일 간단 모드 업로드:", {
+ masterDetailRelation,
+ masterFieldValues,
+ numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
+ });
+
+ const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
+ screenId,
+ filteredData,
+ masterFieldValues,
+ masterDetailExcelConfig?.numberingRuleId || undefined,
+ masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
+ masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
+ );
+
+ if (uploadResult.success && uploadResult.data) {
+ const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data;
+
+ toast.success(
+ `마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
+ (errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
+ );
+
+ // 매핑 템플릿 저장
+ await saveMappingTemplateInternal();
+
+ onSuccess?.();
+ } else {
+ toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
}
}
+ // 🆕 마스터-디테일 기존 모드 처리
+ else if (isMasterDetail && screenId && masterDetailRelation) {
+ console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
- if (successCount > 0) {
- toast.success(
- `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
+ const uploadResult = await DynamicFormApi.uploadMasterDetailData(
+ screenId,
+ filteredData
);
- onSuccess?.();
+
+ if (uploadResult.success && uploadResult.data) {
+ const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
+
+ toast.success(
+ `마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
+ (errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
+ );
+
+ // 매핑 템플릿 저장
+ await saveMappingTemplateInternal();
+
+ onSuccess?.();
+ } else {
+ toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
+ }
} else {
- toast.error("업로드에 실패했습니다.");
+ // 기존 단일 테이블 업로드 로직
+ let successCount = 0;
+ let failCount = 0;
+
+ // 단일 테이블 채번 설정 확인
+ const hasNumbering = numberingRuleId && numberingTargetColumn;
+
+ for (const row of filteredData) {
+ try {
+ let dataToSave = { ...row };
+
+ // 채번 적용: 각 행마다 채번 API 호출
+ if (hasNumbering && uploadMode === "insert") {
+ try {
+ const { apiClient } = await import("@/lib/api/client");
+ const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
+ const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
+ if (numberingResponse.data?.success && generatedCode) {
+ dataToSave[numberingTargetColumn] = generatedCode;
+ }
+ } catch (numError) {
+ console.error("채번 오류:", numError);
+ }
+ }
+
+ if (uploadMode === "insert") {
+ const formData = { screenId: 0, tableName, data: dataToSave };
+ const result = await DynamicFormApi.saveFormData(formData);
+ if (result.success) {
+ successCount++;
+ } else {
+ failCount++;
+ }
+ }
+ } catch (error) {
+ failCount++;
+ }
+ }
+
+ // 🆕 업로드 후 제어 실행
+ if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) {
+ console.log("🔄 업로드 후 제어 실행:", afterUploadFlows);
+ try {
+ const { apiClient } = await import("@/lib/api/client");
+ // 순서대로 실행
+ const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order);
+ for (const flow of sortedFlows) {
+ await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, {
+ sourceData: { tableName, uploadedCount: successCount },
+ });
+ console.log(`✅ 제어 실행 완료: flowId=${flow.flowId}`);
+ }
+ } catch (controlError) {
+ console.error("제어 실행 오류:", controlError);
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(
+ `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
+ );
+
+ // 매핑 템플릿 저장
+ await saveMappingTemplateInternal();
+
+ onSuccess?.();
+ } else {
+ toast.error("업로드에 실패했습니다.");
+ }
}
} catch (error) {
console.error("❌ 엑셀 업로드 실패:", error);
@@ -342,6 +782,35 @@ export const ExcelUploadModal: React.FC = ({
}
};
+ // 매핑 템플릿 저장 헬퍼 함수
+ const saveMappingTemplateInternal = async () => {
+ try {
+ const mappingsToSave: Record = {};
+ columnMappings.forEach((mapping) => {
+ mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
+ });
+
+ console.log("💾 매핑 템플릿 저장 중...", {
+ tableName,
+ excelColumns,
+ mappingsToSave,
+ });
+ const saveResult = await saveMappingTemplate(
+ tableName,
+ excelColumns,
+ mappingsToSave
+ );
+
+ if (saveResult.success) {
+ console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
+ } else {
+ console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
+ }
+ } catch (error) {
+ console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
+ }
+ };
+
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
@@ -349,16 +818,15 @@ export const ExcelUploadModal: React.FC = ({
setFile(null);
setSheetNames([]);
setSelectedSheet("");
- setAutoCreateColumn(false);
- setSelectedCompany("");
- setSelectedDataType("");
+ setIsAutoMappingLoaded(false);
setDetectedRange("");
- setPreviewData([]);
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
setSystemColumns([]);
setColumnMappings([]);
+ // 🆕 마스터-디테일 모드 초기화
+ setMasterFieldValues({});
}
}, [open]);
@@ -379,19 +847,30 @@ export const ExcelUploadModal: React.FC = ({
엑셀 데이터 업로드
+ {isMasterDetail && (
+
+ 마스터-디테일
+
+ )}
- 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
+ {isMasterDetail && masterDetailRelation ? (
+ <>
+ 마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다.
+ 마스터 데이터는 중복 입력 시 병합됩니다.
+ >
+ ) : (
+ "엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
+ )}
- {/* 스텝 인디케이터 */}
+ {/* 스텝 인디케이터 (3단계) */}
{[
{ num: 1, label: "파일 선택" },
- { num: 2, label: "범위 지정" },
- { num: 3, label: "컬럼 매핑" },
- { num: 4, label: "확인" },
+ { num: 2, label: "컬럼 매핑" },
+ { num: 3, label: "확인" },
].map((step, index) => (
@@ -414,15 +893,13 @@ export const ExcelUploadModal: React.FC = ({
{step.label}
- {index < 3 && (
+ {index < 2 && (
= ({
{/* 스텝별 컨텐츠 */}
- {/* 1단계: 파일 선택 */}
+ {/* 1단계: 파일 선택 & 미리보기 (통합) */}
{currentStep === 1 && (
+ {/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
+ {hasMasterSelectFields && (
+
+ )}
+
+ {/* 파일 선택 영역 */}
-
-
+
fileInputRef.current?.click()}
+ className={cn(
+ "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
+ isDragOver
+ ? "border-primary bg-primary/5"
+ : file
+ ? "border-green-500 bg-green-50"
+ : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50"
+ )}
+ >
+ {file ? (
+
+
+
+
{file.name}
+
+ 클릭하여 다른 파일 선택
+
+
+
+ ) : (
+ <>
+
+
+ {isDragOver
+ ? "파일을 놓으세요"
+ : "파일을 드래그하거나 클릭하여 선택"}
+
+
+ 지원 형식: .xlsx, .xls, .csv
+
+ >
+ )}
= ({
className="hidden"
/>
-
- 지원 형식: .xlsx, .xls, .csv
-
- {sheetNames.length > 0 && (
-
-
-
-
- )}
-
- )}
-
- {/* 2단계: 범위 지정 */}
- {currentStep === 2 && (
-
- {/* 상단: 3개 드롭다운 가로 배치 */}
-
-
-
-
-
-
-
-
- {/* 중간: 체크박스 + 버튼들 한 줄 배치 */}
-
-
- setAutoCreateColumn(checked as boolean)}
- />
-
-
-
-
-
-
-
-
-
-
-
- {/* 하단: 감지된 범위 + 테이블 */}
-
- 감지된 범위: {detectedRange}
-
- 첫 행이 컬럼명, 데이터는 자동 감지됩니다
-
-
-
- {displayData.length > 0 && (
-
-
-
-
- |
-
- |
- {excelColumns.map((col, index) => (
-
- {String.fromCharCode(65 + index)}
- |
- ))}
-
-
-
-
- |
- 1
- |
- {excelColumns.map((col) => (
-
- {col}
- |
- ))}
-
- {displayData.map((row, rowIndex) => (
-
- |
- {rowIndex + 2}
- |
- {excelColumns.map((col) => (
- 0 && (
+ <>
+ {/* 시트 선택 */}
+
+
+
+ |
+ {sheetName}
+
))}
-
- ))}
-
-
-
+
+
+
+
+ {displayData.length}개 행 · 셀을 클릭하여 편집, Tab/Enter로 이동
+
+
+
+ {/* 엑셀처럼 편집 가능한 스프레드시트 */}
+
{
+ setExcelColumns(newColumns);
+ // 범위 재계산
+ const lastCol =
+ newColumns.length > 0
+ ? String.fromCharCode(64 + newColumns.length)
+ : "A";
+ setDetectedRange(`A1:${lastCol}${displayData.length + 1}`);
+ }}
+ onDataChange={(newData) => {
+ setDisplayData(newData);
+ setAllData(newData);
+ // 범위 재계산
+ const lastCol =
+ excelColumns.length > 0
+ ? String.fromCharCode(64 + excelColumns.length)
+ : "A";
+ setDetectedRange(`A1:${lastCol}${newData.length + 1}`);
+ }}
+ maxHeight="320px"
+ />
+ >
)}
)}
- {/* 3단계: 컬럼 매핑 */}
- {currentStep === 3 && (
+ {/* 2단계: 컬럼 매핑 */}
+ {currentStep === 2 && (
{/* 상단: 제목 + 자동 매핑 버튼 */}
@@ -693,9 +1147,12 @@ export const ExcelUploadModal: React.FC
= ({
시스템 컬럼
-
+
{columnMappings.map((mapping, index) => (
-
+
{mapping.excelColumn}
@@ -713,7 +1170,9 @@ export const ExcelUploadModal: React.FC
= ({
{mapping.systemColumn
? (() => {
- const col = systemColumns.find(c => c.name === mapping.systemColumn);
+ const col = systemColumns.find(
+ (c) => c.name === mapping.systemColumn
+ );
return col?.label || mapping.systemColumn;
})()
: "매핑 안함"}
@@ -738,11 +1197,40 @@ export const ExcelUploadModal: React.FC = ({
))}
+
+ {/* 매핑 자동 저장 안내 */}
+ {isAutoMappingLoaded ? (
+
+
+
+
+
이전 매핑이 자동 적용됨
+
+ 동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다.
+ 수정하면 업로드 시 자동 저장됩니다.
+
+
+
+
+ ) : (
+
+
+
+
+
새로운 엑셀 구조
+
+ 이 엑셀 구조는 처음입니다. 매핑을 설정하면 다음에 같은 구조의
+ 엑셀에 자동 적용됩니다.
+
+
+
+
+ )}
)}
- {/* 4단계: 확인 */}
- {currentStep === 4 && (
+ {/* 3단계: 확인 */}
+ {currentStep === 3 && (
업로드 요약
@@ -762,7 +1250,7 @@ export const ExcelUploadModal: React.FC
= ({
모드:{" "}
{uploadMode === "insert"
- ? "삽입"
+ ? "신규 등록"
: uploadMode === "update"
? "업데이트"
: "Upsert"}
@@ -775,12 +1263,17 @@ export const ExcelUploadModal: React.FC = ({
{columnMappings
.filter((m) => m.systemColumn)
- .map((mapping, index) => (
-
- {mapping.excelColumn} →{" "}
- {mapping.systemColumn}
-
- ))}
+ .map((mapping, index) => {
+ const col = systemColumns.find(
+ (c) => c.name === mapping.systemColumn
+ );
+ return (
+
+ {mapping.excelColumn} →{" "}
+ {col?.label || mapping.systemColumn}
+
+ );
+ })}
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
매핑된 컬럼이 없습니다.
)}
@@ -793,7 +1286,8 @@ export const ExcelUploadModal: React.FC
= ({
주의사항
- 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까?
+ 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다.
+ 계속하시겠습니까?
@@ -811,10 +1305,10 @@ export const ExcelUploadModal: React.FC = ({
>
{currentStep === 1 ? "취소" : "이전"}
- {currentStep < 4 ? (
+ {currentStep < 3 ? (
@@ -416,13 +420,21 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
*/
interface FlowEditorProps {
initialFlowId?: number | null;
+ /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
+ onSaveComplete?: (flowId: number, flowName: string) => void;
+ /** 임베디드 모드 여부 (헤더 표시 여부 등) */
+ embedded?: boolean;
}
-export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
+export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
return (
-
+
);
diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx
index d837d355..f136d216 100644
--- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx
+++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx
@@ -17,9 +17,11 @@ import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps {
validations?: FlowValidation[];
+ /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
+ onSaveComplete?: (flowId: number, flowName: string) => void;
}
-export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
+export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
@@ -59,13 +61,27 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const result = await saveFlow();
if (result.success) {
toast({
- title: "✅ 플로우 저장 완료",
+ title: "저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`,
variant: "default",
});
+
+ // 임베디드 모드에서 저장 완료 콜백 호출
+ if (onSaveComplete && result.flowId) {
+ onSaveComplete(result.flowId, flowName);
+ }
+
+ // 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
+ if (window.opener && result.flowId) {
+ window.opener.postMessage({
+ type: "FLOW_SAVED",
+ flowId: result.flowId,
+ flowName: flowName,
+ }, "*");
+ }
} else {
toast({
- title: "❌ 저장 실패",
+ title: "저장 실패",
description: result.message,
variant: "destructive",
});
diff --git a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx
index 5418fcab..4cf5e32d 100644
--- a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx
+++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx
@@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record
= {
NOT_IN: "NOT IN",
IS_NULL: "NULL",
IS_NOT_NULL: "NOT NULL",
+ EXISTS_IN: "EXISTS IN",
+ NOT_EXISTS_IN: "NOT EXISTS IN",
+};
+
+// EXISTS 계열 연산자인지 확인
+const isExistsOperator = (operator: string): boolean => {
+ return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
};
export const ConditionNode = memo(({ data, selected }: NodeProps) => {
@@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps 0 && (
{data.logic}
)}
-
+
{condition.field}
-
+
{OPERATOR_LABELS[condition.operator] || condition.operator}
- {condition.value !== null && condition.value !== undefined && (
-
- {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
+ {/* EXISTS 연산자인 경우 테이블.필드 표시 */}
+ {isExistsOperator(condition.operator) ? (
+
+ {(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
+ {(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
+ ) : (
+ // 일반 연산자인 경우 값 표시
+ condition.value !== null &&
+ condition.value !== undefined && (
+
+ {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
+
+ )
)}
diff --git a/frontend/components/dataflow/node-editor/nodes/FormulaTransformNode.tsx b/frontend/components/dataflow/node-editor/nodes/FormulaTransformNode.tsx
index 981a5002..991e1bd4 100644
--- a/frontend/components/dataflow/node-editor/nodes/FormulaTransformNode.tsx
+++ b/frontend/components/dataflow/node-editor/nodes/FormulaTransformNode.tsx
@@ -28,6 +28,14 @@ const OPERATOR_LABELS: Record = {
"%": "%",
};
+// 피연산자를 문자열로 변환
+function getOperandStr(operand: any): string {
+ if (!operand) return "?";
+ if (operand.type === "static") return String(operand.value || "?");
+ if (operand.fieldLabel) return operand.fieldLabel;
+ return operand.field || operand.resultField || "?";
+}
+
// 수식 요약 생성
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
@@ -35,11 +43,19 @@ function getFormulaSummary(transformation: FormulaTransformNodeData["transformat
switch (formulaType) {
case "arithmetic": {
if (!arithmetic) return "미설정";
- const left = arithmetic.leftOperand;
- const right = arithmetic.rightOperand;
- const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
- const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
- return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
+ const leftStr = getOperandStr(arithmetic.leftOperand);
+ const rightStr = getOperandStr(arithmetic.rightOperand);
+ let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
+
+ // 추가 연산 표시
+ if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
+ for (const addOp of arithmetic.additionalOperations) {
+ const opStr = getOperandStr(addOp.operand);
+ formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
+ }
+ }
+
+ return formula;
}
case "function": {
if (!func) return "미설정";
diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx
index 87f7f771..a2d060d4 100644
--- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx
+++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx
@@ -4,14 +4,18 @@
* 조건 분기 노드 속성 편집
*/
-import { useEffect, useState } from "react";
-import { Plus, Trash2 } from "lucide-react";
+import { useEffect, useState, useCallback } from "react";
+import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
-import type { ConditionNodeData } from "@/types/node-editor";
+import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor";
+import { tableManagementApi } from "@/lib/api/tableManagement";
+import { cn } from "@/lib/utils";
// 필드 정의
interface FieldDefinition {
@@ -20,6 +24,19 @@ interface FieldDefinition {
type?: string;
}
+// 테이블 정보
+interface TableInfo {
+ tableName: string;
+ tableLabel: string;
+}
+
+// 테이블 컬럼 정보
+interface ColumnInfo {
+ columnName: string;
+ columnLabel: string;
+ dataType: string;
+}
+
interface ConditionPropertiesProps {
nodeId: string;
data: ConditionNodeData;
@@ -38,8 +55,194 @@ const OPERATORS = [
{ value: "NOT_IN", label: "NOT IN" },
{ value: "IS_NULL", label: "NULL" },
{ value: "IS_NOT_NULL", label: "NOT NULL" },
+ { value: "EXISTS_IN", label: "다른 테이블에 존재함" },
+ { value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" },
] as const;
+// EXISTS 계열 연산자인지 확인
+const isExistsOperator = (operator: string): boolean => {
+ return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
+};
+
+// 테이블 선택용 검색 가능한 Combobox
+function TableCombobox({
+ tables,
+ value,
+ onSelect,
+ placeholder = "테이블 검색...",
+}: {
+ tables: TableInfo[];
+ value: string;
+ onSelect: (value: string) => void;
+ placeholder?: string;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const selectedTable = tables.find((t) => t.tableName === value);
+
+ return (
+
+
+
+ {selectedTable ? (
+
+ {selectedTable.tableLabel}
+ ({selectedTable.tableName})
+
+ ) : (
+ 테이블 선택
+ )}
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+ {tables.map((table) => (
+ {
+ onSelect(table.tableName);
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+
+ {table.tableLabel}
+ {table.tableName}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// 컬럼 선택용 검색 가능한 Combobox
+function ColumnCombobox({
+ columns,
+ value,
+ onSelect,
+ placeholder = "컬럼 검색...",
+}: {
+ columns: ColumnInfo[];
+ value: string;
+ onSelect: (value: string) => void;
+ placeholder?: string;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const selectedColumn = columns.find((c) => c.columnName === value);
+
+ return (
+
+
+
+ {selectedColumn ? (
+
+ {selectedColumn.columnLabel}
+ ({selectedColumn.columnName})
+
+ ) : (
+ 컬럼 선택
+ )}
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+ {columns.map((col) => (
+ {
+ onSelect(col.columnName);
+ setOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ {col.columnLabel}
+ ({col.columnName})
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// 컬럼 선택 섹션 (자동 로드 포함)
+function ColumnSelectSection({
+ lookupTable,
+ lookupField,
+ tableColumnsCache,
+ loadingColumns,
+ loadTableColumns,
+ onSelect,
+}: {
+ lookupTable: string;
+ lookupField: string;
+ tableColumnsCache: Record;
+ loadingColumns: Record;
+ loadTableColumns: (tableName: string) => Promise;
+ onSelect: (value: string) => void;
+}) {
+ // 캐시에 없고 로딩 중이 아니면 자동으로 로드
+ useEffect(() => {
+ if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) {
+ loadTableColumns(lookupTable);
+ }
+ }, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]);
+
+ const isLoading = loadingColumns[lookupTable];
+ const columns = tableColumnsCache[lookupTable];
+
+ return (
+
+
+ {isLoading ? (
+
+ 컬럼 목록 로딩 중...
+
+ ) : columns && columns.length > 0 ? (
+
+ ) : (
+
+ 컬럼 목록을 로드할 수 없습니다
+
+ )}
+
+ );
+}
+
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
@@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
const [availableFields, setAvailableFields] = useState([]);
+ // EXISTS 연산자용 상태
+ const [allTables, setAllTables] = useState([]);
+ const [tableColumnsCache, setTableColumnsCache] = useState>({});
+ const [loadingTables, setLoadingTables] = useState(false);
+ const [loadingColumns, setLoadingColumns] = useState>({});
+
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || "조건 분기");
@@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
setLogic(data.logic || "AND");
}, [data]);
+ // 전체 테이블 목록 로드 (EXISTS 연산자용)
+ useEffect(() => {
+ const loadAllTables = async () => {
+ // 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵
+ if (allTables.length > 0) return;
+
+ // EXISTS 연산자가 하나라도 있으면 테이블 목록 로드
+ const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator));
+ if (!hasExistsOperator) return;
+
+ setLoadingTables(true);
+ try {
+ const response = await tableManagementApi.getTableList();
+ if (response.success && response.data) {
+ setAllTables(
+ response.data.map((t: any) => ({
+ tableName: t.tableName,
+ tableLabel: t.tableLabel || t.tableName,
+ }))
+ );
+ }
+ } catch (error) {
+ console.error("테이블 목록 로드 실패:", error);
+ } finally {
+ setLoadingTables(false);
+ }
+ };
+
+ loadAllTables();
+ }, [conditions, allTables.length]);
+
+ // 테이블 컬럼 로드 함수
+ const loadTableColumns = useCallback(
+ async (tableName: string): Promise => {
+ // 캐시에 있으면 반환
+ if (tableColumnsCache[tableName]) {
+ return tableColumnsCache[tableName];
+ }
+
+ // 이미 로딩 중이면 스킵
+ if (loadingColumns[tableName]) {
+ return [];
+ }
+
+ // 로딩 상태 설정
+ setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
+
+ try {
+ // getColumnList 반환: { success, data: { columns, total, ... } }
+ const response = await tableManagementApi.getColumnList(tableName);
+ if (response.success && response.data && response.data.columns) {
+ const columns = response.data.columns.map((c: any) => ({
+ columnName: c.columnName,
+ columnLabel: c.columnLabel || c.columnName,
+ dataType: c.dataType,
+ }));
+ setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
+ console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개");
+ return columns;
+ } else {
+ console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response);
+ }
+ } catch (error) {
+ console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error);
+ } finally {
+ setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
+ }
+ return [];
+ },
+ [tableColumnsCache, loadingColumns]
+ );
+
+ // EXISTS 연산자 선택 시 테이블 목록 강제 로드
+ const ensureTablesLoaded = useCallback(async () => {
+ if (allTables.length > 0) return;
+
+ setLoadingTables(true);
+ try {
+ const response = await tableManagementApi.getTableList();
+ if (response.success && response.data) {
+ setAllTables(
+ response.data.map((t: any) => ({
+ tableName: t.tableName,
+ tableLabel: t.tableLabel || t.tableName,
+ }))
+ );
+ }
+ } catch (error) {
+ console.error("테이블 목록 로드 실패:", error);
+ } finally {
+ setLoadingTables(false);
+ }
+ }, [allTables.length]);
+
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
useEffect(() => {
const getAllSourceFields = (currentNodeId: string, visited: Set = new Set()): FieldDefinition[] => {
@@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
}, [nodeId, nodes, edges]);
const handleAddCondition = () => {
- setConditions([
- ...conditions,
- {
- field: "",
- operator: "EQUALS",
- value: "",
- valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
- },
- ]);
+ const newCondition = {
+ field: "",
+ operator: "EQUALS" as ConditionOperator,
+ value: "",
+ valueType: "static" as "static" | "field",
+ // EXISTS 연산자용 필드는 초기값 없음
+ lookupTable: undefined,
+ lookupTableLabel: undefined,
+ lookupField: undefined,
+ lookupFieldLabel: undefined,
+ };
+ setConditions([...conditions, newCondition]);
};
const handleRemoveCondition = (index: number) => {
@@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
});
};
- const handleConditionChange = (index: number, field: string, value: any) => {
+ const handleConditionChange = async (index: number, field: string, value: any) => {
const newConditions = [...conditions];
newConditions[index] = { ...newConditions[index], [field]: value };
+
+ // EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화
+ if (field === "operator" && isExistsOperator(value)) {
+ await ensureTablesLoaded();
+ // EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화
+ newConditions[index].value = "";
+ newConditions[index].valueType = undefined;
+ }
+
+ // EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화
+ if (field === "operator" && !isExistsOperator(value)) {
+ newConditions[index].lookupTable = undefined;
+ newConditions[index].lookupTableLabel = undefined;
+ newConditions[index].lookupField = undefined;
+ newConditions[index].lookupFieldLabel = undefined;
+ }
+
+ // lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정
+ if (field === "lookupTable" && value) {
+ const tableInfo = allTables.find((t) => t.tableName === value);
+ if (tableInfo) {
+ newConditions[index].lookupTableLabel = tableInfo.tableLabel;
+ }
+ // 테이블 변경 시 필드 초기화
+ newConditions[index].lookupField = undefined;
+ newConditions[index].lookupFieldLabel = undefined;
+ // 컬럼 목록 미리 로드
+ await loadTableColumns(value);
+ }
+
+ // lookupField 변경 시 라벨 설정
+ if (field === "lookupField" && value) {
+ const tableName = newConditions[index].lookupTable;
+ if (tableName && tableColumnsCache[tableName]) {
+ const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value);
+ if (columnInfo) {
+ newConditions[index].lookupFieldLabel = columnInfo.columnLabel;
+ }
+ }
+ }
+
setConditions(newConditions);
updateNode(nodeId, {
conditions: newConditions,
@@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
- {condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
+ {/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */}
+ {isExistsOperator(condition.operator) && (
<>
-
-
+
+ {loadingTables ? (
+
+ 테이블 목록 로딩 중...
+
+ ) : allTables.length > 0 ? (
+
handleConditionChange(index, "lookupTable", value)}
+ placeholder="테이블 검색..."
+ />
+ ) : (
+
+ 테이블 목록을 로드할 수 없습니다
+
+ )}
-
-
- {(condition as any).valueType === "field" ? (
- // 필드 참조: 드롭다운으로 선택
- availableFields.length > 0 ? (
-
- ) : (
-
- 소스 노드를 연결하세요
-
- )
- ) : (
- // 고정값: 직접 입력
-
handleConditionChange(index, "value", e.target.value)}
- placeholder="비교할 값"
- className="mt-1 h-8 text-xs"
- />
- )}
+ {(condition as any).lookupTable && (
+
handleConditionChange(index, "lookupField", value)}
+ />
+ )}
+
+
+ {condition.operator === "EXISTS_IN"
+ ? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE`
+ : `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
>
)}
+
+ {/* 일반 연산자인 경우: 기존 비교값 UI */}
+ {condition.operator !== "IS_NULL" &&
+ condition.operator !== "IS_NOT_NULL" &&
+ !isExistsOperator(condition.operator) && (
+ <>
+
+
+
+
+
+
+
+ {(condition as any).valueType === "field" ? (
+ // 필드 참조: 드롭다운으로 선택
+ availableFields.length > 0 ? (
+
+ ) : (
+
+ 소스 노드를 연결하세요
+
+ )
+ ) : (
+ // 고정값: 직접 입력
+
handleConditionChange(index, "value", e.target.value)}
+ placeholder="비교할 값"
+ className="mt-1 h-8 text-xs"
+ />
+ )}
+
+ >
+ )}
))}
@@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
{/* 안내 */}
- 🔌 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
+ 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
- 🔄 비교 값 타입:
• 고정값: 직접 입력한 값과 비교 (예: age > 30)
-
• 필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
+ 비교 값 타입:
+ - 고정값: 직접 입력한 값과 비교 (예: age > 30)
+
- 필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
+
+
+ 테이블 존재 여부 검사:
+ - 다른 테이블에 존재함: 값이 다른 테이블에 있으면 TRUE
+
- 다른 테이블에 존재하지 않음: 값이 다른 테이블에 없으면 TRUE
+
+ (예: 품명이 품목정보 테이블에 없으면 자동 등록)
- 💡 AND: 모든 조건이 참이어야 TRUE 출력
+ AND: 모든 조건이 참이어야 TRUE 출력
- 💡 OR: 하나라도 참이면 TRUE 출력
+ OR: 하나라도 참이면 TRUE 출력
- ⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
+ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx
index 16eca3cd..b30bc1f4 100644
--- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx
+++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx
@@ -4,7 +4,7 @@
* DELETE 액션 노드 속성 편집
*/
-import { useEffect, useState } from "react";
+import { useEffect, useState, useCallback } from "react";
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@@ -24,6 +24,12 @@ interface DeleteActionPropertiesProps {
data: DeleteActionNodeData;
}
+// 소스 필드 타입
+interface SourceField {
+ name: string;
+ label?: string;
+}
+
const OPERATORS = [
{ value: "EQUALS", label: "=" },
{ value: "NOT_EQUALS", label: "≠" },
@@ -34,7 +40,7 @@ const OPERATORS = [
] as const;
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
- const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
+ const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore();
// 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
@@ -43,6 +49,10 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
const [targetTable, setTargetTable] = useState(data.targetTable);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
+ // 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기)
+ const [sourceFields, setSourceFields] = useState
([]);
+ const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState([]);
+
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
@@ -124,8 +134,106 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
// whereConditions 변경 시 fieldOpenState 초기화
useEffect(() => {
setFieldOpenState(new Array(whereConditions.length).fill(false));
+ setSourceFieldsOpenState(new Array(whereConditions.length).fill(false));
}, [whereConditions.length]);
+ // 🆕 소스 필드 로딩 (연결된 입력 노드에서)
+ const loadSourceFields = useCallback(async () => {
+ // 현재 노드로 연결된 엣지 찾기
+ const incomingEdges = edges.filter((e) => e.target === nodeId);
+ console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges);
+
+ if (incomingEdges.length === 0) {
+ console.log("⚠️ 연결된 소스 노드가 없습니다");
+ setSourceFields([]);
+ return;
+ }
+
+ const fields: SourceField[] = [];
+ const processedFields = new Set();
+
+ for (const edge of incomingEdges) {
+ const sourceNode = nodes.find((n) => n.id === edge.source);
+ if (!sourceNode) continue;
+
+ console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data);
+
+ // 소스 노드 타입에 따라 필드 추출
+ if (sourceNode.type === "trigger" && sourceNode.data.tableName) {
+ // 트리거 노드: 테이블 컬럼 조회
+ try {
+ const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
+ if (columns && Array.isArray(columns)) {
+ columns.forEach((col: any) => {
+ const colName = col.columnName || col.column_name;
+ if (!processedFields.has(colName)) {
+ processedFields.add(colName);
+ fields.push({
+ name: colName,
+ label: col.columnLabel || col.column_label || colName,
+ });
+ }
+ });
+ }
+ } catch (error) {
+ console.error("트리거 노드 컬럼 로딩 실패:", error);
+ }
+ } else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) {
+ // 테이블 소스 노드
+ try {
+ const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
+ if (columns && Array.isArray(columns)) {
+ columns.forEach((col: any) => {
+ const colName = col.columnName || col.column_name;
+ if (!processedFields.has(colName)) {
+ processedFields.add(colName);
+ fields.push({
+ name: colName,
+ label: col.columnLabel || col.column_label || colName,
+ });
+ }
+ });
+ }
+ } catch (error) {
+ console.error("테이블 소스 노드 컬럼 로딩 실패:", error);
+ }
+ } else if (sourceNode.type === "condition") {
+ // 조건 노드: 연결된 이전 노드에서 필드 가져오기
+ const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id);
+ for (const condEdge of conditionIncomingEdges) {
+ const condSourceNode = nodes.find((n) => n.id === condEdge.source);
+ if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) {
+ try {
+ const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName);
+ if (columns && Array.isArray(columns)) {
+ columns.forEach((col: any) => {
+ const colName = col.columnName || col.column_name;
+ if (!processedFields.has(colName)) {
+ processedFields.add(colName);
+ fields.push({
+ name: colName,
+ label: col.columnLabel || col.column_label || colName,
+ });
+ }
+ });
+ }
+ } catch (error) {
+ console.error("조건 노드 소스 컬럼 로딩 실패:", error);
+ }
+ }
+ }
+ }
+ }
+
+ console.log("✅ DELETE 노드 소스 필드:", fields);
+ setSourceFields(fields);
+ }, [nodeId, nodes, edges]);
+
+ // 소스 필드 로딩
+ useEffect(() => {
+ loadSourceFields();
+ }, [loadSourceFields]);
+
const loadExternalConnections = async () => {
try {
setExternalConnectionsLoading(true);
@@ -239,22 +347,41 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
field: "",
operator: "EQUALS",
value: "",
+ sourceField: undefined,
+ staticValue: undefined,
},
];
setWhereConditions(newConditions);
setFieldOpenState(new Array(newConditions.length).fill(false));
+ setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
+
+ // 자동 저장
+ updateNode(nodeId, {
+ whereConditions: newConditions,
+ });
};
const handleRemoveCondition = (index: number) => {
const newConditions = whereConditions.filter((_, i) => i !== index);
setWhereConditions(newConditions);
setFieldOpenState(new Array(newConditions.length).fill(false));
+ setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
+
+ // 자동 저장
+ updateNode(nodeId, {
+ whereConditions: newConditions,
+ });
};
const handleConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...whereConditions];
newConditions[index] = { ...newConditions[index], [field]: value };
setWhereConditions(newConditions);
+
+ // 자동 저장
+ updateNode(nodeId, {
+ whereConditions: newConditions,
+ });
};
const handleSave = () => {
@@ -840,14 +967,125 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
+ {/* 🆕 소스 필드 - Combobox */}
-
+
+ {sourceFields.length > 0 ? (
+
{
+ const newState = [...sourceFieldsOpenState];
+ newState[index] = open;
+ setSourceFieldsOpenState(newState);
+ }}
+ >
+
+
+ {condition.sourceField
+ ? (() => {
+ const field = sourceFields.find((f) => f.name === condition.sourceField);
+ return (
+
+
+ {field?.label || condition.sourceField}
+
+ {field?.label && field.label !== field.name && (
+ {field.name}
+ )}
+
+ );
+ })()
+ : "소스 필드 선택 (선택)"}
+
+
+
+
+
+
+
+ 필드를 찾을 수 없습니다.
+
+ {
+ handleConditionChange(index, "sourceField", undefined);
+ const newState = [...sourceFieldsOpenState];
+ newState[index] = false;
+ setSourceFieldsOpenState(newState);
+ }}
+ className="text-xs text-gray-400 sm:text-sm"
+ >
+
+ 없음 (정적 값 사용)
+
+ {sourceFields.map((field) => (
+ {
+ handleConditionChange(index, "sourceField", currentValue);
+ const newState = [...sourceFieldsOpenState];
+ newState[index] = false;
+ setSourceFieldsOpenState(newState);
+ }}
+ className="text-xs sm:text-sm"
+ >
+
+
+ {field.label || field.name}
+ {field.label && field.label !== field.name && (
+
+ {field.name}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ ) : (
+
+ 연결된 소스 노드가 없습니다
+
+ )}
+
소스 데이터에서 값을 가져올 필드
+
+
+ {/* 정적 값 */}
+
+
handleConditionChange(index, "value", e.target.value)}
- placeholder="비교 값"
+ value={condition.staticValue || condition.value || ""}
+ onChange={(e) => {
+ handleConditionChange(index, "staticValue", e.target.value || undefined);
+ handleConditionChange(index, "value", e.target.value);
+ }}
+ placeholder="비교할 고정 값"
className="mt-1 h-8 text-xs"
/>
+
소스 필드가 비어있을 때 사용됩니다
diff --git a/frontend/components/dataflow/node-editor/panels/properties/FormulaTransformProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/FormulaTransformProperties.tsx
index fc2fbdf8..d9a9a20c 100644
--- a/frontend/components/dataflow/node-editor/panels/properties/FormulaTransformProperties.tsx
+++ b/frontend/components/dataflow/node-editor/panels/properties/FormulaTransformProperties.tsx
@@ -797,6 +797,85 @@ export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPro
index,
)}