diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index e3d97088..bf7426e5 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -217,10 +217,16 @@ export default function TableManagementPage() { // 메모이제이션된 입력타입 옵션 const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []); - // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) + // 참조 테이블 옵션 (한글라벨 (영어명) 동시 표시) const referenceTableOptions = [ { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") }, - ...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })), + ...tables.map((table) => ({ + value: table.tableName, + label: + table.displayName && table.displayName !== table.tableName + ? `${table.displayName} (${table.tableName})` + : table.tableName, + })), ]; // 공통 코드 카테고리 목록 상태 @@ -1610,6 +1616,8 @@ export default function TableManagementPage() { onIndexToggle={(columnName, checked) => handleIndexToggle(columnName, "index", checked) } + tables={tables} + referenceTableColumns={referenceTableColumns} /> )} @@ -1809,11 +1817,16 @@ export default function TableManagementPage() {

변경될 PK 컬럼:

{pendingPkColumns.length > 0 ? (
- {pendingPkColumns.map((col) => ( - - {col} - - ))} + {pendingPkColumns.map((col) => { + const colInfo = columns.find((c) => c.columnName === col); + return ( + + {colInfo?.displayName && colInfo.displayName !== col + ? `${colInfo.displayName} (${col})` + : col} + + ); + })}
) : (

PK가 모두 제거됩니다

diff --git a/frontend/components/admin/CompanySwitcher.tsx b/frontend/components/admin/CompanySwitcher.tsx index c37d82a5..23445780 100644 --- a/frontend/components/admin/CompanySwitcher.tsx +++ b/frontend/components/admin/CompanySwitcher.tsx @@ -174,6 +174,8 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp ? "bg-accent/50 font-semibold" : "" }`} + role="button" + aria-label={`${company.company_name} ${company.company_code}`} onClick={() => handleCompanySwitch(company.company_code)} >
diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index 1d053775..0d770dc9 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -76,9 +76,34 @@ export function ColumnDetailPanel({ if (!column) return null; - const refTableOpts = referenceTableOptions.length - ? referenceTableOptions - : [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))]; + const refTableOpts = useMemo(() => { + const hasKorean = (s: string) => /[가-힣]/.test(s); + const raw = referenceTableOptions.length + ? [...referenceTableOptions] + : [ + { value: "none", label: "없음" }, + ...tables.map((t) => ({ + value: t.tableName, + label: + t.displayName && t.displayName !== t.tableName + ? `${t.displayName} (${t.tableName})` + : t.tableName, + })), + ]; + + const noneOpt = raw.find((o) => o.value === "none"); + const rest = raw.filter((o) => o.value !== "none"); + + rest.sort((a, b) => { + const aK = hasKorean(a.label); + const bK = hasKorean(b.label); + if (aK && !bK) return -1; + if (!aK && bK) return 1; + return a.label.localeCompare(b.label, "ko"); + }); + + return noneOpt ? [noneOpt, ...rest] : rest; + }, [referenceTableOptions, tables]); return (
@@ -90,7 +115,11 @@ export function ColumnDetailPanel({ {typeConf.label} )} - {column.columnName} + + {column.displayName && column.displayName !== column.columnName + ? `${column.displayName} (${column.columnName})` + : column.columnName} +
@@ -245,7 +289,14 @@ export function ColumnDetailPanel({ column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0", )} /> - {refCol.columnName} + {refCol.displayName && refCol.displayName !== refCol.columnName ? ( +
+ {refCol.displayName} + {refCol.columnName} +
+ ) : ( + {refCol.columnName} + )} ))} @@ -259,12 +310,20 @@ export function ColumnDetailPanel({ {/* 참조 요약 미니맵 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
- - {column.referenceTable} + + {(() => { + const tbl = refTableOpts.find((o) => o.value === column.referenceTable); + return tbl?.label ?? column.referenceTable; + })()} - - {column.referenceColumn} + + {(() => { + const col = refColumns.find((c) => c.columnName === column.referenceColumn); + return col?.displayName && col.displayName !== column.referenceColumn + ? `${col.displayName} (${column.referenceColumn})` + : column.referenceColumn; + })()}
)} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index c03c7516..825dbd36 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -5,8 +5,9 @@ import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import type { ColumnTypeInfo } from "./types"; +import type { ColumnTypeInfo, TableInfo } from "./types"; import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; +import type { ReferenceTableColumn } from "@/lib/api/entityJoin"; export interface ColumnGridConstraints { primaryKey: { columns: string[] }; @@ -23,6 +24,9 @@ export interface ColumnGridProps { getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; onPkToggle?: (columnName: string, checked: boolean) => void; onIndexToggle?: (columnName: string, checked: boolean) => void; + /** 호버 시 한글 라벨 표시용 (Badge title) */ + tables?: TableInfo[]; + referenceTableColumns?: Record; } function getIndexState( @@ -53,6 +57,8 @@ export function ColumnGrid({ getColumnIndexState: externalGetIndexState, onPkToggle, onIndexToggle, + tables, + referenceTableColumns, }: ColumnGridProps) { const getIdxState = useMemo( () => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)), @@ -136,13 +142,12 @@ export function ColumnGrid({ {/* 4px 색상바 (타입별 진한 색) */}
- {/* 라벨 + 컬럼명 */} + {/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
- {column.displayName || column.columnName} -
-
- {column.columnName} + {column.displayName && column.displayName !== column.columnName + ? `${column.displayName} (${column.columnName})` + : column.columnName}
@@ -150,11 +155,38 @@ export function ColumnGrid({
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && ( <> - + { + const t = tables.find((tb) => tb.tableName === column.referenceTable); + return t?.displayName && t.displayName !== t.tableName + ? `${t.displayName} (${column.referenceTable})` + : column.referenceTable; + })() + : column.referenceTable + } + > {column.referenceTable} - + { + const refCols = referenceTableColumns[column.referenceTable]; + const c = refCols.find((rc) => rc.columnName === (column.referenceColumn ?? "")); + return c?.displayName && c.displayName !== c.columnName + ? `${c.displayName} (${column.referenceColumn})` + : column.referenceColumn ?? "—"; + })() + : column.referenceColumn ?? "—" + } + > {column.referenceColumn || "—"} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index c80cb581..2014d535 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, Suspense, useEffect } from "react"; +import { useState, Suspense, useEffect, useCallback } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { @@ -341,6 +341,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { const currentMenus = isAdminMode ? adminMenus : userMenus; + const currentTabs = useTabStore((s) => s[s.mode].tabs); + const currentActiveTabId = useTabStore((s) => s[s.mode].activeTabId); + const activeTab = currentTabs.find((t) => t.id === currentActiveTabId); + const toggleMenu = (menuId: string) => { const newExpanded = new Set(expandedMenus); if (newExpanded.has(menuId)) { @@ -478,6 +482,26 @@ function AppLayoutInner({ children }: AppLayoutProps) { } }; + // pathname + 활성 탭 기반 활성 메뉴 판별 (탭 네비게이션에서도 사이드바 활성 표시) + const isMenuActive = useCallback( + (menu: any): boolean => { + if (pathname === menu.url) return true; + if (!activeTab) return false; + + const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); + + if (activeTab.type === "admin" && activeTab.adminUrl) { + return menu.url === activeTab.adminUrl; + } + if (activeTab.type === "screen") { + if (activeTab.menuObjid != null && menuObjid === activeTab.menuObjid) return true; + if (activeTab.screenId != null && menu.screenId === activeTab.screenId) return true; + } + return false; + }, + [pathname, activeTab], + ); + // 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용) const renderMenu = (menu: any, level: number = 0) => { const isExpanded = expandedMenus.has(menu.id); @@ -489,8 +513,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { draggable={isLeaf} onDragStart={(e) => handleMenuDragStart(e, menu)} className={`group flex min-h-[44px] cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 ease-in-out sm:min-h-[40px] ${ - pathname === menu.url - ? "border-primary bg-primary/8 text-primary border-l-3 font-semibold" + isMenuActive(menu) + ? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold" : isExpanded ? "bg-accent/60 text-foreground" : "text-muted-foreground hover:bg-accent hover:text-foreground" @@ -518,8 +542,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { draggable={!child.hasChildren} onDragStart={(e) => handleMenuDragStart(e, child)} className={`flex min-h-[44px] cursor-pointer items-center rounded-md px-3 py-2 text-sm transition-colors duration-150 hover:cursor-pointer sm:min-h-[40px] ${ - pathname === child.url - ? "border-primary bg-primary/8 text-primary border-l-3 font-semibold" + isMenuActive(child) + ? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold" : "text-muted-foreground hover:bg-accent hover:text-foreground" }`} onClick={() => handleMenuClick(child)} @@ -544,6 +568,30 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } + const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : []; + + // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 + useEffect(() => { + if (!activeTab || uiMenus.length === 0) return; + + const toExpand: string[] = []; + for (const menu of uiMenus) { + if (menu.hasChildren && menu.children) { + const hasActiveChild = menu.children.some((child: any) => isMenuActive(child)); + if (hasActiveChild && !expandedMenus.has(menu.id)) { + toExpand.push(menu.id); + } + } + } + if (toExpand.length > 0) { + setExpandedMenus((prev) => { + const next = new Set(prev); + toExpand.forEach((id) => next.add(id)); + return next; + }); + } + }, [activeTab, uiMenus, isMenuActive, expandedMenus]); + if (!user) { return (
@@ -555,8 +603,6 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } - const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); - return (
{/* 모바일 헤더 */} diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx index e86ada2e..1ac5144e 100644 --- a/frontend/components/layout/TabBar.tsx +++ b/frontend/components/layout/TabBar.tsx @@ -493,8 +493,8 @@ export function TabBar() { className={cn( "group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none", isActive - ? "text-foreground z-10 -mb-px h-[30px] bg-white" - : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent", + ? "text-primary z-10 -mb-px h-[30px] bg-primary/15 dark:bg-primary/20 border-primary/40 border-t-[3px] border-t-primary font-semibold" + : "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent", )} style={{ width: TAB_WIDTH, diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index ac4d2ffa..0586006a 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -478,7 +478,7 @@ const DateConfigPanel: React.FC = ({ {sourceTableName && columns.length === 0 && !loadingColumns && ( -

+

이 테이블에 날짜 타입 컬럼이 없습니다

)} diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index e3dbc3ab..6a941eda 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -1,7 +1,6 @@ "use client"; import React from "react"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -27,25 +26,24 @@ export const NumberingRuleCard: React.FC = ({ tableName, }) => { return ( - - -
- - 규칙 {part.order} - - -
-
+
+
+ + 규칙 {part.order} + + +
- +
setColumnSearch(e.target.value)} - placeholder="검색..." - className="h-8 text-xs" - /> - -
- {loading && numberingColumns.length === 0 ? ( -
-

로딩 중...

+
+ {/* 좌측: 규칙 리스트 (code-nav, 220px) */} +
+
+
+ + 채번 규칙 ({rulesList.length}) +
+ +
+
+ {loading && rulesList.length === 0 ? ( +
+ 로딩 중...
- ) : filteredGroups.length === 0 ? ( -
-

- {numberingColumns.length === 0 - ? "채번 타입 컬럼이 없습니다" - : "검색 결과가 없습니다"} -

+ ) : rulesList.length === 0 ? ( +
+ 규칙이 없습니다
) : ( - filteredGroups.map(([tableName, group]) => ( -
-
- - {group.tableLabel} - ({group.columns.length}) -
- {group.columns.map((col) => { - const isSelected = - selectedColumn?.tableName === col.tableName && - selectedColumn?.columnName === col.columnName; - return ( -
handleSelectColumn(col.tableName, col.columnName)} - > - {col.columnLabel} -
- ); - })} -
- )) + rulesList.map((rule) => { + const isSelected = selectedRuleId === rule.ruleId; + return ( + + ); + }) )}
- {/* 구분선 */} -
- - {/* 우측: 편집 영역 */} -
+ {/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */} +
{!currentRule ? ( -
-
- -

컬럼을 선택해주세요

-

좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다

-
+
+ +

규칙을 선택하세요

+

+ 좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요 +

) : ( <> -
- {editingRightTitle ? ( - setRightTitle(e.target.value)} - onBlur={() => setEditingRightTitle(false)} - onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)} - className="h-8 text-sm font-semibold" - autoFocus - /> - ) : ( -

{rightTitle}

- )} - +
+ + setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))} + placeholder="예: 프로젝트 코드" + className="h-9 text-sm" + />
-
- {/* 첫 번째 줄: 규칙명 + 미리보기 */} -
-
- - setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} - className="h-9" - placeholder="예: 프로젝트 코드" - /> -
-
- - -
-
- - + {/* 큰 미리보기 스트립 (code-preview-strip) */} +
+
-
-
-

코드 구성

- + {/* 파이프라인 영역 (code-pipeline-area) */} +
+
+ 코드 구성 + {currentRule.parts.length}/{maxRules}
- - {currentRule.parts.length === 0 ? ( -
-

규칙을 추가하여 코드를 구성하세요

-
- ) : ( -
- {currentRule.parts.map((part, index) => ( - -
- handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - tableName={selectedColumn?.tableName} - /> - {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} - {index < currentRule.parts.length - 1 && ( -
- 뒤 구분자 - - {separatorTypes[part.order] === "custom" && ( - handlePartCustomSeparatorChange(part.order, e.target.value)} - className="h-6 w-14 text-center text-[10px]" - placeholder="2자" - maxLength={2} - /> +
+ {currentRule.parts.length === 0 ? ( +
+ 규칙을 추가하여 코드를 구성하세요 +
+ ) : ( + <> + {currentRule.parts.map((part, index) => { + const item = partItems.find((i) => i.order === part.order); + const sep = part.separatorAfter ?? globalSep; + const isSelected = selectedPartOrder === part.order; + const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType; + return ( + +
- - ))} -
- )} + onClick={() => setSelectedPartOrder(part.order)} + > +
+ {typeLabel} +
+
+ {item?.displayValue ?? "-"} +
+ + {index < currentRule.parts.length - 1 && ( +
+ + + {sep || "-"} + +
+ )} + + ); + })} + + + )} +
-
+ {/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */} + {selectedPart && ( +
+
+ handleUpdatePart(selectedPart.order, updates)} + onDelete={() => handleDeletePart(selectedPart.order)} + isPreview={isPreview} + tableName={currentRule.tableName ?? currentTableName} + /> +
+ {currentRule.parts.some((p) => p.order === selectedPart.order) && ( +
+ 뒤 구분자 + + {separatorTypes[selectedPart.order] === "custom" && ( + handlePartCustomSeparatorChange(selectedPart.order, e.target.value)} + className="h-7 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+ )} + + {/* 저장 바 (code-save-bar) */} +
+
+ {currentRule.tableName && ( + 테이블: {currentRule.tableName} + )} + {currentRule.columnName && ( + 컬럼: {currentRule.columnName} + )} + 구분자: {globalSep || "-"} + {currentRule.resetPeriod && currentRule.resetPeriod !== "none" && ( + 리셋: {currentRule.resetPeriod} + )} +
-
diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index eff551a1..6a7f9732 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -1,88 +1,163 @@ "use client"; import React, { useMemo } from "react"; -import { NumberingRuleConfig } from "@/types/numbering-rule"; +import { cn } from "@/lib/utils"; +import { NumberingRuleConfig, NumberingRulePart, CodePartType } from "@/types/numbering-rule"; +import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule"; + +/** 파트별 표시값 + 타입 (미리보기 스트립/세그먼트용) */ +export interface PartDisplayItem { + partType: CodePartType; + displayValue: string; + order: number; +} + +/** config에서 파트별 표시값 배열 계산 (정렬된 parts 기준) */ +export function computePartDisplayItems(config: NumberingRuleConfig): PartDisplayItem[] { + if (!config.parts || config.parts.length === 0) return []; + const sorted = [...config.parts].sort((a, b) => a.order - b.order); + const globalSep = config.separator ?? "-"; + return sorted.map((part) => ({ + order: part.order, + partType: part.partType, + displayValue: getPartDisplayValue(part), + })); +} + +function getPartDisplayValue(part: NumberingRulePart): string { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + const c = part.autoConfig || {}; + switch (part.partType) { + case "sequence": + return String(c.startFrom ?? 1).padStart(c.sequenceLength ?? 3, "0"); + case "number": + return String(c.numberValue ?? 0).padStart(c.numberLength ?? 4, "0"); + case "date": { + const format = c.dateFormat || "YYYYMMDD"; + if (c.useColumnValue && c.sourceColumnName) { + return format === "YYYY" ? "[YYYY]" : format === "YY" ? "[YY]" : format === "YYYYMM" ? "[YYYYMM]" : format === "YYMM" ? "[YYMM]" : format === "YYMMDD" ? "[YYMMDD]" : "[DATE]"; + } + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + if (format === "YYYY") return String(y); + if (format === "YY") return String(y).slice(-2); + if (format === "YYYYMM") return `${y}${m}`; + if (format === "YYMM") return `${String(y).slice(-2)}${m}`; + if (format === "YYYYMMDD") return `${y}${m}${d}`; + if (format === "YYMMDD") return `${String(y).slice(-2)}${m}${d}`; + return `${y}${m}${d}`; + } + case "text": + return c.textValue || "TEXT"; + default: + return "XXX"; + } +} + +/** 파트 타입별 미리보기용 텍스트 색상 클래스 (CSS 변수 기반) */ +export function getPartTypeColorClass(partType: CodePartType): string { + switch (partType) { + case "date": + return "text-warning"; + case "text": + return "text-primary"; + case "sequence": + return "text-primary"; + case "number": + return "text-muted-foreground"; + case "category": + case "reference": + return "text-muted-foreground"; + default: + return "text-foreground"; + } +} + +/** 파트 타입별 점(dot) 배경 색상 (범례용) */ +export function getPartTypeDotClass(partType: CodePartType): string { + switch (partType) { + case "date": + return "bg-warning"; + case "text": + case "sequence": + return "bg-primary"; + case "number": + case "category": + case "reference": + return "bg-muted-foreground"; + default: + return "bg-foreground"; + } +} interface NumberingRulePreviewProps { config: NumberingRuleConfig; compact?: boolean; + /** 큰 미리보기 스트립: 28px, 파트별 색상, 하단 범례 */ + variant?: "default" | "strip"; } export const NumberingRulePreview: React.FC = ({ config, - compact = false + compact = false, + variant = "default", }) => { + const partItems = useMemo(() => computePartDisplayItems(config), [config]); + const sortedParts = useMemo( + () => (config.parts ? [...config.parts].sort((a, b) => a.order - b.order) : []), + [config.parts] + ); const generatedCode = useMemo(() => { - if (!config.parts || config.parts.length === 0) { - return "규칙을 추가해주세요"; - } - - const sortedParts = config.parts.sort((a, b) => a.order - b.order); - - const partValues = sortedParts.map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; - } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - return String(startFrom).padStart(length, "0"); - } - case "number": { - const length = autoConfig.numberLength || 4; - const value = autoConfig.numberValue || 0; - return String(value).padStart(length, "0"); - } - case "date": { - const format = autoConfig.dateFormat || "YYYYMMDD"; - if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { - switch (format) { - case "YYYY": return "[YYYY]"; - case "YY": return "[YY]"; - case "YYYYMM": return "[YYYYMM]"; - case "YYMM": return "[YYMM]"; - case "YYYYMMDD": return "[YYYYMMDD]"; - case "YYMMDD": return "[YYMMDD]"; - default: return "[DATE]"; - } - } - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; - } - } - case "text": - return autoConfig.textValue || "TEXT"; - default: - return "XXX"; - } - }); - - // 파트별 개별 구분자로 결합 + if (partItems.length === 0) return "규칙을 추가해주세요"; const globalSep = config.separator ?? "-"; let result = ""; - partValues.forEach((val, idx) => { - result += val; - if (idx < partValues.length - 1) { - const sep = sortedParts[idx].separatorAfter ?? globalSep; - result += sep; + partItems.forEach((item, idx) => { + result += item.displayValue; + if (idx < partItems.length - 1) { + const part = sortedParts.find((p) => p.order === item.order); + result += part?.separatorAfter ?? globalSep; } }); return result; - }, [config]); + }, [config.separator, partItems, sortedParts]); + + if (variant === "strip") { + const globalSep = config.separator ?? "-"; + return ( +
+
+ {partItems.length === 0 ? ( + 규칙을 추가해주세요 + ) : ( + partItems.map((item, idx) => ( + + {item.displayValue} + {idx < partItems.length - 1 && ( + + {sortedParts.find((p) => p.order === item.order)?.separatorAfter ?? globalSep} + + )} + + )) + )} +
+ {partItems.length > 0 && ( +
+ {CODE_PART_TYPE_OPTIONS.filter((opt) => partItems.some((p) => p.partType === opt.value)).map((opt) => ( + + + {opt.label} + + ))} +
+ )} +
+ ); + } if (compact) { return ( diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 51a9e34e..87579061 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -362,15 +362,15 @@ const RealtimePreviewDynamicComponent: React.FC = ({ // 런타임 모드에서 컴포넌트 타입별 높이 처리 if (!isDesignMode) { const compType = (component as any).componentType || component.componentConfig?.type || ""; - // 테이블: 부모 flex 컨테이너가 높이 관리 (flex: 1) - const flexGrowTypes = [ + // 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리) + const fillParentTypes = [ "table-list", "v2-table-list", "split-panel-layout", "split-panel-layout2", "v2-split-panel-layout", "screen-split-panel", "v2-tab-container", "tab-container", "tabs-widget", "v2-tabs-widget", ]; - if (flexGrowTypes.some(t => compType === t)) { + if (fillParentTypes.some(t => compType === t)) { return "100%"; } const autoHeightTypes = [ diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index 1322ee99..47a2cd52 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -23,8 +23,9 @@ function getComponentTypeId(component: ComponentData): string { } /** - * 디자이너 절대좌표를 캔버스 대비 비율로 변환하여 렌더링. - * 화면이 줄어들면 비율에 맞게 축소, 늘어나면 확대. + * 디자이너 절대좌표를 캔버스 대비 비율(%)로 변환하여 렌더링. + * 가로: 컨테이너 너비 대비 % → 반응형 스케일 + * 세로: 컨테이너 높이 대비 % → 뷰포트에 맞게 자동 조절 */ function ProportionalRenderer({ components, @@ -47,19 +48,12 @@ function ProportionalRenderer({ }, []); const topLevel = components.filter((c) => !c.parentId); - const ratio = containerW > 0 ? containerW / canvasWidth : 1; - - const maxBottom = topLevel.reduce((max, c) => { - const bottom = c.position.y + (c.size?.height || 40); - return Math.max(max, bottom); - }, 0); return (
0 ? `${maxBottom * ratio}px` : "200px" }} + className="bg-background relative h-full w-full overflow-hidden" > {containerW > 0 && topLevel.map((component) => { @@ -72,9 +66,9 @@ function ProportionalRenderer({ style={{ position: "absolute", left: `${(component.position.x / canvasWidth) * 100}%`, - top: `${component.position.y * ratio}px`, + top: `${(component.position.y / canvasHeight) * 100}%`, width: `${((component.size?.width || 100) / canvasWidth) * 100}%`, - height: `${(component.size?.height || 40) * ratio}px`, + height: `${((component.size?.height || 40) / canvasHeight) * 100}%`, zIndex: component.position.z || 1, }} > diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index ef739b27..a18f3bda 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -17,7 +17,6 @@ import { GroupComponent, DataTableComponent, TableInfo, - LayoutComponent, FileComponent, AreaComponent, } from "@/types/screen"; @@ -47,7 +46,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; -import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; +import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -98,6 +97,24 @@ export const V2PropertiesPanel: React.FC = ({ // 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용) const [allTables, setAllTables] = useState>([]); + // 🆕 선택된 컴포넌트의 테이블에 대한 columnMeta 캐시가 비어 있으면 로드 후 재렌더 + const [columnMetaVersion, setColumnMetaVersion] = useState(0); + useEffect(() => { + if (!selectedComponent) return; + const tblName = + (selectedComponent as any).tableName || + currentTable?.tableName || + tables?.[0]?.tableName; + if (!tblName) return; + if (columnMetaCache[tblName]) return; + loadColumnMeta(tblName).then(() => setColumnMetaVersion((v) => v + 1)); + }, [ + selectedComponent?.id, + (selectedComponent as any)?.tableName, + currentTable?.tableName, + tables?.[0]?.tableName, + ]); + // 🆕 전체 테이블 목록 로드 useEffect(() => { const loadAllTables = async () => { @@ -211,20 +228,20 @@ export const V2PropertiesPanel: React.FC = ({ // 현재 화면의 테이블명 가져오기 const currentTableName = tables?.[0]?.tableName; - // DB input_type 가져오기 (columnMetaCache에서 최신값 조회) - const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; - const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + // DB input_type만 조회 (saved config와 분리하여 전달) + const colName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName; + const tblName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined; const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined; - const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; // 컴포넌트별 추가 props const extraProps: Record = {}; - const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; + const resolvedColumnName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName; if (componentId === "v2-input" || componentId === "v2-select") { - extraProps.inputType = inputType; + extraProps.componentType = componentId; + extraProps.inputType = dbInputType; extraProps.tableName = resolvedTableName; extraProps.columnName = resolvedColumnName; extraProps.screenTableName = resolvedTableName; @@ -256,7 +273,7 @@ export const V2PropertiesPanel: React.FC = ({ const currentConfig = selectedComponent.componentConfig || {}; // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) - const config = currentConfig || definition.defaultProps?.componentConfig || {}; + const config = currentConfig || (definition as any).defaultProps?.componentConfig || {}; const handlePanelConfigChange = (newConfig: any) => { // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합 @@ -282,14 +299,14 @@ export const V2PropertiesPanel: React.FC = ({ onConfigChange={handlePanelConfigChange} tables={tables} allTables={allTables} - screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} - tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} + screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} + tableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} columnName={ (selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName } inputType={(selectedComponent as any).inputType || currentConfig?.inputType} componentType={componentType} - tableColumns={currentTable?.columns || []} + tableColumns={(currentTable as any)?.columns || []} allComponents={allComponents} currentComponent={selectedComponent} menuObjid={menuObjid} @@ -323,8 +340,8 @@ export const V2PropertiesPanel: React.FC = ({ componentType={componentType} config={selectedComponent.componentConfig || {}} onChange={handleDynamicConfigChange} - screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} - tableColumns={currentTable?.columns || []} + screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} + tableColumns={(currentTable as any)?.columns || []} tables={tables} menuObjid={menuObjid} allComponents={allComponents} @@ -491,7 +508,7 @@ export const V2PropertiesPanel: React.FC = ({ 제목
handleUpdate("title", e.target.value)} placeholder="제목" className="h-7 text-xs" @@ -503,7 +520,7 @@ export const V2PropertiesPanel: React.FC = ({ 설명
handleUpdate("description", e.target.value)} placeholder="설명" className="h-7 text-xs" @@ -519,9 +536,9 @@ export const V2PropertiesPanel: React.FC = ({

OPTIONS

{(isInputField || widget.required !== undefined) && (() => { - const colName = widget.columnName || selectedComponent?.columnName; + const colName = widget.columnName || (selectedComponent as any)?.columnName; const colMeta = colName - ? currentTable?.columns?.find( + ? (currentTable as any)?.columns?.find( (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(), ) : null; @@ -568,7 +585,7 @@ export const V2PropertiesPanel: React.FC = ({
숨김 { handleUpdate("hidden", checked); handleUpdate("componentConfig.hidden", checked); @@ -689,7 +706,7 @@ export const V2PropertiesPanel: React.FC = ({
표시 { const boolValue = checked === true; handleUpdate("style.labelDisplay", boolValue); @@ -785,7 +802,7 @@ export const V2PropertiesPanel: React.FC = ({ const webType = selectedComponent.componentConfig?.webType; // 테이블 패널에서 드래그한 컴포넌트인지 확인 - const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName); + const isFromTablePanel = !!((selectedComponent as any).tableName && (selectedComponent as any).columnName); if (!componentId) { return ( @@ -845,8 +862,8 @@ export const V2PropertiesPanel: React.FC = ({ = ({ = ({ return (
{/* WebType 선택 (있는 경우만) */} - {widget.webType && ( + {(widget as any).webType && (
- handleUpdate("webType", value)}> {webTypes.map((wt) => ( - {wt.web_type_name_kor || wt.web_type} + {(wt as any).web_type_name_kor || wt.web_type} ))} diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 872e7d57..1b1bf0a5 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -5,10 +5,11 @@ import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; -interface CategoryColumn { +export interface CategoryColumn { tableName: string; - tableLabel?: string; // 테이블 라벨 추가 + tableLabel?: string; columnName: string; columnLabel: string; inputType: string; @@ -16,17 +17,30 @@ interface CategoryColumn { } interface CategoryColumnListProps { - tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시) + tableName: string; selectedColumn: string | null; - onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void; - menuObjid?: number; // 현재 메뉴 OBJID (필수) + onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void; + menuObjid?: number; + /** 대시보드 모드: 테이블 단위 네비만 표시, 선택 시 onTableSelect 호출 */ + selectedTable?: string | null; + onTableSelect?: (tableName: string) => void; + /** 컬럼 로드 완료 시 부모에 전달 (Stat Strip 등 계산용) */ + onColumnsLoaded?: (columns: CategoryColumn[]) => void; } /** * 카테고리 컬럼 목록 (좌측 패널) * - 형제 메뉴들의 모든 카테고리 타입 컬럼을 표시 (메뉴 스코프) */ -export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) { +export function CategoryColumnList({ + tableName, + selectedColumn, + onColumnSelect, + menuObjid, + selectedTable = null, + onTableSelect, + onColumnsLoaded, +}: CategoryColumnListProps) { const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -151,8 +165,8 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, ); setColumns(columnsWithCount); + onColumnsLoaded?.(columnsWithCount); - // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); @@ -160,6 +174,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, } catch (error) { console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error); setColumns([]); + onColumnsLoaded?.([]); } finally { setIsLoading(false); } @@ -248,21 +263,20 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, } setColumns(columnsWithCount); + onColumnsLoaded?.(columnsWithCount); - // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); } } catch (error) { console.error("❌ 카테고리 컬럼 조회 실패:", error); - // 에러 시에도 tableName 기반으로 fallback if (tableName) { - console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName); await loadCategoryColumnsByTable(); return; } else { setColumns([]); + onColumnsLoaded?.([]); } } setIsLoading(false); @@ -291,6 +305,72 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, ); } + // 대시보드 모드: 테이블 단위 네비만 표시 + if (onTableSelect != null) { + return ( +
+
+
+ + setSearchQuery(e.target.value)} + className="h-8 border-0 bg-transparent pl-8 pr-8 text-xs shadow-none focus-visible:ring-0" + /> + {searchQuery && ( + + )} +
+
+
+ {filteredColumns.length === 0 && searchQuery ? ( +
+ '{searchQuery}'에 대한 검색 결과가 없습니다 +
+ ) : null} + {groupedColumns.map((group) => { + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const isActive = selectedTable === group.tableName; + return ( + + ); + })} +
+
+ ); + } + return (
@@ -298,7 +378,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,

관리할 카테고리 컬럼을 선택하세요

- {/* 검색 입력 필드 */}
{searchQuery && ( + {headerRight != null ?
{headerRight}
: null}
@@ -405,7 +408,6 @@ export const CategoryValueManager: React.FC = ({ value.isActive !== false ) } - className="data-[state=checked]:bg-emerald-500" /> - {/* 아이콘 */} {getIcon()} - {/* 라벨 */} -
- {node.valueLabel} - {getDepthLabel()} +
+ + {node.valueLabel} + + + {getDepthLabel()} +
- {/* 비활성 표시 */} {!node.isActive && ( - 비활성 + + 비활성 + )} - {/* 액션 버튼 */} -
+
{canAddChild && ( + {headerRight != null ?
{headerRight}
: null}
@@ -720,7 +727,7 @@ export const CategoryValueManagerTree: React.FC =

상단의 대분류 추가 버튼을 클릭하여 시작하세요

) : ( -
+
{tree.map((node) => ( 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ─── function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { - // DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용 + // (a) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달) if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { const dbType = metaInputType as FieldType; if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { @@ -87,9 +87,10 @@ function resolveFieldType(config: Record, componentType?: string, m } } + // (b) 사용자가 설정 패널에서 직접 선택한 fieldType if (config.fieldType) return config.fieldType as FieldType; - // v2-select 계열 + // (c) v2-select 계열: componentType 또는 config.source 기반 if (componentType === "v2-select" || config.source) { const source = config.source === "code" ? "category" : config.source; if (source === "entity") return "entity"; @@ -97,11 +98,13 @@ function resolveFieldType(config: Record, componentType?: string, m return "select"; } - // v2-input 계열 + // (d) saved config fallback (config.inputType / config.type) const it = config.inputType || config.type; if (it === "number") return "number"; if (it === "textarea") return "textarea"; if (it === "numbering") return "numbering"; + + // (e) 최종 기본값 return "text"; } diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index c788612e..325656d1 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -14,31 +14,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Slider } from "@/components/ui/slider"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Separator } from "@/components/ui/separator"; import { Database, @@ -70,17 +49,8 @@ import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; -import { - DndContext, - closestCenter, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, - arrayMove, -} from "@dnd-kit/sortable"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { MAX_LOAD_ALL_SIZE, @@ -116,14 +86,7 @@ function SortableColumnRow({ onFormatChange: (checked: boolean) => void; onRemove: () => void; }) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; return ( @@ -133,7 +96,7 @@ function SortableColumnRow({ className={cn( "bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5", isDragging && "z-50 opacity-50 shadow-md", - isEntityJoin && "border-primary/20 bg-primary/5" + isEntityJoin && "border-primary/20 bg-primary/5", )} >
) : ( - - #{index + 1} - + #{index + 1} )} % {isNumeric && ( -
); } @@ -231,9 +187,7 @@ function SwitchRow({

{label}

- {description && ( -

{description}

- )} + {description &&

{description}

}
@@ -306,17 +260,8 @@ const PanelColumnSection: React.FC<{ }; loadingEntityJoins: boolean; tableName: string; - onColumnsChange: ( - columns: SplitPanelLayoutConfig["leftPanel"]["columns"] - ) => void; -}> = ({ - columns, - availableColumns, - entityJoinData, - loadingEntityJoins, - tableName, - onColumnsChange, -}) => { + onColumnsChange: (columns: SplitPanelLayoutConfig["leftPanel"]["columns"]) => void; +}> = ({ columns, availableColumns, entityJoinData, loadingEntityJoins, tableName, onColumnsChange }) => { const currentColumns = columns || []; const addColumn = (colInfo: ColumnInfo) => { @@ -325,8 +270,7 @@ const PanelColumnSection: React.FC<{ ...currentColumns, { name: colInfo.columnName, - label: - colInfo.displayName || colInfo.columnName, + label: colInfo.displayName || colInfo.columnName, width: 120, }, ]); @@ -336,18 +280,11 @@ const PanelColumnSection: React.FC<{ onColumnsChange(currentColumns.filter((c) => c.name !== name)); }; - const updateColumn = ( - name: string, - updates: Partial<(typeof currentColumns)[0]> - ) => { - onColumnsChange( - currentColumns.map((c) => (c.name === name ? { ...c, ...updates } : c)) - ); + const updateColumn = (name: string, updates: Partial<(typeof currentColumns)[0]>) => { + onColumnsChange(currentColumns.map((c) => (c.name === name ? { ...c, ...updates } : c))); }; - const addEntityColumn = ( - joinCol: (typeof entityJoinData.availableColumns)[0] - ) => { + const addEntityColumn = (joinCol: (typeof entityJoinData.availableColumns)[0]) => { if (currentColumns.some((c) => c.name === joinCol.joinAlias)) return; onColumnsChange([ ...currentColumns, @@ -385,37 +322,28 @@ const PanelColumnSection: React.FC<{ {availableColumns.length > 0 && (
- + 컬럼 선택
{availableColumns.map((col) => { - const isAdded = currentColumns.some( - (c) => c.name === col.columnName - ); + const isAdded = currentColumns.some((c) => c.name === col.columnName); return (
{ if (isAdded) removeColumn(col.columnName); else addColumn(col); }} > - + - - {col.displayName || col.columnName} - - - {col.input_type || col.dataType} - + {col.displayName || col.columnName} + {col.input_type || col.dataType}
); })} @@ -427,58 +355,43 @@ const PanelColumnSection: React.FC<{ {entityJoinData.joinTables.length > 0 && (
- + Entity 조인 컬럼 - {loadingEntityJoins && ( - - )} + {loadingEntityJoins && }
{entityJoinData.joinTables.map((joinTable, idx) => (
-
+
{joinTable.tableName} {joinTable.currentDisplayColumn}
-
+
{joinTable.availableColumns.map((jCol, jIdx) => { - const matchingJoinColumn = - entityJoinData.availableColumns.find( - (jc) => - jc.tableName === joinTable.tableName && - jc.columnName === jCol.columnName - ); - if (!matchingJoinColumn) return null; - const isAdded = currentColumns.some( - (c) => c.name === matchingJoinColumn.joinAlias + const matchingJoinColumn = entityJoinData.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === jCol.columnName, ); + if (!matchingJoinColumn) return null; + const isAdded = currentColumns.some((c) => c.name === matchingJoinColumn.joinAlias); return (
{ - if (isAdded) - removeColumn(matchingJoinColumn.joinAlias); + if (isAdded) removeColumn(matchingJoinColumn.joinAlias); else addEntityColumn(matchingJoinColumn); }} > - - - - {jCol.columnLabel} - - - {jCol.inputType || jCol.dataType} - + + + {jCol.columnLabel} + {jCol.inputType || jCol.dataType}
); })} @@ -493,10 +406,8 @@ const PanelColumnSection: React.FC<{ {currentColumns.length > 0 && (
- - - 선택된 컬럼 ({currentColumns.length}개) - + + 선택된 컬럼 ({currentColumns.length}개)
- c.name)} - strategy={verticalListSortingStrategy} - > + c.name)} strategy={verticalListSortingStrategy}>
{currentColumns.map((col, idx) => ( t.tableName === value)?.displayName || - value + ? allTables.find((t) => t.tableName === value)?.displayName || value : "테이블 선택"}
- + - - 테이블을 찾을 수 없습니다. - + 테이블을 찾을 수 없습니다. {screenTableName && ( - + - {allTables.find((t) => t.tableName === screenTableName) - ?.displayName || screenTableName} + {allTables.find((t) => t.tableName === screenTableName)?.displayName || screenTableName} )} @@ -626,18 +521,11 @@ const TableCombobox: React.FC<{ }} className="text-xs" > - +
{table.displayName} {table.displayName !== table.tableName && ( - - {table.tableName} - + {table.tableName} )}
@@ -660,20 +548,18 @@ interface V2SplitPanelLayoutConfigPanelProps { menuObjid?: number; } -export const V2SplitPanelLayoutConfigPanel: React.FC< - V2SplitPanelLayoutConfigPanelProps -> = ({ config, onChange, tables, screenTableName, menuObjid }) => { +export const V2SplitPanelLayoutConfigPanel: React.FC = ({ + config, + onChange, + tables, + screenTableName, + menuObjid, +}) => { // ─── 상태 ─── - const [allTables, setAllTables] = useState< - Array<{ tableName: string; displayName: string }> - >([]); + const [allTables, setAllTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); - const [loadedTableColumns, setLoadedTableColumns] = useState< - Record - >({}); - const [loadingColumns, setLoadingColumns] = useState< - Record - >({}); + const [loadedTableColumns, setLoadedTableColumns] = useState>({}); + const [loadingColumns, setLoadingColumns] = useState>({}); const [entityJoinColumns, setEntityJoinColumns] = useState< Record< string, @@ -700,9 +586,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< } > >({}); - const [loadingEntityJoins, setLoadingEntityJoins] = useState< - Record - >({}); + const [loadingEntityJoins, setLoadingEntityJoins] = useState>({}); // Collapsible 상태 const [leftPanelOpen, setLeftPanelOpen] = useState(false); @@ -721,11 +605,11 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< const leftTableColumns = useMemo( () => (leftTableName ? loadedTableColumns[leftTableName] || [] : []), - [loadedTableColumns, leftTableName] + [loadedTableColumns, leftTableName], ); const rightTableColumns = useMemo( () => (rightTableName ? loadedTableColumns[rightTableName] || [] : []), - [loadedTableColumns, rightTableName] + [loadedTableColumns, rightTableName], ); const leftEntityJoins = useMemo( @@ -734,7 +618,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< availableColumns: [], joinTables: [], }, - [entityJoinColumns, leftTableName] + [entityJoinColumns, leftTableName], ); const rightEntityJoins = useMemo( () => @@ -742,7 +626,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< availableColumns: [], joinTables: [], }, - [entityJoinColumns, rightTableName] + [entityJoinColumns, rightTableName], ); // ─── 이벤트 발행 래퍼 ─── @@ -753,18 +637,18 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< window.dispatchEvent( new CustomEvent("componentConfigChanged", { detail: { config: newConfig }, - }) + }), ); } }, - [onChange] + [onChange], ); const updateConfig = useCallback( (updates: Partial) => { handleChange({ ...config, ...updates }); }, - [handleChange, config] + [handleChange, config], ); const updateLeftPanel = useCallback( @@ -774,7 +658,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< leftPanel: { ...config.leftPanel, ...updates }, }); }, - [handleChange, config] + [handleChange, config], ); const updateRightPanel = useCallback( @@ -784,7 +668,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< rightPanel: { ...config.rightPanel, ...updates }, }); }, - [handleChange, config] + [handleChange, config], ); // ─── 테이블 목록 로드 ─── @@ -797,9 +681,8 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< setAllTables( response.data.map((t: any) => ({ tableName: t.tableName || t.table_name, - displayName: - t.tableLabel || t.displayName || t.tableName || t.table_name, - })) + displayName: t.tableLabel || t.displayName || t.tableName || t.table_name, + })), ); } } catch (error) { @@ -829,12 +712,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< const cols = (columnsResponse || []).map((col: any) => ({ tableName: col.tableName || tableName, columnName: col.columnName || col.column_name, - displayName: - col.displayName || - col.columnLabel || - col.column_label || - col.columnName || - col.column_name, + displayName: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, dataType: col.dataType || col.data_type || col.dbType || "", dbType: col.dbType || col.dataType || col.data_type || "", webType: col.webType || col.web_type || "text", @@ -853,7 +731,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< setLoadingColumns((prev) => ({ ...prev, [tableName]: false })); } }, - [loadedTableColumns, loadingColumns] + [loadedTableColumns, loadingColumns], ); const loadEntityJoinColumnsForTable = useCallback( @@ -879,7 +757,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< setLoadingEntityJoins((prev) => ({ ...prev, [tableName]: false })); } }, - [entityJoinColumns, loadingEntityJoins] + [entityJoinColumns, loadingEntityJoins], ); // 좌측/우측 테이블 변경 시 컬럼 로드 @@ -910,17 +788,15 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< newTabs[tabIndex] = { ...newTabs[tabIndex], ...updates }; updateRightPanel({ additionalTabs: newTabs }); }, - [config.rightPanel?.additionalTabs, updateRightPanel] + [config.rightPanel?.additionalTabs, updateRightPanel], ); const removeTab = useCallback( (tabIndex: number) => { - const newTabs = - config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || - []; + const newTabs = config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || []; updateRightPanel({ additionalTabs: newTabs }); }, - [config.rightPanel?.additionalTabs, updateRightPanel] + [config.rightPanel?.additionalTabs, updateRightPanel], ); // ─── 렌더링 ─── @@ -950,19 +826,15 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< }) } className={cn( - "flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]", + "flex min-h-[80px] flex-col items-center justify-center rounded-lg border p-3 text-center transition-all", isSelected - ? "border-primary bg-primary/5 ring-1 ring-primary/20" - : "border-border hover:border-primary/50 hover:bg-muted/50" + ? "border-primary bg-primary/5 ring-primary/20 ring-1" + : "border-border hover:border-primary/50 hover:bg-muted/50", )} > - - - {card.title} - - - {card.description} - + + {card.title} + {card.description} ); })} @@ -973,21 +845,13 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 2단계: 레이아웃 설정 */} {/* ═══════════════════════════════════════ */}
- +
- - 좌측 패널 너비 - - - {config.splitRatio || 30}% - + 좌측 패널 너비 + {config.splitRatio || 30}%
@@ -1044,7 +908,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{/* 좌측 패널 제목 */}
- + updateLeftPanel({ title: e.target.value })} @@ -1055,66 +919,58 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 좌측 테이블 선택 */}
- + - updateLeftPanel({ tableName, columns: [] }) - } + onChange={(tableName) => updateLeftPanel({ tableName, columns: [] })} /> - {screenTableName && - leftTableName !== screenTableName && ( -
- - 기본 테이블({screenTableName})과 다름 - - -
- )} + {screenTableName && leftTableName !== screenTableName && ( +
+ + 기본 테이블({screenTableName})과 다름 + + +
+ )}
{/* 표시 모드 */}
- +
{DISPLAY_MODE_CARDS.map((card) => { const Icon = card.icon; - const currentMode = - config.leftPanel?.displayMode || "list"; + const currentMode = config.leftPanel?.displayMode || "list"; const isSelected = currentMode === card.value; return ( ); })} @@ -1126,38 +982,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< - updateLeftPanel({ showSearch: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showSearch: checked })} /> - updateLeftPanel({ showAdd: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })} /> - updateLeftPanel({ showEdit: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showEdit: checked })} /> - updateLeftPanel({ showDelete: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showDelete: checked })} /> - updateLeftPanel({ showItemAddButton: checked }) - } + onCheckedChange={(checked) => updateLeftPanel({ showItemAddButton: checked })} /> + @@ -1224,12 +1067,12 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{loadingColumns[leftTableName] ? ( -
+
컬럼 로딩 중...
) : leftTableColumns.length === 0 ? ( -

+

테이블을 선택하면 컬럼이 표시됩니다

) : ( @@ -1238,13 +1081,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< columns={config.leftPanel?.columns} availableColumns={leftTableColumns} entityJoinData={leftEntityJoins} - loadingEntityJoins={ - loadingEntityJoins[leftTableName] || false - } + loadingEntityJoins={loadingEntityJoins[leftTableName] || false} tableName={leftTableName} - onColumnsChange={(columns) => - updateLeftPanel({ columns }) - } + onColumnsChange={(columns) => updateLeftPanel({ columns })} /> )}
@@ -1253,23 +1092,20 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< )} {/* 좌측 패널 데이터 필터 (접이식) */} - + @@ -1280,9 +1116,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< tableName={leftTableName} columns={leftTableColumns} config={config.leftPanel?.dataFilter} - onConfigChange={(dataFilter) => - updateLeftPanel({ dataFilter }) - } + onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })} />
@@ -1298,24 +1132,22 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< @@ -1324,7 +1156,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{/* 우측 패널 제목 */}
- + updateRightPanel({ title: e.target.value })} @@ -1335,45 +1167,38 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 우측 테이블 선택 */}
- + - updateRightPanel({ tableName, columns: [] }) - } + onChange={(tableName) => updateRightPanel({ tableName, columns: [] })} />
{/* 표시 모드 */}
- +
{DISPLAY_MODE_CARDS.map((card) => { const Icon = card.icon; - const currentMode = - config.rightPanel?.displayMode || "list"; + const currentMode = config.rightPanel?.displayMode || "list"; const isSelected = currentMode === card.value; return ( ); })} @@ -1384,111 +1209,91 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {rightTableName && (
- + 테이블 연결 키
-

- 좌측 패널과 우측 패널을 연결할 컬럼을 설정합니다 -

+

좌측 패널과 우측 패널을 연결할 컬럼을 설정합니다

{/* 기존 키 목록 */} - {(config.rightPanel?.relation?.keys || []).map( - (key, idx) => ( -
- + {(config.rightPanel?.relation?.keys || []).map((key, idx) => ( +
+ - + - + - -
- ) - )} + +
+ ))} {/* 키가 없을 때 단일키 호환 */} - {(!config.rightPanel?.relation?.keys || - config.rightPanel.relation.keys.length === 0) && ( + {(!config.rightPanel?.relation?.keys || config.rightPanel.relation.keys.length === 0) && (
- + updateRightPanel({ deduplication: { @@ -1796,10 +1565,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {rightTableColumns.map((col) => ( - + {col.displayName || col.columnName} ))} @@ -1807,14 +1573,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
- - 유지 전략 - + 유지 전략
@@ -1852,15 +1605,12 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ editButton: { ...config.rightPanel?.editButton, - enabled: - config.rightPanel?.editButton?.enabled ?? true, + enabled: config.rightPanel?.editButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) @@ -1868,23 +1618,17 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> {config.rightPanel?.editButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateRightPanel({ editButton: { ...config.rightPanel?.editButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -1899,15 +1643,12 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ addButton: { ...config.rightPanel?.addButton, - enabled: - config.rightPanel?.addButton?.enabled ?? true, + enabled: config.rightPanel?.addButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) @@ -1915,22 +1656,17 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> {config.rightPanel?.addButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateRightPanel({ addButton: { ...config.rightPanel?.addButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -1949,22 +1685,17 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ deleteButton: { ...config.rightPanel?.deleteButton, - enabled: - config.rightPanel?.deleteButton?.enabled ?? true, - confirmMessage: checked - ? "정말 삭제하시겠습니까?" - : undefined, + enabled: config.rightPanel?.deleteButton?.enabled ?? true, + confirmMessage: checked ? "정말 삭제하시겠습니까?" : undefined, }, }) } /> {config.rightPanel?.deleteButton?.confirmMessage && ( -
+
updateRightPanel({ deleteButton: { @@ -1983,21 +1714,15 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 추가 시 대상 테이블 (N:M 관계) */}
- - 추가 대상 설정 (N:M) - -

+ 추가 대상 설정 (N:M) +

추가 버튼 클릭 시 실제 INSERT할 테이블을 지정합니다

- - 대상 테이블 - + 대상 테이블 updateRightPanel({ addConfig: { @@ -2011,14 +1736,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< />
- - 좌측값 컬럼 - + 좌측값 컬럼 updateRightPanel({ addConfig: { @@ -2032,13 +1752,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< />
- - 대상 컬럼 - + 대상 컬럼 updateRightPanel({ addConfig: { @@ -2059,15 +1775,10 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< <>
- - 테이블 옵션 - + 테이블 옵션 updateRightPanel({ tableConfig: { @@ -2079,10 +1790,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateRightPanel({ tableConfig: { @@ -2094,9 +1802,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateRightPanel({ tableConfig: { @@ -2108,10 +1814,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateRightPanel({ tableConfig: { @@ -2138,17 +1841,19 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< @@ -2156,193 +1861,159 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
{/* 탭 목록 */} - {(config.rightPanel?.additionalTabs || []).map( - (tab, tabIndex) => ( -
-
- - {tab.label || `탭 ${tabIndex + 1}`} - - -
+ {(config.rightPanel?.additionalTabs || []).map((tab, tabIndex) => ( +
+
+ {tab.label || `탭 ${tabIndex + 1}`} + +
-
-
- - - updateTab(tabIndex, { label: e.target.value }) - } - placeholder="탭 이름" - className="h-7 text-xs" - /> -
-
- - - updateTab(tabIndex, { title: e.target.value }) - } - placeholder="패널 제목" - className="h-7 text-xs" - /> -
-
- - {/* 탭 테이블 선택 */} +
- - { - updateTab(tabIndex, { - tableName, - columns: [], - }); - if (tableName) loadTableColumns(tableName); - }} + + updateTab(tabIndex, { label: e.target.value })} + placeholder="탭 이름" + className="h-7 text-xs" />
- - {/* 탭 표시 모드 */} -
- - 표시 모드 - - -
- - {/* 탭 연결 키 */} - {tab.tableName && ( -
- 연결 키 -
- - - -
-
- )} - - {/* 탭 기능 토글 */} -
- - updateTab(tabIndex, { showSearch: checked }) - } - /> - - updateTab(tabIndex, { showAdd: checked }) - } - /> - - updateTab(tabIndex, { showDelete: checked }) - } +
+ + updateTab(tabIndex, { title: e.target.value })} + placeholder="패널 제목" + className="h-7 text-xs" />
- ) - )} + + {/* 탭 테이블 선택 */} +
+ + { + updateTab(tabIndex, { + tableName, + columns: [], + }); + if (tableName) loadTableColumns(tableName); + }} + /> +
+ + {/* 탭 표시 모드 */} +
+ 표시 모드 + +
+ + {/* 탭 연결 키 */} + {tab.tableName && ( +
+ 연결 키 +
+ + + +
+
+ )} + + {/* 탭 기능 토글 */} +
+ updateTab(tabIndex, { showSearch: checked })} + /> + updateTab(tabIndex, { showAdd: checked })} + /> + updateTab(tabIndex, { showDelete: checked })} + /> +
+
+ ))} {/* 탭 추가 버튼 */} -
@@ -2355,17 +2026,19 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< @@ -2376,9 +2049,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< label="선택 동기화" description="좌우 패널 간 선택 항목 동기화" checked={config.syncSelection ?? false} - onCheckedChange={(checked) => - updateConfig({ syncSelection: checked }) - } + onCheckedChange={(checked) => updateConfig({ syncSelection: checked })} /> @@ -2421,26 +2092,18 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< {/* 좌측 패널 하위 항목 추가 설정 */} {config.leftPanel?.showItemAddButton && (
- - 하위 항목 추가 설정 - + 하위 항목 추가 설정
- - 부모 컬럼 - + 부모 컬럼 updateLeftPanel({ itemAddConfig: { ...config.leftPanel?.itemAddConfig, parentColumn: e.target.value, - sourceColumn: - config.leftPanel?.itemAddConfig?.sourceColumn || - "", + sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "", }, }) } @@ -2449,13 +2112,9 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< />
- - 소스 컬럼 - + 소스 컬럼 updateLeftPanel({ itemAddConfig: { @@ -2478,9 +2137,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< 좌측 테이블 옵션 updateLeftPanel({ tableConfig: { @@ -2492,9 +2149,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateLeftPanel({ tableConfig: { @@ -2518,9 +2173,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> updateLeftPanel({ tableConfig: { @@ -2544,30 +2197,24 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateLeftPanel({ editButton: { ...config.leftPanel?.editButton, - enabled: - config.leftPanel?.editButton?.enabled ?? true, + enabled: config.leftPanel?.editButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) } /> {config.leftPanel?.editButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateLeftPanel({ editButton: { ...config.leftPanel?.editButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -2585,30 +2232,24 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateLeftPanel({ addButton: { ...config.leftPanel?.addButton, - enabled: - config.leftPanel?.addButton?.enabled ?? true, + enabled: config.leftPanel?.addButton?.enabled ?? true, mode: checked ? "modal" : "auto", }, }) } /> {config.leftPanel?.addButton?.mode === "modal" && ( -
+
- - 모달 화면 ID - + 모달 화면 ID updateLeftPanel({ addButton: { ...config.leftPanel?.addButton!, - modalScreenId: - parseInt(e.target.value) || undefined, + modalScreenId: parseInt(e.target.value) || undefined, }, }) } @@ -2632,8 +2273,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< value={config.leftPanel?.panelHeaderHeight || ""} onChange={(e) => updateLeftPanel({ - panelHeaderHeight: - parseInt(e.target.value) || undefined, + panelHeaderHeight: parseInt(e.target.value) || undefined, }) } placeholder="자동" @@ -2647,8 +2287,7 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< value={config.rightPanel?.panelHeaderHeight || ""} onChange={(e) => updateRightPanel({ - panelHeaderHeight: - parseInt(e.target.value) || undefined, + panelHeaderHeight: parseInt(e.target.value) || undefined, }) } placeholder="자동" diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index bd0cf9a2..01231441 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -2,12 +2,13 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { LoginFormData, LoginResponse } from "@/types/auth"; +import { LoginFormData } from "@/types/auth"; import { AUTH_CONFIG, FORM_VALIDATION } from "@/constants/auth"; -import { API_BASE_URL } from "@/lib/api/client"; +import { apiCall } from "@/lib/api/client"; /** * 로그인 관련 비즈니스 로직을 관리하는 커스텀 훅 + * API 호출은 lib/api/client의 apiCall(Axios) 사용 (fetch 직접 사용 금지) */ export const useLogin = () => { const router = useRouter(); @@ -73,67 +74,34 @@ export const useLogin = () => { }, [formData]); /** - * API 호출 공통 함수 - */ - const apiCall = useCallback(async (endpoint: string, options: RequestInit = {}): Promise => { - // 로컬 스토리지에서 토큰 가져오기 - const token = localStorage.getItem("authToken"); - - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - credentials: "include", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }, - ...options, - }); - - const result = await response.json(); - return result; - }, []); - - /** - * 기존 인증 상태 확인 + * 기존 인증 상태 확인 (apiCall 사용) */ const checkExistingAuth = useCallback(async () => { try { - // 로컬 스토리지에서 토큰 확인 const token = localStorage.getItem("authToken"); - if (!token) { - // 토큰이 없으면 로그인 페이지 유지 - return; - } + if (!token) return; - // 토큰이 있으면 API 호출로 유효성 확인 - const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS); + const result = await apiCall<{ isAuthenticated?: boolean }>("GET", AUTH_CONFIG.ENDPOINTS.STATUS); - // 백엔드가 isAuthenticated 필드를 반환함 if (result.success && result.data?.isAuthenticated) { - // 이미 로그인된 경우 메인으로 리다이렉트 router.push(AUTH_CONFIG.ROUTES.MAIN); } else { - // 토큰이 유효하지 않으면 제거 localStorage.removeItem("authToken"); document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } - } catch (error) { - // 에러가 발생하면 토큰 제거 + } catch { localStorage.removeItem("authToken"); document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; - console.debug("기존 인증 체크 중 오류 (정상):", error); } - }, [apiCall, router]); + }, [router]); /** - * 로그인 처리 + * 로그인 처리 (apiCall 사용 - Axios 기반, fetch 미사용) */ const handleLogin = useCallback( async (e: React.FormEvent) => { e.preventDefault(); - // 입력값 검증 const validationError = validateForm(); if (validationError) { setError(validationError); @@ -144,9 +112,13 @@ export const useLogin = () => { setError(""); try { - const result = await apiCall(AUTH_CONFIG.ENDPOINTS.LOGIN, { - method: "POST", - body: JSON.stringify(formData), + const result = await apiCall<{ + token?: string; + firstMenuPath?: string; + popLandingPath?: string; + }>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, { + userId: formData.userId, + password: formData.password, }); if (result.success && result.data?.token) { @@ -185,7 +157,7 @@ export const useLogin = () => { setIsLoading(false); } }, - [formData, validateForm, apiCall, router, isPopMode], + [formData, validateForm, router, isPopMode], ); // 컴포넌트 마운트 시 기존 인증 상태 확인 diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index f06a43fe..6e95e4d9 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -30,18 +30,20 @@ export function invalidateColumnMetaCache(tableName?: string): void { } } -async function loadColumnMeta(tableName: string, forceReload = false): Promise { +export async function loadColumnMeta(tableName: string, forceReload = false): Promise { const now = Date.now(); - const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); + const cachedAt = columnMetaTimestamp[tableName]; + const isStale = + typeof cachedAt === "number" && now - cachedAt > CACHE_TTL_MS; - if (!forceReload && !isStale && columnMetaCache[tableName]) return; + if (!forceReload && !isStale && tableName in columnMetaCache && columnMetaCache[tableName]) return; if (forceReload || isStale) { delete columnMetaCache[tableName]; delete columnMetaLoading[tableName]; } - if (columnMetaLoading[tableName]) { + if (tableName in columnMetaLoading) { await columnMetaLoading[tableName]; return; } @@ -663,7 +665,8 @@ export const DynamicComponentRenderer: React.FC = } // 1. 새 컴포넌트 시스템에서 먼저 조회 - const newComponent = ComponentRegistry.getComponent(componentType); + const newComponent = + componentType != null ? ComponentRegistry.getComponent(componentType) : null; if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 @@ -775,7 +778,7 @@ export const DynamicComponentRenderer: React.FC = // 렌더러 props 구성 // 숨김 값 추출 - const hiddenValue = component.hidden || component.componentConfig?.hidden; + const hiddenValue = (component as any).hidden || component.componentConfig?.hidden; // 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시 if (hiddenValue && isInteractive) { @@ -892,7 +895,7 @@ export const DynamicComponentRenderer: React.FC = // 새로운 기능들 전달 // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환 autoGeneration: - component.autoGeneration || + (component as any).autoGeneration || component.componentConfig?.autoGeneration || ((component as any).webTypeConfig?.numberingRuleId ? { @@ -992,7 +995,8 @@ export const DynamicComponentRenderer: React.FC = let renderedElement: React.ReactElement; if (isClass) { - const rendererInstance = new NewComponentRenderer(rendererProps); + const RendererClass = NewComponentRenderer as new (props: any) => { render: () => React.ReactElement }; + const rendererInstance = new RendererClass(rendererProps); renderedElement = rendererInstance.render(); } else { renderedElement = ; @@ -1004,7 +1008,9 @@ export const DynamicComponentRenderer: React.FC = const labelFontSize = component.style?.labelFontSize || "14px"; const labelColor = getAdaptiveLabelColor(component.style?.labelColor); const labelFontWeight = component.style?.labelFontWeight || "500"; - const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName); + const isRequired = + effectiveComponent.required || + isColumnRequiredByMeta(screenTableName ?? "", baseColumnName ?? ""); const isLeft = labelPosition === "left"; return ( @@ -1038,7 +1044,8 @@ export const DynamicComponentRenderer: React.FC = } // 2. 레거시 시스템에서 조회 - const renderer = legacyComponentRegistry.get(componentType); + const renderer = + componentType != null ? legacyComponentRegistry.get(componentType) : undefined; if (!renderer) { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx index 2e654c7a..22485ec9 100644 --- a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx @@ -2,18 +2,19 @@ /** * V2 카테고리 관리 컴포넌트 - * - 트리 구조 기반 카테고리 값 관리 + * - 대시보드 레이아웃: Stat Strip + 좌측 테이블 nav + 칩 바 + 트리/목록 편집기 * - 3단계 계층 구조 지원 (대분류/중분류/소분류) */ -import React, { useState, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; +import type { CategoryColumn } from "@/components/table-category/CategoryColumnList"; import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree"; import { LayoutList, TreeDeciduous } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel"; import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types"; interface V2CategoryManagerComponentProps { @@ -33,80 +34,87 @@ export function V2CategoryManagerComponent({ componentConfig, ...props }: V2CategoryManagerComponentProps) { - // 설정 병합 (componentConfig도 포함) const config: V2CategoryManagerConfig = { ...defaultV2CategoryManagerConfig, ...externalConfig, ...componentConfig, }; - // tableName 우선순위: props > selectedScreen > componentConfig - const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || ""; - - // menuObjid 우선순위: props > selectedScreen + const effectiveTableName = + tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || ""; const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined; const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid; - // 디버그 로그 - useEffect(() => { - console.log("🔍 V2CategoryManagerComponent props:", { - tableName, - menuObjid, - selectedScreen, - effectiveTableName, - effectiveMenuObjid, - config, - }); - }, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]); - - // 선택된 컬럼 상태 + const [columns, setColumns] = useState([]); + const [selectedTable, setSelectedTable] = useState(null); const [selectedColumn, setSelectedColumn] = useState<{ uniqueKey: string; columnName: string; columnLabel: string; tableName: string; } | null>(null); - - // 뷰 모드 상태 const [viewMode, setViewMode] = useState(config.viewMode); - // 컬럼 선택 핸들러 - const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => { - const columnName = uniqueKey.split(".")[1]; - setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName }); + const handleColumnsLoaded = useCallback((loaded: CategoryColumn[]) => { + setColumns(loaded); + if (loaded.length > 0) { + setSelectedTable((prev) => prev ?? loaded[0].tableName); + } }, []); - // 우측 패널 콘텐츠 + const handleTableSelect = useCallback((tableName: string) => { + setSelectedTable(tableName); + setSelectedColumn(null); + }, []); + + const handleColumnSelect = useCallback( + (uniqueKey: string, columnLabel: string, colTableName: string) => { + const columnName = uniqueKey.includes(".") ? uniqueKey.split(".")[1] : uniqueKey; + setSelectedColumn({ uniqueKey: uniqueKey.includes(".") ? uniqueKey : `${colTableName}.${uniqueKey}`, columnName, columnLabel, tableName: colTableName }); + }, + [], + ); + + const stats = useMemo(() => { + const columnCount = columns.length; + const totalValues = columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const tableCount = new Set(columns.map((c) => c.tableName)).size; + const inactiveCount = 0; + return { columnCount, totalValues, tableCount, inactiveCount }; + }, [columns]); + + const columnsForSelectedTable = useMemo( + () => (selectedTable ? columns.filter((c) => c.tableName === selectedTable) : []), + [columns, selectedTable], + ); + + /** 편집기 헤더에 표시할 트리/목록 세그먼트 (보기 방식 토글) */ + const viewModeSegment = + config.showViewModeToggle ? ( +
+ + +
+ ) : null; + const rightContent = ( <> - {/* 뷰 모드 토글 */} - {config.showViewModeToggle && ( -
- 보기 방식: -
- - -
-
- )} - - {/* 카테고리 값 관리 */}
{selectedColumn ? ( viewMode === "tree" ? ( @@ -115,6 +123,7 @@ export function V2CategoryManagerComponent({ tableName={selectedColumn.tableName} columnName={selectedColumn.columnName} columnLabel={selectedColumn.columnLabel} + headerRight={viewModeSegment} /> ) : ( ) ) : ( @@ -130,7 +140,9 @@ export function V2CategoryManagerComponent({

- {config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"} + {config.showColumnList + ? "칩에서 카테고리 컬럼을 선택하세요" + : "카테고리 컬럼이 설정되지 않았습니다"}

@@ -148,24 +160,107 @@ export function V2CategoryManagerComponent({ } return ( - - } - right={rightContent} - leftTitle="카테고리 컬럼" - leftWidth={config.leftPanelWidth} - minLeftWidth={10} - maxLeftWidth={40} - height={config.height} - /> +
+ {/* Stat Strip: 카테고리 컬럼(primary) | 전체 값(success) | 테이블(primary) | 비활성(warning) */} +
+
+
+ {stats.columnCount} +
+
+ 카테고리 컬럼 +
+
+
+
+ {stats.totalValues} +
+
+ 전체 값 +
+
+
+
+ {stats.tableCount} +
+
+ 테이블 +
+
+
+
+ {stats.inactiveCount} +
+
+ 비활성 +
+
+
+ +
+ {/* 좌측 테이블 nav: 240px */} +
+ +
+ + {/* 우측: 칩 바 + 편집기 */} +
+ {/* 칩 바 */} +
+ {columnsForSelectedTable.map((col) => { + const uniqueKey = `${col.tableName}.${col.columnName}`; + const isActive = selectedColumn?.uniqueKey === uniqueKey; + return ( + + ); + })} + {selectedTable && columnsForSelectedTable.length === 0 && ( + 이 테이블에 카테고리 컬럼이 없습니다 + )} +
+ + {/* 편집기 영역 */} +
+ {rightContent} +
+
+
+
); } export default V2CategoryManagerComponent; - diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index bcd670c5..aa293ce6 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge"; import { Plus, Search, - GripVertical, Loader2, ChevronDown, ChevronUp, @@ -24,6 +23,9 @@ import { Settings, Move, FileSpreadsheet, + List, + PanelRight, + GripVertical, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -144,14 +146,7 @@ const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) if (!imgSrc) return -; - return ( - setImgSrc(null)} - /> - ); + return setImgSrc(null)} />; }); SplitPanelCellImage.displayName = "SplitPanelCellImage"; @@ -186,14 +181,8 @@ const ScaledCustomPanel: React.FC<{ return () => ro.disconnect(); }, []); - const canvasW = Math.max( - ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), - 400, - ); - const canvasH = Math.max( - ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), - 200, - ); + const canvasW = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400); + const canvasH = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 200); return (
{containerWidth > 0 && @@ -269,19 +258,6 @@ export const SplitPanelLayoutComponent: React.FC }) => { const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; - // 🐛 디버깅: 로드 시 rightPanel.components 확인 - const rightComps = componentConfig.rightPanel?.components || []; - const finishedTimeline = rightComps.find((c: any) => c.id === "finished_timeline"); - if (finishedTimeline) { - const fm = finishedTimeline.componentConfig?.fieldMapping; - console.log("🔍 [SplitPanelLayout] finished_timeline fieldMapping:", { - componentId: finishedTimeline.id, - fieldMapping: fm ? JSON.stringify(fm) : "undefined", - fieldMappingKeys: fm ? Object.keys(fm) : [], - fieldMappingId: fm?.id, - fullComponentConfig: JSON.stringify(finishedTimeline.componentConfig || {}, null, 2), - }); - } // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능) const companyCode = (props as any).companyCode as string | undefined; @@ -383,11 +359,19 @@ export const SplitPanelLayoutComponent: React.FC const [leftFilters, setLeftFilters] = useState([]); const [leftGrouping, setLeftGrouping] = useState([]); const [leftColumnVisibility, setLeftColumnVisibility] = useState([]); - const [leftColumnOrder, setLeftColumnOrder] = useState([]); // 🔧 컬럼 순서 - const [leftGroupSumConfig, setLeftGroupSumConfig] = useState(null); // 🆕 그룹별 합산 설정 + const [leftColumnOrder, setLeftColumnOrder] = useState([]); + const [leftGroupSumConfig, setLeftGroupSumConfig] = useState(null); + // 좌측 패널 컬럼 헤더 드래그 + const [leftDraggedColumnIndex, setLeftDraggedColumnIndex] = useState(null); + const [leftDropTargetColumnIndex, setLeftDropTargetColumnIndex] = useState(null); const [rightFilters, setRightFilters] = useState([]); const [rightGrouping, setRightGrouping] = useState([]); const [rightColumnVisibility, setRightColumnVisibility] = useState([]); + // 우측 패널 컬럼 헤더 드래그 (디자인 + 런타임 순서 변경) + const [rightDraggedColumnIndex, setRightDraggedColumnIndex] = useState(null); + const [rightDropTargetColumnIndex, setRightDropTargetColumnIndex] = useState(null); + const [rightDragSource, setRightDragSource] = useState<"main" | number | null>(null); + const [runtimeColumnOrder, setRuntimeColumnOrder] = useState>({}); // 데이터 상태 const [leftData, setLeftData] = useState([]); @@ -398,7 +382,13 @@ export const SplitPanelLayoutComponent: React.FC // 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용) const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]); const handleLocalSelectedRowsChange = useCallback( - (selectedRows: any[], selectedRowsDataNew: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => { + ( + selectedRows: any[], + selectedRowsDataNew: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + ) => { setLocalSelectedRowsData(selectedRowsDataNew); if ((props as any).onSelectedRowsChange) { (props as any).onSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder); @@ -423,7 +413,9 @@ export const SplitPanelLayoutComponent: React.FC const [rightTotalPages, setRightTotalPages] = useState(1); const [rightTotal, setRightTotal] = useState(0); const [rightPageSize, setRightPageSize] = useState(componentConfig.rightPanel?.pagination?.pageSize ?? 20); - const [tabsPagination, setTabsPagination] = useState>({}); + const [tabsPagination, setTabsPagination] = useState< + Record + >({}); const [leftPageInput, setLeftPageInput] = useState("1"); const [rightPageInput, setRightPageInput] = useState("1"); @@ -462,13 +454,11 @@ export const SplitPanelLayoutComponent: React.FC const handleRemovePanelComponent = useCallback( (panelSide: "left" | "right", compId: string) => { if (!onUpdateComponent) return; - + const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; - const updatedComponents = (panelConfig.components || []).filter( - (c: PanelInlineComponent) => c.id !== compId - ); - + const updatedComponents = (panelConfig.components || []).filter((c: PanelInlineComponent) => c.id !== compId); + onUpdateComponent({ ...component, componentConfig: { @@ -480,22 +470,22 @@ export const SplitPanelLayoutComponent: React.FC }, }); }, - [component, componentConfig, onUpdateComponent] + [component, componentConfig, onUpdateComponent], ); // 🆕 중첩된 컴포넌트 업데이트 핸들러 (탭 컴포넌트 내부 위치 변경 등) const handleNestedComponentUpdate = useCallback( (panelSide: "left" | "right", compId: string, updatedNestedComponent: any) => { if (!onUpdateComponent) return; - + const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; const panelComponents = panelConfig.components || []; - + const updatedComponents = panelComponents.map((c: PanelInlineComponent) => - c.id === compId ? { ...c, ...updatedNestedComponent, id: c.id } : c + c.id === compId ? { ...c, ...updatedNestedComponent, id: c.id } : c, ); - + onUpdateComponent({ ...component, componentConfig: { @@ -507,7 +497,7 @@ export const SplitPanelLayoutComponent: React.FC }, }); }, - [component, componentConfig, onUpdateComponent] + [component, componentConfig, onUpdateComponent], ); // 🆕 커스텀 모드: 드래그 시작 핸들러 @@ -515,12 +505,12 @@ export const SplitPanelLayoutComponent: React.FC (e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent) => { e.stopPropagation(); e.preventDefault(); - + const startMouseX = e.clientX; const startMouseY = e.clientY; const startLeft = comp.position?.x || 0; const startTop = comp.position?.y || 0; - + setDraggingCompId(comp.id); setDragPosition({ x: startLeft, y: startTop }); @@ -528,7 +518,7 @@ export const SplitPanelLayoutComponent: React.FC if (rafRef.current) { cancelAnimationFrame(rafRef.current); } - + rafRef.current = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; @@ -542,18 +532,18 @@ export const SplitPanelLayoutComponent: React.FC const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); - + if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } - + const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; // 10px 단위 스냅 적용 const newX = snapTo10(Math.max(0, startLeft + deltaX)); const newY = snapTo10(Math.max(0, startTop + deltaY)); - + setDraggingCompId(null); setDragPosition(null); @@ -561,11 +551,9 @@ export const SplitPanelLayoutComponent: React.FC const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) => - c.id === comp.id - ? { ...c, position: { x: newX, y: newY } } - : c + c.id === comp.id ? { ...c, position: { x: newX, y: newY } } : c, ); - + onUpdateComponent({ ...component, componentConfig: { @@ -582,7 +570,7 @@ export const SplitPanelLayoutComponent: React.FC document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [component, componentConfig, onUpdateComponent, snapTo10] + [component, componentConfig, onUpdateComponent, snapTo10], ); // 🆕 커스텀 모드: 리사이즈 시작 핸들러 @@ -590,12 +578,12 @@ export const SplitPanelLayoutComponent: React.FC (e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent, direction: "e" | "s" | "se") => { e.stopPropagation(); e.preventDefault(); - + const startMouseX = e.clientX; const startMouseY = e.clientY; const startWidth = comp.size?.width || 200; const startHeight = comp.size?.height || 100; - + setResizingCompId(comp.id); setResizeSize({ width: startWidth, height: startHeight }); @@ -603,21 +591,21 @@ export const SplitPanelLayoutComponent: React.FC if (rafRef.current) { cancelAnimationFrame(rafRef.current); } - + rafRef.current = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; - + let newWidth = startWidth; let newHeight = startHeight; - + if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(30, startHeight + deltaY)); } - + setResizeSize({ width: newWidth, height: newHeight }); }); }; @@ -625,25 +613,25 @@ export const SplitPanelLayoutComponent: React.FC const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); - + if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } - + const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; - + let newWidth = startWidth; let newHeight = startHeight; - + if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(30, startHeight + deltaY)); } - + setResizingCompId(null); setResizeSize(null); @@ -651,11 +639,9 @@ export const SplitPanelLayoutComponent: React.FC const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; const panelConfig = componentConfig[panelKey] || {}; const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) => - c.id === comp.id - ? { ...c, size: { width: newWidth, height: newHeight } } - : c + c.id === comp.id ? { ...c, size: { width: newWidth, height: newHeight } } : c, ); - + onUpdateComponent({ ...component, componentConfig: { @@ -672,7 +658,7 @@ export const SplitPanelLayoutComponent: React.FC document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [component, componentConfig, onUpdateComponent, snapTo10] + [component, componentConfig, onUpdateComponent, snapTo10], ); const { toast } = useToast(); @@ -709,14 +695,6 @@ export const SplitPanelLayoutComponent: React.FC } = splitPanelContext; const splitPanelId = `split-panel-${component.id}`; - // 디버깅: Context 연결 상태 확인 - console.log("🔗 [SplitPanelLayout] Context 연결 상태:", { - componentId: component.id, - splitPanelId, - hasRegisterFunc: typeof ctxRegisterSplitPanel === "function", - splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음", - }); - // Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행 const ctxRegisterRef = useRef(ctxRegisterSplitPanel); const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel); @@ -740,15 +718,9 @@ export const SplitPanelLayoutComponent: React.FC isDragging: false, }; - console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", { - splitPanelId, - panelInfo, - }); - ctxRegisterRef.current(splitPanelId, panelInfo); return () => { - console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId); ctxUnregisterRef.current(splitPanelId); }; // 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리 @@ -805,10 +777,6 @@ export const SplitPanelLayoutComponent: React.FC isDragging: false, initialLeftWidthPercent: leftWidth, }); - console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", { - splitPanelId, - finalLeftWidthPercent: leftWidth, - }); } prevIsDraggingRef.current = isDragging; @@ -816,11 +784,8 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 그룹별 합산된 데이터 계산 const summedLeftData = useMemo(() => { - console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); - // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) { - console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환"); return leftData; } @@ -833,7 +798,6 @@ export const SplitPanelLayoutComponent: React.FC const [refTable, fieldName] = columnName.split("."); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const exactKey = `${inferredSourceColumn}_${fieldName}`; - console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined }); if (item[exactKey] !== undefined) return exactKey; if (fieldName === "item_name" || fieldName === "name") { const aliasKey = `${inferredSourceColumn}_name`; @@ -886,14 +850,7 @@ export const SplitPanelLayoutComponent: React.FC } }); - const result = Array.from(groupMap.values()); - console.log("🔗 [분할패널] 그룹별 합산 결과:", { - 원본개수: leftData.length, - 그룹개수: result.length, - 그룹기준: groupByColumn, - }); - - return result; + return Array.from(groupMap.values()); }, [leftData, leftGroupSumConfig]); // 컴포넌트 스타일 @@ -913,7 +870,7 @@ export const SplitPanelLayoutComponent: React.FC height: "100%", minHeight: getHeightValue(), cursor: "pointer", - border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb", + border: isSelected ? "2px solid hsl(var(--primary))" : "1px solid hsl(var(--border))", } : { position: "relative", @@ -1000,9 +957,7 @@ export const SplitPanelLayoutComponent: React.FC // columnVisibility가 있으면 가시성 + 너비 적용 if (leftColumnVisibility.length > 0) { - const visibilityMap = new Map( - leftColumnVisibility.map((cv) => [cv.columnName, cv]) - ); + const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv])); columns = columns .filter((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; @@ -1117,40 +1072,34 @@ export const SplitPanelLayoutComponent: React.FC }, []); // 프로그레스바 셀 렌더링 (부모 값 대비 자식 값 비율) - const renderProgressCell = useCallback( - (col: any, item: any, parentData: any) => { - const current = Number(item[col.numerator] || 0); - const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0); - const percentage = max > 0 ? Math.round((current / max) * 100) : 0; - const barWidth = Math.min(percentage, 100); - const barColor = - percentage > 100 - ? "bg-red-600" - : percentage >= 90 - ? "bg-red-500" - : percentage >= 70 - ? "bg-amber-500" - : "bg-emerald-500"; + const renderProgressCell = useCallback((col: any, item: any, parentData: any) => { + const current = Number(item[col.numerator] || 0); + const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0); + const percentage = max > 0 ? Math.round((current / max) * 100) : 0; + const barWidth = Math.min(percentage, 100); + const barColor = + percentage > 100 + ? "bg-destructive" + : percentage >= 90 + ? "bg-destructive" + : percentage >= 70 + ? "bg-warning" + : "bg-success"; - return ( -
-
-
-
-
-
- {current.toLocaleString()} / {max.toLocaleString()} -
+ return ( +
+
+
+
+
+
+ {current.toLocaleString()} / {max.toLocaleString()}
- {percentage}%
- ); - }, - [], - ); + {percentage}% +
+ ); + }, []); // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) const formatCellValue = useCallback( @@ -1285,7 +1234,7 @@ export const SplitPanelLayoutComponent: React.FC // 방법 1: isEntityJoin 플래그가 있는 경우 (설정 패널에서 Entity 조인 컬럼으로 추가한 경우) if (col.isEntityJoin && col.joinInfo) { const existing = joinColumns.find( - (j) => j.referenceTable === col.joinInfo.referenceTable && j.joinAlias === col.joinInfo.joinAlias + (j) => j.referenceTable === col.joinInfo.referenceTable && j.joinAlias === col.joinInfo.joinAlias, ); if (!existing) { joinColumns.push({ @@ -1304,7 +1253,7 @@ export const SplitPanelLayoutComponent: React.FC const [refTable, refColumn] = colName.split("."); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const existing = joinColumns.find( - (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn + (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn, ); if (!existing) { joinColumns.push({ @@ -1332,84 +1281,84 @@ export const SplitPanelLayoutComponent: React.FC }, []); // 좌측 데이터 로드 (페이징 ON: page 파라미터 사용, OFF: 전체 로드) - const loadLeftData = useCallback(async (page?: number, pageSizeOverride?: number) => { - const leftTableName = componentConfig.leftPanel?.tableName; - if (!leftTableName || isDesignMode) return; + const loadLeftData = useCallback( + async (page?: number, pageSizeOverride?: number) => { + const leftTableName = componentConfig.leftPanel?.tableName; + if (!leftTableName || isDesignMode) return; - setIsLoadingLeft(true); - try { - const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; - const leftJoinColumns = extractAdditionalJoinColumns( - componentConfig.leftPanel?.columns, - leftTableName, - ); + setIsLoadingLeft(true); + try { + const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; + const leftJoinColumns = extractAdditionalJoinColumns(componentConfig.leftPanel?.columns, leftTableName); - if (leftPaginationEnabled) { - const currentPageToLoad = page ?? leftCurrentPage; - const effectivePageSize = pageSizeOverride ?? leftPageSize; - const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { - page: currentPageToLoad, - size: effectivePageSize, - search: filters, - enableEntityJoin: true, - dataFilter: componentConfig.leftPanel?.dataFilter, - additionalJoinColumns: leftJoinColumns, - companyCodeOverride: companyCode, - }); - - setLeftData(result.data || []); - setLeftCurrentPage(result.page || currentPageToLoad); - setLeftTotalPages(result.totalPages || 1); - setLeftTotal(result.total || 0); - setLeftPageInput(String(result.page || currentPageToLoad)); - } else { - const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { - page: 1, - size: MAX_LOAD_ALL_SIZE, - search: filters, - enableEntityJoin: true, - dataFilter: componentConfig.leftPanel?.dataFilter, - additionalJoinColumns: leftJoinColumns, - companyCodeOverride: companyCode, - }); - - let filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter); - - const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; - if (leftColumn && filteredLeftData.length > 0) { - filteredLeftData.sort((a, b) => { - const aValue = String(a[leftColumn] || ""); - const bValue = String(b[leftColumn] || ""); - return aValue.localeCompare(bValue, "ko-KR"); + if (leftPaginationEnabled) { + const currentPageToLoad = page ?? leftCurrentPage; + const effectivePageSize = pageSizeOverride ?? leftPageSize; + const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { + page: currentPageToLoad, + size: effectivePageSize, + search: filters, + enableEntityJoin: true, + dataFilter: componentConfig.leftPanel?.dataFilter, + additionalJoinColumns: leftJoinColumns, + companyCodeOverride: companyCode, }); - } - const hierarchicalData = buildHierarchy(filteredLeftData); - setLeftData(hierarchicalData); + setLeftData(result.data || []); + setLeftCurrentPage(result.page || currentPageToLoad); + setLeftTotalPages(result.totalPages || 1); + setLeftTotal(result.total || 0); + setLeftPageInput(String(result.page || currentPageToLoad)); + } else { + const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { + page: 1, + size: MAX_LOAD_ALL_SIZE, + search: filters, + enableEntityJoin: true, + dataFilter: componentConfig.leftPanel?.dataFilter, + additionalJoinColumns: leftJoinColumns, + companyCodeOverride: companyCode, + }); + + const filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter); + + const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; + if (leftColumn && filteredLeftData.length > 0) { + filteredLeftData.sort((a, b) => { + const aValue = String(a[leftColumn] || ""); + const bValue = String(b[leftColumn] || ""); + return aValue.localeCompare(bValue, "ko-KR"); + }); + } + + const hierarchicalData = buildHierarchy(filteredLeftData); + setLeftData(hierarchicalData); + } + } catch (error) { + console.error("좌측 데이터 로드 실패:", error); + toast({ + title: "데이터 로드 실패", + description: "좌측 패널 데이터를 불러올 수 없습니다.", + variant: "destructive", + }); + } finally { + setIsLoadingLeft(false); } - } catch (error) { - console.error("좌측 데이터 로드 실패:", error); - toast({ - title: "데이터 로드 실패", - description: "좌측 패널 데이터를 불러올 수 없습니다.", - variant: "destructive", - }); - } finally { - setIsLoadingLeft(false); - } - }, [ - componentConfig.leftPanel?.tableName, - componentConfig.leftPanel?.columns, - componentConfig.leftPanel?.dataFilter, - componentConfig.rightPanel?.relation?.leftColumn, - leftPaginationEnabled, - leftCurrentPage, - leftPageSize, - isDesignMode, - toast, - buildHierarchy, - searchValues, - ]); + }, + [ + componentConfig.leftPanel?.tableName, + componentConfig.leftPanel?.columns, + componentConfig.leftPanel?.dataFilter, + componentConfig.rightPanel?.relation?.leftColumn, + leftPaginationEnabled, + leftCurrentPage, + leftPageSize, + isDesignMode, + toast, + buildHierarchy, + searchValues, + ], + ); const updateRightPaginationState = useCallback((result: any, fallbackPage: number) => { setRightCurrentPage(result.page || fallbackPage); @@ -1430,10 +1379,7 @@ export const SplitPanelLayoutComponent: React.FC if (!leftItem && relationshipType === "join") { setIsLoadingRight(true); try { - const rightJoinColumns = extractAdditionalJoinColumns( - componentConfig.rightPanel?.columns, - rightTableName, - ); + const rightJoinColumns = extractAdditionalJoinColumns(componentConfig.rightPanel?.columns, rightTableName); const effectivePageSize = pageSizeOverride ?? rightPageSize; if (rightPaginationEnabled) { @@ -1486,11 +1432,8 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시 if (leftItem._originalItems && leftItem._originalItems.length > 0) { - console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length); - // 정렬 기준 컬럼 (복합키의 leftColumn들) const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || []; - console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns); // 정렬 함수 const sortByKeys = (data: any[]) => { @@ -1509,7 +1452,6 @@ export const SplitPanelLayoutComponent: React.FC // 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우) if (leftTable === rightTableName) { const sortedData = sortByKeys(leftItem._originalItems); - console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length); setRightData(sortedData); return; } @@ -1547,9 +1489,7 @@ export const SplitPanelLayoutComponent: React.FC } } - // 정렬 적용 const sortedResults = sortByKeys(allResults); - console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length); setRightData(sortedResults); return; } @@ -1567,13 +1507,8 @@ export const SplitPanelLayoutComponent: React.FC } }); - console.log("🔗 [분할패널] 복합키 조건:", searchConditions); - // 🆕 우측 패널 config의 Entity 조인 컬럼 추출 - const rightJoinColumns = extractAdditionalJoinColumns( - componentConfig.rightPanel?.columns, - rightTableName, - ); + const rightJoinColumns = extractAdditionalJoinColumns(componentConfig.rightPanel?.columns, rightTableName); if (rightJoinColumns) { console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns); } @@ -1602,8 +1537,6 @@ export const SplitPanelLayoutComponent: React.FC const leftValue = leftItem[leftColumn]; const { entityJoinApi } = await import("@/lib/api/entityJoin"); - console.log("🔗 [분할패널] 단일키 조건:", { leftColumn, rightColumn, leftValue, rightTableName }); - // 단일키를 복합키 형식으로 변환 (entity 컬럼이므로 equals 연산자 필수) const searchConditions: Record = {}; searchConditions[rightColumn] = { value: leftValue, operator: "equals" }; @@ -1613,10 +1546,6 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.columns, rightTableName, ); - if (rightJoinColumnsLegacy) { - console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy); - } - const effectivePageSizeLegacy = pageSizeOverride ?? rightPageSize; const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, @@ -1675,7 +1604,6 @@ export const SplitPanelLayoutComponent: React.FC const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); - let resultData: any[] = []; let apiResult: any = null; const tabDataFilterForApi = (tabConfig as any).dataFilter; @@ -1748,23 +1676,33 @@ export const SplitPanelLayoutComponent: React.FC console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); toast({ title: "데이터 로드 실패", - description: `탭 데이터를 불러올 수 없습니다.`, + description: "탭 데이터를 불러올 수 없습니다.", variant: "destructive", }); } finally { setTabsLoading((prev) => ({ ...prev, [tabIndex]: false })); } }, - [componentConfig.rightPanel?.additionalTabs, rightPaginationEnabled, rightPageSize, tabsPagination, isDesignMode, toast], + [ + componentConfig.rightPanel?.additionalTabs, + rightPaginationEnabled, + rightPageSize, + tabsPagination, + isDesignMode, + toast, + ], ); // 🆕 좌측 페이지 변경 핸들러 - const handleLeftPageChange = useCallback((newPage: number) => { - if (newPage < 1 || newPage > leftTotalPages) return; - setLeftCurrentPage(newPage); - setLeftPageInput(String(newPage)); - loadLeftData(newPage); - }, [leftTotalPages, loadLeftData]); + const handleLeftPageChange = useCallback( + (newPage: number) => { + if (newPage < 1 || newPage > leftTotalPages) return; + setLeftCurrentPage(newPage); + setLeftPageInput(String(newPage)); + loadLeftData(newPage); + }, + [leftTotalPages, loadLeftData], + ); const commitLeftPageInput = useCallback(() => { const parsed = parseInt(leftPageInput, 10); @@ -1776,24 +1714,30 @@ export const SplitPanelLayoutComponent: React.FC }, [leftPageInput, leftTotalPages, leftCurrentPage, handleLeftPageChange]); // 🆕 좌측 페이지 크기 변경 - const handleLeftPageSizeChange = useCallback((newSize: number) => { - setLeftPageSize(newSize); - setLeftCurrentPage(1); - setLeftPageInput("1"); - loadLeftData(1, newSize); - }, [loadLeftData]); + const handleLeftPageSizeChange = useCallback( + (newSize: number) => { + setLeftPageSize(newSize); + setLeftCurrentPage(1); + setLeftPageInput("1"); + loadLeftData(1, newSize); + }, + [loadLeftData], + ); // 🆕 우측 페이지 변경 핸들러 - const handleRightPageChange = useCallback((newPage: number) => { - if (newPage < 1 || newPage > rightTotalPages) return; - setRightCurrentPage(newPage); - setRightPageInput(String(newPage)); - if (activeTabIndex === 0) { - loadRightData(selectedLeftItem, newPage); - } else { - loadTabData(activeTabIndex, selectedLeftItem, newPage); - } - }, [rightTotalPages, activeTabIndex, selectedLeftItem, loadRightData, loadTabData]); + const handleRightPageChange = useCallback( + (newPage: number) => { + if (newPage < 1 || newPage > rightTotalPages) return; + setRightCurrentPage(newPage); + setRightPageInput(String(newPage)); + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem, newPage); + } else { + loadTabData(activeTabIndex, selectedLeftItem, newPage); + } + }, + [rightTotalPages, activeTabIndex, selectedLeftItem, loadRightData, loadTabData], + ); const commitRightPageInput = useCallback(() => { const parsed = parseInt(rightPageInput, 10); @@ -1807,82 +1751,128 @@ export const SplitPanelLayoutComponent: React.FC }, [rightPageInput, rightTotalPages, rightCurrentPage, activeTabIndex, tabsPagination, handleRightPageChange]); // 🆕 우측 페이지 크기 변경 - const handleRightPageSizeChange = useCallback((newSize: number) => { - setRightPageSize(newSize); - setRightCurrentPage(1); - setRightPageInput("1"); - setTabsPagination({}); - if (activeTabIndex === 0) { - loadRightData(selectedLeftItem, 1, newSize); - } else { - loadTabData(activeTabIndex, selectedLeftItem, 1, newSize); - } - }, [activeTabIndex, selectedLeftItem, loadRightData, loadTabData]); + const handleRightPageSizeChange = useCallback( + (newSize: number) => { + setRightPageSize(newSize); + setRightCurrentPage(1); + setRightPageInput("1"); + setTabsPagination({}); + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem, 1, newSize); + } else { + loadTabData(activeTabIndex, selectedLeftItem, 1, newSize); + } + }, + [activeTabIndex, selectedLeftItem, loadRightData, loadTabData], + ); // 🆕 페이징 UI 컴포넌트 (공통) - const renderPaginationBar = useCallback((params: { - currentPage: number; - totalPages: number; - total: number; - pageSize: number; - pageInput: string; - setPageInput: (v: string) => void; - onPageChange: (p: number) => void; - onPageSizeChange: (s: number) => void; - commitPageInput: () => void; - loading: boolean; - }) => { - const { currentPage, totalPages, total, pageSize, pageInput, setPageInput, onPageChange, onPageSizeChange, commitPageInput: commitFn, loading } = params; - return ( -
-
- 표시: - { - const v = Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 1)); - onPageSizeChange(v); - }} - className="border-input bg-background focus:ring-ring h-6 w-12 rounded border px-1 text-center text-[10px] focus:ring-1 focus:outline-none" - /> - / {total}건 -
- -
- - -
+ const renderPaginationBar = useCallback( + (params: { + currentPage: number; + totalPages: number; + total: number; + pageSize: number; + pageInput: string; + setPageInput: (v: string) => void; + onPageChange: (p: number) => void; + onPageSizeChange: (s: number) => void; + commitPageInput: () => void; + loading: boolean; + }) => { + const { + currentPage, + totalPages, + total, + pageSize, + pageInput, + setPageInput, + onPageChange, + onPageSizeChange, + commitPageInput: commitFn, + loading, + } = params; + return ( +
+
+ 표시: setPageInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { commitFn(); (e.target as HTMLInputElement).blur(); } }} - onBlur={commitFn} - onFocus={(e) => e.target.select()} - disabled={loading} - className="border-input bg-background focus:ring-ring h-6 w-8 rounded border px-1 text-center text-[10px] font-medium focus:ring-1 focus:outline-none" + type="number" + min={1} + max={MAX_LOAD_ALL_SIZE} + value={pageSize} + onChange={(e) => { + const v = Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 1)); + onPageSizeChange(v); + }} + className="border-input bg-background focus:ring-ring h-6 w-12 rounded border px-1 text-center text-[10px] focus:ring-1 focus:outline-none" /> - / - {totalPages || 1} + / {total}건 +
+ +
+ + +
+ setPageInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + commitFn(); + (e.target as HTMLInputElement).blur(); + } + }} + onBlur={commitFn} + onFocus={(e) => e.target.select()} + disabled={loading} + className="border-input bg-background focus:ring-ring h-6 w-8 rounded border px-1 text-center text-[10px] font-medium focus:ring-1 focus:outline-none" + /> + / + {totalPages || 1} +
+ +
- -
-
- ); - }, []); + ); + }, + [], + ); // 우측/탭 페이징 상태 (IIFE 대신 useMemo로 사전 계산) const rightPagState = useMemo(() => { @@ -1927,10 +1917,9 @@ export const SplitPanelLayoutComponent: React.FC const handleLeftItemSelect = useCallback( (item: any) => { // 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀) - const leftPk = componentConfig.rightPanel?.relation?.leftColumn || - componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn; - const isSameItem = selectedLeftItem && leftPk && - selectedLeftItem[leftPk] === item[leftPk]; + const leftPk = + componentConfig.rightPanel?.relation?.leftColumn || componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn; + const isSameItem = selectedLeftItem && leftPk && selectedLeftItem[leftPk] === item[leftPk]; if (isSameItem) { setSelectedLeftItem(null); @@ -1991,11 +1980,20 @@ export const SplitPanelLayoutComponent: React.FC if (leftTableName && !isDesignMode) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(leftTableName, [item]); - console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item); }); } }, - [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem, rightPaginationEnabled], + [ + loadRightData, + loadTabData, + activeTabIndex, + componentConfig.leftPanel?.tableName, + componentConfig.rightPanel?.relation, + componentConfig.rightPanel?.additionalTabs, + isDesignMode, + selectedLeftItem, + rightPaginationEnabled, + ], ); // 우측 항목 확장/축소 토글 @@ -2212,7 +2210,6 @@ export const SplitPanelLayoutComponent: React.FC } }); setLeftColumnLabels(labels); - console.log("✅ 좌측 컬럼 라벨 로드:", labels); } catch (error) { console.error("좌측 테이블 컬럼 라벨 로드 실패:", error); } @@ -2301,7 +2298,9 @@ export const SplitPanelLayoutComponent: React.FC for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { - const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); + const response = await apiClient.get( + `/table-categories/${tableName}/${columnName}/values?includeInactive=true`, + ); if (response.data.success && response.data.data) { const valueMap: Record = {}; @@ -2375,8 +2374,6 @@ export const SplitPanelLayoutComponent: React.FC }); }); - console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); - // 각 테이블에 대해 카테고리 매핑 로드 for (const tableName of tablesToLoad) { try { @@ -2388,7 +2385,9 @@ export const SplitPanelLayoutComponent: React.FC for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { - const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); + const response = await apiClient.get( + `/table-categories/${tableName}/${columnName}/values?includeInactive=true`, + ); if (response.data.success && response.data.data) { const valueMap: Record = {}; @@ -2406,9 +2405,6 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블) // 기존 매핑이 있으면 병합, 없으면 새로 생성 mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; - - console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap); - console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]); } } catch (error) { console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error); @@ -2426,7 +2422,12 @@ export const SplitPanelLayoutComponent: React.FC }; loadRightCategoryMappings(); - }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, componentConfig.rightPanel?.additionalTabs, isDesignMode]); + }, [ + componentConfig.rightPanel?.tableName, + componentConfig.rightPanel?.columns, + componentConfig.rightPanel?.additionalTabs, + isDesignMode, + ]); // 항목 펼치기/접기 토글 const toggleExpand = useCallback((itemId: any) => { @@ -2463,10 +2464,6 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] 좌측 추가 모달 화면 열기:", { - screenId: addButtonConfig.modalScreenId, - tableName: leftTableName, - }); return; } } @@ -2502,9 +2499,10 @@ export const SplitPanelLayoutComponent: React.FC // relation.keys에서 FK 데이터 추출 const parentData: Record = {}; - const relation = activeTabIndex === 0 - ? componentConfig.rightPanel?.relation - : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; + const relation = + activeTabIndex === 0 + ? componentConfig.rightPanel?.relation + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; if (relation?.keys && Array.isArray(relation.keys)) { for (const key of relation.keys) { @@ -2533,11 +2531,6 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { - screenId: addButtonConfig.modalScreenId, - tableName: currentTableName, - parentData, - }); return; } } @@ -2606,11 +2599,6 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] 좌측 수정 모달 화면 열기:", { - screenId: editButtonConfig.modalScreenId, - tableName: leftTableName, - primaryKeyValue, - }); return; } } @@ -2634,79 +2622,58 @@ export const SplitPanelLayoutComponent: React.FC // 커스텀 모달 화면 열기 const rightTableName = currentTableName; - // Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드) - let primaryKeyName = "id"; - let primaryKeyValue: any; + // Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드) + let primaryKeyName = "id"; + let primaryKeyValue: any; - if (item.id !== undefined && item.id !== null) { - primaryKeyName = "id"; - primaryKeyValue = item.id; - } else if (item.ID !== undefined && item.ID !== null) { - primaryKeyName = "ID"; - primaryKeyValue = item.ID; - } else if (item.user_id !== undefined && item.user_id !== null) { - // user_info 테이블 등 user_id를 Primary Key로 사용하는 경우 - primaryKeyName = "user_id"; - primaryKeyValue = item.user_id; - } else { - // 테이블명_id 패턴 확인 (예: dept_id, item_id 등) - const tableIdKey = rightTableName ? `${rightTableName.replace(/_info$/, "")}_id` : ""; - if (tableIdKey && item[tableIdKey] !== undefined && item[tableIdKey] !== null) { - primaryKeyName = tableIdKey; - primaryKeyValue = item[tableIdKey]; + if (item.id !== undefined && item.id !== null) { + primaryKeyName = "id"; + primaryKeyValue = item.id; + } else if (item.ID !== undefined && item.ID !== null) { + primaryKeyName = "ID"; + primaryKeyValue = item.ID; + } else if (item.user_id !== undefined && item.user_id !== null) { + // user_info 테이블 등 user_id를 Primary Key로 사용하는 경우 + primaryKeyName = "user_id"; + primaryKeyValue = item.user_id; } else { - // 마지막으로 첫 번째 필드를 Primary Key로 간주 - const firstKey = Object.keys(item)[0]; - primaryKeyName = firstKey; - primaryKeyValue = item[firstKey]; + // 테이블명_id 패턴 확인 (예: dept_id, item_id 등) + const tableIdKey = rightTableName ? `${rightTableName.replace(/_info$/, "")}_id` : ""; + if (tableIdKey && item[tableIdKey] !== undefined && item[tableIdKey] !== null) { + primaryKeyName = tableIdKey; + primaryKeyValue = item[tableIdKey]; + } else { + // 마지막으로 첫 번째 필드를 Primary Key로 간주 + const firstKey = Object.keys(item)[0]; + primaryKeyName = firstKey; + primaryKeyValue = item[firstKey]; + } } - } - console.log("✅ 수정 모달 열기:", { - tableName: rightTableName, - primaryKeyName, - primaryKeyValue, - screenId: modalScreenId, - fullItem: item, - }); + // modalDataStore에도 저장 (호환성 유지) + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(rightTableName, [item]); + }); - // modalDataStore에도 저장 (호환성 유지) - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - useModalDataStore.getState().setData(rightTableName, [item]); - }); + // 🆕 groupByColumns 추출 + const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; - // 🆕 groupByColumns 추출 - const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; - - console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { - groupByColumns, - editButtonConfig: componentConfig.rightPanel?.editButton, - hasGroupByColumns: groupByColumns.length > 0, - }); - - // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) - window.dispatchEvent( - new CustomEvent("openScreenModal", { - detail: { - screenId: modalScreenId, - urlParams: { - mode: "edit", - editId: primaryKeyValue, - tableName: rightTableName, - ...(groupByColumns.length > 0 && { - groupByColumns: JSON.stringify(groupByColumns), - }), + // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: modalScreenId, + urlParams: { + mode: "edit", + editId: primaryKeyValue, + tableName: rightTableName, + ...(groupByColumns.length > 0 && { + groupByColumns: JSON.stringify(groupByColumns), + }), + }, }, - }, - }), - ); - - console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { - screenId: modalScreenId, - editId: primaryKeyValue, - tableName: rightTableName, - groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", - }); + }), + ); return; } @@ -2767,8 +2734,6 @@ export const SplitPanelLayoutComponent: React.FC cleanData.company_code = companyCode; } - console.log("📝 [SplitPanel] 커스텀 우측 패널 저장:", { tableName, primaryKey, data: cleanData }); - const response = await dataApi.updateRecord(tableName, primaryKey, cleanData); if (response.success) { @@ -2797,6 +2762,202 @@ export const SplitPanelLayoutComponent: React.FC } }, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]); + // 좌측 패널 컬럼 헤더 드래그 + const handleLeftColumnDragStart = useCallback((e: React.DragEvent, columnIndex: number) => { + setLeftDraggedColumnIndex(columnIndex); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `left-col-${columnIndex}`); + }, []); + const handleLeftColumnDragOver = useCallback((e: React.DragEvent, columnIndex: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setLeftDropTargetColumnIndex(columnIndex); + }, []); + const handleLeftColumnDragEnd = useCallback(() => { + setLeftDraggedColumnIndex(null); + setLeftDropTargetColumnIndex(null); + }, []); + const handleLeftColumnDrop = useCallback( + (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + const fromIdx = leftDraggedColumnIndex; + if (fromIdx === null || fromIdx === targetIndex) { + handleLeftColumnDragEnd(); + return; + } + const leftColumns = componentConfig.leftPanel?.columns || []; + const colNames = leftColumns + .filter((c: any) => typeof c === "string" || c.name || c.columnName) + .map((c: any) => (typeof c === "string" ? c : c.name || c.columnName)); + if ( + colNames.length > 0 && + fromIdx >= 0 && + fromIdx < colNames.length && + targetIndex >= 0 && + targetIndex < colNames.length + ) { + const reordered = [...colNames]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + setLeftColumnOrder(reordered); + } + handleLeftColumnDragEnd(); + }, + [leftDraggedColumnIndex, componentConfig, handleLeftColumnDragEnd], + ); + + // 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 컬럼 순서 변경) + const handleRightColumnDragStart = useCallback((e: React.DragEvent, columnIndex: number, source: "main" | number) => { + setRightDraggedColumnIndex(columnIndex); + setRightDragSource(source); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `col-${source}-${columnIndex}`); + }, []); + const handleRightColumnDragOver = useCallback((e: React.DragEvent, columnIndex: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setRightDropTargetColumnIndex(columnIndex); + }, []); + const handleRightColumnDragEnd = useCallback(() => { + setRightDraggedColumnIndex(null); + setRightDropTargetColumnIndex(null); + setRightDragSource(null); + }, []); + const handleRightColumnDrop = useCallback( + (e: React.DragEvent, targetIndex: number, source: "main" | number) => { + e.preventDefault(); + const fromIdx = rightDraggedColumnIndex; + if (fromIdx === null || rightDragSource !== source || fromIdx === targetIndex) { + handleRightColumnDragEnd(); + return; + } + + if (isDesignMode && onUpdateComponent) { + // 디자인 모드: config에 영구 저장 + const rightPanel = componentConfig.rightPanel || {}; + if (source === "main") { + const allColumns = rightPanel.columns || []; + const visibleColumns = allColumns.filter((c: any) => c.showInSummary !== false); + const hiddenColumns = allColumns.filter((c: any) => c.showInSummary === false); + if ( + fromIdx < 0 || + fromIdx >= visibleColumns.length || + targetIndex < 0 || + targetIndex >= visibleColumns.length + ) { + handleRightColumnDragEnd(); + return; + } + const reordered = [...visibleColumns]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + const columns = [...reordered, ...hiddenColumns]; + onUpdateComponent({ + ...component, + componentConfig: { + ...componentConfig, + rightPanel: { ...rightPanel, columns }, + }, + }); + } else { + const tabs = [...(rightPanel.additionalTabs || [])]; + const tabConfig = tabs[source]; + if (!tabConfig || !Array.isArray(tabConfig.columns)) { + handleRightColumnDragEnd(); + return; + } + const allTabCols = tabConfig.columns; + const visibleTabCols = allTabCols.filter((c: any) => c.showInSummary !== false); + const hiddenTabCols = allTabCols.filter((c: any) => c.showInSummary === false); + if ( + fromIdx < 0 || + fromIdx >= visibleTabCols.length || + targetIndex < 0 || + targetIndex >= visibleTabCols.length + ) { + handleRightColumnDragEnd(); + return; + } + const reordered = [...visibleTabCols]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + const columns = [...reordered, ...hiddenTabCols]; + const newTabs = tabs.map((t, i) => (i === source ? { ...t, columns } : t)); + onUpdateComponent({ + ...component, + componentConfig: { + ...componentConfig, + rightPanel: { ...rightPanel, additionalTabs: newTabs }, + }, + }); + } + } else { + // 런타임 모드: 컬럼 이름 순서로 로컬 상태 관리 + const key = String(source); + const rightPanel = componentConfig.rightPanel || {}; + let colNames: string[] = []; + + if (source === "main") { + const allColumns = rightPanel.columns || []; + colNames = allColumns.filter((c: any) => c.showInSummary !== false).map((c: any) => c.name); + } else if (typeof source === "number") { + const tabs = rightPanel.additionalTabs || []; + const tabConfig = tabs[source]; + if (tabConfig?.columns) { + colNames = tabConfig.columns.filter((c: any) => c.showInSummary !== false).map((c: any) => c.name); + } + } + + if (colNames.length > 0) { + setRuntimeColumnOrder((prev) => { + const currentOrder = prev[key] || colNames; + const reordered = [...currentOrder]; + if (fromIdx >= 0 && fromIdx < reordered.length && targetIndex >= 0 && targetIndex < reordered.length) { + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + } + return { ...prev, [key]: reordered }; + }); + } + } + handleRightColumnDragEnd(); + }, + [ + rightDraggedColumnIndex, + rightDragSource, + componentConfig, + component, + onUpdateComponent, + isDesignMode, + handleRightColumnDragEnd, + setRuntimeColumnOrder, + ], + ); + + // 런타임 컬럼 순서 적용 헬퍼 (이름 기반) + const applyRuntimeOrder = useCallback( + (columns: any[], source: "main" | number): any[] => { + const key = String(source); + const order = runtimeColumnOrder[key]; + if (!order || order.length === 0) return columns; + const colMap = new Map(columns.map((col) => [col.name, col])); + const result: any[] = []; + for (const name of order) { + const col = colMap.get(name); + if (col) { + result.push(col); + colMap.delete(name); + } + } + // order에 없는 나머지 컬럼 뒤에 추가 + for (const col of colMap.values()) { + result.push(col); + } + return result; + }, + [runtimeColumnOrder], + ); + // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { const tableName = @@ -2815,8 +2976,6 @@ export const SplitPanelLayoutComponent: React.FC } try { - console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData }); - // 프론트엔드 전용 필드 제거 (children, level 등) const cleanData = { ...editModalFormData }; delete cleanData.children; @@ -2833,7 +2992,6 @@ export const SplitPanelLayoutComponent: React.FC rightColumn: componentConfig.rightPanel.relation.rightColumn, oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn], }; - console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo); } const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload); @@ -2903,7 +3061,6 @@ export const SplitPanelLayoutComponent: React.FC // 우측 패널 + 중계 테이블 모드인 경우 if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { tableName = componentConfig.rightPanel.addConfig.targetTable; - console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } } @@ -2913,9 +3070,6 @@ export const SplitPanelLayoutComponent: React.FC if (!primaryKey && deleteModalItem && typeof deleteModalItem === "object") { // id가 없는 경우에만 전체 객체 전달 (복합키 테이블) primaryKey = deleteModalItem; - console.log("🔑 복합키: 전체 객체 전달", Object.keys(primaryKey)); - } else { - console.log("🔑 단일키 삭제: id =", primaryKey, "테이블 =", tableName); } if (!tableName || !primaryKey) { @@ -2928,16 +3082,6 @@ export const SplitPanelLayoutComponent: React.FC } try { - console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); - - // 🔍 중복 제거 설정 디버깅 - console.log("🔍 중복 제거 디버깅:", { - panel: deleteModalPanel, - dataFilter: componentConfig.rightPanel?.dataFilter, - deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, - enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, - }); - let result; // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 @@ -2947,7 +3091,6 @@ export const SplitPanelLayoutComponent: React.FC if (groupByColumn && deleteModalItem[groupByColumn]) { const groupValue = deleteModalItem[groupByColumn]; - console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); // groupByColumn 값으로 필터링하여 삭제 const filterConditions: Record = { @@ -2961,8 +3104,6 @@ export const SplitPanelLayoutComponent: React.FC filterConditions[rightColumn] = selectedLeftItem[leftColumn]; } - console.log("🗑️ 그룹 삭제 조건:", filterConditions); - // 그룹 삭제 API 호출 result = await dataApi.deleteGroupRecords(tableName, filterConditions); } else { @@ -3024,7 +3165,18 @@ export const SplitPanelLayoutComponent: React.FC variant: "destructive", }); } - }, [deleteModalPanel, deleteModalTableName, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, loadTabData, activeTabIndex]); + }, [ + deleteModalPanel, + deleteModalTableName, + componentConfig, + deleteModalItem, + toast, + selectedLeftItem, + loadLeftData, + loadRightData, + loadTabData, + activeTabIndex, + ]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback( @@ -3094,7 +3246,6 @@ export const SplitPanelLayoutComponent: React.FC if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) { const leftValue = selectedLeftItem[addConfig.leftPanelColumn]; finalData[addConfig.targetColumn] = leftValue; - console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`); } // 자동 채움 컬럼 추가 @@ -3102,7 +3253,6 @@ export const SplitPanelLayoutComponent: React.FC Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => { finalData[key] = value; }); - console.log("🔧 자동 채움 컬럼:", addConfig.autoFillColumns); } } else { // 일반 테이블 모드 @@ -3138,8 +3288,6 @@ export const SplitPanelLayoutComponent: React.FC } try { - console.log("📝 데이터 추가:", { tableName, data: finalData }); - const result = await dataApi.createRecord(tableName, finalData); if (result.success) { @@ -3302,7 +3450,6 @@ export const SplitPanelLayoutComponent: React.FC useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { - console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); // 현재 활성 탭 데이터 새로고침 (좌측 미선택 시에도 전체 데이터 로드) if (activeTabIndex === 0) { @@ -3398,10 +3545,18 @@ export const SplitPanelLayoutComponent: React.FC alignItems: "center", }} > -
- - {componentConfig.leftPanel?.title || "좌측 패널"} - +
+
+ + + {componentConfig.leftPanel?.title || "좌측 패널"} + + {!isDesignMode && ( + + {summedLeftData.length} + + )} +
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
)} - {/* 좌측 데이터 목록/테이블/커스텀 */} {componentConfig.leftPanel?.displayMode === "custom" ? ( // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 -
onSelectedRowsChange={handleLocalSelectedRowsChange} /> ) : ( -
- {componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => { - const isSelectedComp = selectedPanelComponentId === comp.id; - const isDraggingComp = draggingCompId === comp.id; - const isResizingComp = resizingCompId === comp.id; - - const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0); - const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0); - const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200); - const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100); - - const componentData = { - id: comp.id, - type: "component" as const, - componentType: comp.componentType, - label: comp.label, - position: comp.position || { x: 0, y: 0 }, - size: { width: displayWidth, height: displayHeight }, - componentConfig: comp.componentConfig || {}, - style: comp.style || {}, - tableName: comp.componentConfig?.tableName, - columnName: comp.componentConfig?.columnName, - webType: comp.componentConfig?.webType, - inputType: (comp as any).inputType || comp.componentConfig?.inputType, - }; +
+ {componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => { + const isSelectedComp = selectedPanelComponentId === comp.id; + const isDraggingComp = draggingCompId === comp.id; + const isResizingComp = resizingCompId === comp.id; - return ( + const displayX = isDraggingComp && dragPosition ? dragPosition.x : comp.position?.x || 0; + const displayY = isDraggingComp && dragPosition ? dragPosition.y : comp.position?.y || 0; + const displayWidth = isResizingComp && resizeSize ? resizeSize.width : comp.size?.width || 200; + const displayHeight = + isResizingComp && resizeSize ? resizeSize.height : comp.size?.height || 100; + + const componentData = { + id: comp.id, + type: "component" as const, + componentType: comp.componentType, + label: comp.label, + position: comp.position || { x: 0, y: 0 }, + size: { width: displayWidth, height: displayHeight }, + componentConfig: comp.componentConfig || {}, + style: comp.style || {}, + tableName: comp.componentConfig?.tableName, + columnName: comp.componentConfig?.columnName, + webType: comp.componentConfig?.webType, + inputType: (comp as any).inputType || comp.componentConfig?.inputType, + }; + + return (
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
handlePanelDragStart(e, "left", comp)} >
- - + + {comp.label || comp.componentType}
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */} -
}} > {/* 🆕 컨테이너 컴포넌트(탭, 분할 패널)는 드롭 이벤트를 받을 수 있어야 함 */} -
+
}} // 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함 onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { - console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id }); // 탭 내 컴포넌트 선택 상태 업데이트 setNestedTabSelectedCompId(compId); // 부모 분할 패널 정보와 함께 전역 이벤트 발생 @@ -3613,23 +3770,23 @@ export const SplitPanelLayoutComponent: React.FC selectedTabComponentId={nestedTabSelectedCompId} />
- + {/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */} {isSelectedComp && ( <> {/* 오른쪽 가장자리 (너비 조절) */}
handlePanelResizeStart(e, "left", comp, "e")} /> {/* 아래 가장자리 (높이 조절) */}
handlePanelResizeStart(e, "left", comp, "s")} /> {/* 오른쪽 아래 모서리 (너비+높이 조절) */}
handlePanelResizeStart(e, "left", comp, "se")} /> @@ -3637,17 +3794,15 @@ export const SplitPanelLayoutComponent: React.FC
); - })} -
+ })} +
) ) : ( // 컴포넌트가 없을 때 드롭 영역 표시 -
- -

- 커스텀 모드 -

-

+

+ +

커스텀 모드

+

{isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"}

@@ -3659,21 +3814,27 @@ export const SplitPanelLayoutComponent: React.FC {isDesignMode ? ( // 디자인 모드: 샘플 테이블
- +
- - - + + + - - + + - + @@ -3690,12 +3851,6 @@ export const SplitPanelLayoutComponent: React.FC (() => { // 🆕 그룹별 합산된 데이터 사용 const dataSource = summedLeftData; - console.log( - "🔍 [테이블모드 렌더링] dataSource 개수:", - dataSource.length, - "leftGroupSumConfig:", - leftGroupSumConfig, - ); // 🔧 로컬 검색 필터 적용 const filteredData = leftSearchQuery @@ -3734,10 +3889,11 @@ export const SplitPanelLayoutComponent: React.FC })); // 🔧 그룹화된 데이터 렌더링 - const hasGroupedLeftActions = !isDesignMode && ( - (componentConfig.leftPanel?.showEdit !== false) || - (componentConfig.leftPanel?.showDelete !== false) - ); + const hasGroupedLeftActions = + !isDesignMode && + (componentConfig.leftPanel?.showEdit !== false || + componentConfig.leftPanel?.showDelete !== false); + const canDragLeftGroupedColumns = !isDesignMode && columnsToShow.length > 1; if (groupedLeftData.length > 0) { return (
@@ -3746,28 +3902,52 @@ export const SplitPanelLayoutComponent: React.FC
{group.groupKey} ({group.count}개)
-
컬럼 1컬럼 2컬럼 3 + 컬럼 1 + + 컬럼 2 + + 컬럼 3 +
데이터 1-1 데이터 1-2 데이터 1-3
데이터 2-1 데이터 2-2 데이터 2-3
+
- {columnsToShow.map((col, idx) => ( - - ))} + {columnsToShow.map((col, idx) => { + const isDropTarget = + canDragLeftGroupedColumns && leftDropTargetColumnIndex === idx; + const isDragging = canDragLeftGroupedColumns && leftDraggedColumnIndex === idx; + return ( + + ); + })} {hasGroupedLeftActions && ( - + )} - + {group.items.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const itemId = item[sourceColumn] || item.id || item.ID || idx; @@ -3786,8 +3966,12 @@ export const SplitPanelLayoutComponent: React.FC {columnsToShow.map((col, colIdx) => ( ))} {hasGroupedLeftActions && ( -
- {col.label} - + canDragLeftGroupedColumns && handleLeftColumnDragStart(e, idx) + } + onDragOver={(e) => + canDragLeftGroupedColumns && handleLeftColumnDragOver(e, idx) + } + onDragEnd={handleLeftColumnDragEnd} + onDrop={(e) => canDragLeftGroupedColumns && handleLeftColumnDrop(e, idx)} + > + {canDragLeftGroupedColumns && ( + + )} + {col.label} + -
{formatCellValue( col.name, @@ -3798,28 +3982,28 @@ export const SplitPanelLayoutComponent: React.FC +
- {(componentConfig.leftPanel?.showEdit !== false) && ( + {componentConfig.leftPanel?.showEdit !== false && ( )} - {(componentConfig.leftPanel?.showDelete !== false) && ( + {componentConfig.leftPanel?.showDelete !== false && ( )}
@@ -3837,34 +4021,54 @@ export const SplitPanelLayoutComponent: React.FC } // 🔧 일반 테이블 렌더링 (그룹화 없음) - const hasLeftTableActions = !isDesignMode && ( - (componentConfig.leftPanel?.showEdit !== false) || - (componentConfig.leftPanel?.showDelete !== false) - ); + const hasLeftTableActions = + !isDesignMode && + (componentConfig.leftPanel?.showEdit !== false || + componentConfig.leftPanel?.showDelete !== false); + const canDragLeftColumns = !isDesignMode && columnsToShow.length > 1; return (
- - +
+ - {columnsToShow.map((col, idx) => ( - - ))} + {columnsToShow.map((col, idx) => { + const isDropTarget = canDragLeftColumns && leftDropTargetColumnIndex === idx; + const isDragging = canDragLeftColumns && leftDraggedColumnIndex === idx; + return ( + + ); + })} {hasLeftTableActions && ( - + )} - + {filteredData.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const itemId = item[sourceColumn] || item.id || item.ID || idx; @@ -3883,8 +4087,12 @@ export const SplitPanelLayoutComponent: React.FC {columnsToShow.map((col, colIdx) => ( ))} {hasLeftTableActions && ( -
- {col.label} - canDragLeftColumns && handleLeftColumnDragStart(e, idx)} + onDragOver={(e) => canDragLeftColumns && handleLeftColumnDragOver(e, idx)} + onDragEnd={handleLeftColumnDragEnd} + onDrop={(e) => canDragLeftColumns && handleLeftColumnDrop(e, idx)} + > + {canDragLeftColumns && ( + + )} + {col.label} + -
{formatCellValue( col.name, @@ -3895,28 +4103,28 @@ export const SplitPanelLayoutComponent: React.FC +
- {(componentConfig.leftPanel?.showEdit !== false) && ( + {componentConfig.leftPanel?.showEdit !== false && ( )} - {(componentConfig.leftPanel?.showDelete !== false) && ( + {componentConfig.leftPanel?.showDelete !== false && ( )}
@@ -3976,12 +4184,6 @@ export const SplitPanelLayoutComponent: React.FC (() => { // 🆕 그룹별 합산된 데이터 사용 const dataToDisplay = summedLeftData; - console.log( - "🔍 [렌더링] dataToDisplay 개수:", - dataToDisplay.length, - "leftGroupSumConfig:", - leftGroupSumConfig, - ); // 검색 필터링 (클라이언트 사이드) const filteredLeftData = leftSearchQuery @@ -4008,13 +4210,6 @@ export const SplitPanelLayoutComponent: React.FC const configuredColumns = componentConfig.leftPanel?.columns || []; let displayFields: { label: string; value: any }[] = []; - // 디버그 로그 - if (index === 0) { - console.log("🔍 좌측 패널 표시 로직:"); - console.log(" - 설정된 표시 컬럼:", configuredColumns); - console.log(" - item keys:", Object.keys(item)); - } - if (configuredColumns.length > 0) { // 🔧 "표시할 컬럼 선택"에서 설정한 컬럼 사용 displayFields = configuredColumns.slice(0, 2).map((col: any) => { @@ -4038,10 +4233,6 @@ export const SplitPanelLayoutComponent: React.FC value: displayValue, }; }); - - if (index === 0) { - console.log(" ✅ 설정된 컬럼 기반 표시:", displayFields); - } } else { // 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시 const keys = Object.keys(item).filter( @@ -4062,10 +4253,6 @@ export const SplitPanelLayoutComponent: React.FC value: displayValue, }; }); - - if (index === 0) { - console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields); - } } const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`; @@ -4093,9 +4280,9 @@ export const SplitPanelLayoutComponent: React.FC {hasChildren ? (
{isExpanded ? ( - + ) : ( - + )}
) : ( @@ -4114,30 +4301,30 @@ export const SplitPanelLayoutComponent: React.FC {!isDesignMode && (
{/* 수정 버튼 (showEdit 활성화 시에만 표시) */} - {(componentConfig.leftPanel?.showEdit !== false) && ( + {componentConfig.leftPanel?.showEdit !== false && ( )} {/* 삭제 버튼 (showDelete 활성화 시에만 표시) */} - {(componentConfig.leftPanel?.showDelete !== false) && ( + {componentConfig.leftPanel?.showDelete !== false && ( )} @@ -4148,10 +4335,10 @@ export const SplitPanelLayoutComponent: React.FC e.stopPropagation(); handleItemAddClick(item); }} - className="rounded p-1 transition-colors hover:bg-muted/80" + className="hover:bg-muted/80 rounded p-1 transition-colors" title="하위 항목 추가" > - + )}
@@ -4190,7 +4377,8 @@ export const SplitPanelLayoutComponent: React.FC {/* 좌측 페이징 UI */} - {leftPaginationEnabled && !isDesignMode && ( + {leftPaginationEnabled && + !isDesignMode && renderPaginationBar({ currentPage: leftCurrentPage, totalPages: leftTotalPages, @@ -4202,29 +4390,29 @@ export const SplitPanelLayoutComponent: React.FC onPageSizeChange: handleLeftPageSizeChange, commitPageInput: commitLeftPageInput, loading: isLoadingLeft, - }) - )} + })} - {/* 리사이저 */} + {/* 리사이저: 6px 너비, 그립 핸들(2x28px bar), hover 시 primary 하이라이트 */} {resizable && (
- +
)} {/* 우측 패널 */}
alignItems: "center", }} > -
-
- {/* 탭이 없으면 제목만, 있으면 탭으로 전환 */} +
+
+ + {/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */} {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
) : ( - + {componentConfig.rightPanel?.title || "우측 패널"} )} + {!isDesignMode && ( + + {activeTabIndex === 0 + ? Array.isArray(rightData) + ? rightData.length + : rightData + ? 1 + : 0 + : (tabsData[activeTabIndex]?.length ?? 0)} + + )}
{!isDesignMode && (
@@ -4309,13 +4509,15 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 추가 탭 컨텐츠 */} {activeTabIndex > 0 ? ( @@ -4357,20 +4559,46 @@ export const SplitPanelLayoutComponent: React.FC // 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기) if (currentTabConfig?.displayMode === "table") { const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; - // showInSummary가 false가 아닌 것만 메인 테이블에 표시 - const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + const tabIndex = activeTabIndex - 1; + let tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + if (!isDesignMode) { + tabSummaryColumns = applyRuntimeOrder(tabSummaryColumns, tabIndex); + } + const canDragTabColumns = tabSummaryColumns.length > 0; return (
- - - {tabSummaryColumns.map((col: any) => ( - - ))} + + + {tabSummaryColumns.map((col: any, idx: number) => { + const isDropTarget = rightDragSource === tabIndex && rightDropTargetColumnIndex === idx; + const isDragging = rightDragSource === tabIndex && rightDraggedColumnIndex === idx; + return ( + + ); + })} {hasTabActions && ( - + )} @@ -4381,23 +4609,39 @@ export const SplitPanelLayoutComponent: React.FC // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만) const tabDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); - const tabAllValues: [string, any, string][] = tabDetailColumns.length > 0 - ? tabDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) - : Object.entries(item) - .filter(([, v]) => v !== null && v !== undefined && v !== "") - .map(([k, v]) => [k, v, ""] as [string, any, string]); + const tabAllValues: [string, any, string][] = + tabDetailColumns.length > 0 + ? tabDetailColumns.map( + (col: any) => + [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [ + string, + any, + string, + ], + ) + : Object.entries(item) + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .map(([k, v]) => [k, v, ""] as [string, any, string]); return ( toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {tabSummaryColumns.map((col: any) => ( - -
- {col.label || col.name} -
canDragTabColumns && handleRightColumnDragStart(e, idx, tabIndex)} + onDragOver={(e) => canDragTabColumns && handleRightColumnDragOver(e, idx)} + onDragEnd={handleRightColumnDragEnd} + onDrop={(e) => canDragTabColumns && handleRightColumnDrop(e, idx, tabIndex)} + > + {canDragTabColumns && ( + + )} + {col.label || col.name} + 작업 + 작업 +
+ {col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( @@ -4412,7 +4656,10 @@ export const SplitPanelLayoutComponent: React.FC
{currentTabConfig?.showEdit && ( - )} {currentTabConfig?.showDelete && ( -
+
상세 정보
{tabAllValues.map(([key, value, label]) => { - const displayValue = (value === null || value === undefined || value === "") - ? "-" - : formatCellValue(key, value, rightCategoryMappings); + const displayValue = + value === null || value === undefined || value === "" + ? "-" + : formatCellValue(key, value, rightCategoryMappings); return ( - + ); })} @@ -4474,20 +4730,49 @@ export const SplitPanelLayoutComponent: React.FC // 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시) { const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; - // showInSummary가 false가 아닌 것만 메인 테이블에 표시 - const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + const listTabIndex = activeTabIndex - 1; + let listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + if (!isDesignMode) { + listSummaryColumns = applyRuntimeOrder(listSummaryColumns, listTabIndex); + } + const canDragListTabColumns = listSummaryColumns.length > 0; return (
{label || getColumnLabel(key)} {displayValue} + {displayValue} +
- - - {listSummaryColumns.map((col: any) => ( - - ))} + + + {listSummaryColumns.map((col: any, idx: number) => { + const isDropTarget = + rightDragSource === listTabIndex && rightDropTargetColumnIndex === idx; + const isDragging = rightDragSource === listTabIndex && rightDraggedColumnIndex === idx; + return ( + + ); + })} {hasTabActions && ( - + )} @@ -4497,23 +4782,39 @@ export const SplitPanelLayoutComponent: React.FC const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`); // showInDetail이 false가 아닌 것만 상세에 표시 const listDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); - const tabAllValues: [string, any, string][] = listDetailColumns.length > 0 - ? listDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) - : Object.entries(item) - .filter(([, v]) => v !== null && v !== undefined && v !== "") - .map(([k, v]) => [k, v, ""] as [string, any, string]); + const tabAllValues: [string, any, string][] = + listDetailColumns.length > 0 + ? listDetailColumns.map( + (col: any) => + [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [ + string, + any, + string, + ], + ) + : Object.entries(item) + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .map(([k, v]) => [k, v, ""] as [string, any, string]); return ( toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {listSummaryColumns.map((col: any) => ( - {isTabExpanded && ( -
- {col.label || col.name} -
+ canDragListTabColumns && handleRightColumnDragStart(e, idx, listTabIndex) + } + onDragOver={(e) => canDragListTabColumns && handleRightColumnDragOver(e, idx)} + onDragEnd={handleRightColumnDragEnd} + onDrop={(e) => canDragListTabColumns && handleRightColumnDrop(e, idx, listTabIndex)} + > + {canDragListTabColumns && ( + + )} + {col.label || col.name} + 작업 + 작업 +
+ {col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( @@ -4528,15 +4829,27 @@ export const SplitPanelLayoutComponent: React.FC
{currentTabConfig?.showEdit && ( - )} {currentTabConfig?.showDelete && ( - @@ -4547,20 +4860,27 @@ export const SplitPanelLayoutComponent: React.FC
+
상세 정보
{tabAllValues.map(([key, value, label]) => { - const displayValue = (value === null || value === undefined || value === "") - ? "-" : formatCellValue(key, value, rightCategoryMappings); + const displayValue = + value === null || value === undefined || value === "" + ? "-" + : formatCellValue(key, value, rightCategoryMappings); return ( - + ); })} @@ -4589,198 +4909,198 @@ export const SplitPanelLayoutComponent: React.FC ) : ( -
- {/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */} - {componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? ( - !isDesignMode ? ( - { - setCustomLeftSelectedData((prev: Record) => ({ ...prev, [fieldName]: value })); - }} - tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName} - menuObjid={(props as any).menuObjid} - screenId={(props as any).screenId} - userId={(props as any).userId} - userName={(props as any).userName} - companyCode={companyCode} - allComponents={(props as any).allComponents} - selectedRowsData={localSelectedRowsData} - onSelectedRowsChange={handleLocalSelectedRowsChange} - /> - ) : ( -
- {componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => { - const isSelectedComp = selectedPanelComponentId === comp.id; - const isDraggingComp = draggingCompId === comp.id; - const isResizingComp = resizingCompId === comp.id; - - const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0); - const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0); - const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200); - const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100); - - const componentData = { - id: comp.id, - type: "component" as const, - componentType: comp.componentType, - label: comp.label, - position: comp.position || { x: 0, y: 0 }, - size: { width: displayWidth, height: displayHeight }, - componentConfig: comp.componentConfig || {}, - style: comp.style || {}, - tableName: comp.componentConfig?.tableName, - columnName: comp.componentConfig?.columnName, - webType: comp.componentConfig?.webType, - inputType: (comp as any).inputType || comp.componentConfig?.inputType, - }; +
+ {/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */} + {componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? ( + !isDesignMode ? ( + { + setCustomLeftSelectedData((prev: Record) => ({ ...prev, [fieldName]: value })); + }} + tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName} + menuObjid={(props as any).menuObjid} + screenId={(props as any).screenId} + userId={(props as any).userId} + userName={(props as any).userName} + companyCode={companyCode} + allComponents={(props as any).allComponents} + selectedRowsData={localSelectedRowsData} + onSelectedRowsChange={handleLocalSelectedRowsChange} + /> + ) : ( +
+ {componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => { + const isSelectedComp = selectedPanelComponentId === comp.id; + const isDraggingComp = draggingCompId === comp.id; + const isResizingComp = resizingCompId === comp.id; - return ( -
{ - e.stopPropagation(); - if (comp.componentType !== "v2-tabs-widget") { - setNestedTabSelectedCompId(undefined); - } - setInternalSelectedCompId(comp.id); - onSelectPanelComponent?.("right", comp.id, comp); - }} - > + const displayX = isDraggingComp && dragPosition ? dragPosition.x : comp.position?.x || 0; + const displayY = isDraggingComp && dragPosition ? dragPosition.y : comp.position?.y || 0; + const displayWidth = + isResizingComp && resizeSize ? resizeSize.width : comp.size?.width || 200; + const displayHeight = + isResizingComp && resizeSize ? resizeSize.height : comp.size?.height || 100; + + const componentData = { + id: comp.id, + type: "component" as const, + componentType: comp.componentType, + label: comp.label, + position: comp.position || { x: 0, y: 0 }, + size: { width: displayWidth, height: displayHeight }, + componentConfig: comp.componentConfig || {}, + style: comp.style || {}, + tableName: comp.componentConfig?.tableName, + columnName: comp.componentConfig?.columnName, + webType: comp.componentConfig?.webType, + inputType: (comp as any).inputType || comp.componentConfig?.inputType, + }; + + return (
handlePanelDragStart(e, "right", comp)} - > -
- - - {comp.label || comp.componentType} - -
-
- - -
-
- -
{ + e.stopPropagation(); + if (comp.componentType !== "v2-tabs-widget") { + setNestedTabSelectedCompId(undefined); + } + setInternalSelectedCompId(comp.id); + onSelectPanelComponent?.("right", comp.id, comp); }} > -
- { - handleNestedComponentUpdate("right", comp.id, updatedComp); - }} - onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { - setNestedTabSelectedCompId(compId); - const event = new CustomEvent("nested-tab-component-select", { - detail: { - tabsComponentId: comp.id, - tabId, - componentId: compId, - component: tabComp, - parentSplitPanelId: component.id, - parentPanelSide: "right", - }, - }); - window.dispatchEvent(event); - }} - selectedTabComponentId={nestedTabSelectedCompId} - /> +
handlePanelDragStart(e, "right", comp)} + > +
+ + + {comp.label || comp.componentType} + +
+
+ + +
- - {isSelectedComp && ( - <> -
handlePanelResizeStart(e, "right", comp, "e")} + +
+
+ { + handleNestedComponentUpdate("right", comp.id, updatedComp); + }} + onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { + setNestedTabSelectedCompId(compId); + const event = new CustomEvent("nested-tab-component-select", { + detail: { + tabsComponentId: comp.id, + tabId, + componentId: compId, + component: tabComp, + parentSplitPanelId: component.id, + parentPanelSide: "right", + }, + }); + window.dispatchEvent(event); + }} + selectedTabComponentId={nestedTabSelectedCompId} /> -
handlePanelResizeStart(e, "right", comp, "s")} - /> -
handlePanelResizeStart(e, "right", comp, "se")} - /> - - )} +
+ + {isSelectedComp && ( + <> +
handlePanelResizeStart(e, "right", comp, "e")} + /> +
handlePanelResizeStart(e, "right", comp, "s")} + /> +
handlePanelResizeStart(e, "right", comp, "se")} + /> + + )} +
-
- ); - })} -
- ) - ) : ( - // 컴포넌트가 없을 때 드롭 영역 표시 -
- -

- 커스텀 모드 -

-

- {isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"} -

-
- )} -
+ ); + })} +
+ ) + ) : ( + // 컴포넌트가 없을 때 드롭 영역 표시 +
+ +

커스텀 모드

+

+ {isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"} +

+
+ )} +
) ) : isLoadingRight ? ( // 로딩 중 @@ -4845,7 +5165,6 @@ export const SplitPanelLayoutComponent: React.FC _isKeyColumn: true, // 구분용 플래그 })); columnsToShow = [...keyColsToAdd, ...columnsToShow]; - console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns); } } } else { @@ -4862,45 +5181,110 @@ export const SplitPanelLayoutComponent: React.FC })); } + // 런타임 컬럼 순서 적용 + if (!isDesignMode && runtimeColumnOrder["main"]) { + const keyColCount = columnsToShow.filter((c: any) => c._isKeyColumn).length; + const keyCols = columnsToShow.slice(0, keyColCount); + const dataCols = columnsToShow.slice(keyColCount); + columnsToShow = [...keyCols, ...applyRuntimeOrder(dataCols, "main")]; + } + + // 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤) + const rightTotalColWidth = columnsToShow.reduce((sum, col) => { + const w = col.width && col.width <= 100 ? col.width : 0; + return sum + w; + }, 0); + + const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length; + const canDragRightColumns = displayColumns.length > 0; + return (
{label || getColumnLabel(key)} {displayValue} + {displayValue} +
- - {columnsToShow.map((col, idx) => ( - - ))} - {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} - {!isDesignMode && - ((componentConfig.rightPanel?.editButton?.enabled ?? true) || - (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - + {columnsToShow.map((col, idx) => { + const configColIndex = idx - rightConfigColumnStart; + const isDraggable = canDragRightColumns && !col._isKeyColumn; + const isDropTarget = + rightDragSource === "main" && rightDropTargetColumnIndex === configColIndex; + const isDragging = + rightDragSource === "main" && rightDraggedColumnIndex === configColIndex; + return ( + + ); + })} + {(() => { + const rightEditVisible = + (componentConfig.rightPanel?.showEdit ?? + componentConfig.rightPanel?.editButton?.enabled) !== false; + const rightDeleteVisible = + (componentConfig.rightPanel?.showDelete ?? + componentConfig.rightPanel?.deleteButton?.enabled) !== false; + return !isDesignMode && (rightEditVisible || rightDeleteVisible) ? ( + - )} + ) : null; + })()} {filteredData.map((item, idx) => { const itemId = item.id || item.ID || idx; + const rightEditVisible = + (componentConfig.rightPanel?.showEdit ?? + componentConfig.rightPanel?.editButton?.enabled) !== false; + const rightDeleteVisible = + (componentConfig.rightPanel?.showDelete ?? + componentConfig.rightPanel?.deleteButton?.enabled) !== false; return ( - + {columnsToShow.map((col, colIdx) => ( ))} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} - {!isDesignMode && - ((componentConfig.rightPanel?.editButton?.enabled ?? true) || - (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - - )} + {!isDesignMode && (rightEditVisible || rightDeleteVisible) && ( + + )} ); })} @@ -4963,7 +5345,13 @@ export const SplitPanelLayoutComponent: React.FC { // 표시 컬럼 결정 const rightColumns = componentConfig.rightPanel?.columns; - let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean; width?: number }[] = []; + let columnsToDisplay: { + name: string; + label: string; + format?: string; + bold?: boolean; + width?: number; + }[] = []; if (rightColumns && rightColumns.length > 0) { // showInSummary가 false가 아닌 것만 메인 테이블에 표시 @@ -4986,27 +5374,38 @@ export const SplitPanelLayoutComponent: React.FC })); } - const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); - const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); + const hasEditButton = + !isDesignMode && + (componentConfig.rightPanel?.showEdit ?? componentConfig.rightPanel?.editButton?.enabled) !== + false; + const hasDeleteButton = + !isDesignMode && + (componentConfig.rightPanel?.showDelete ?? componentConfig.rightPanel?.deleteButton?.enabled) !== + false; const hasActions = hasEditButton || hasDeleteButton; return filteredData.length > 0 ? (
- {col.label} - +
+ isDraggable && handleRightColumnDragStart(e, configColIndex, "main") + } + onDragOver={(e) => isDraggable && handleRightColumnDragOver(e, configColIndex)} + onDragEnd={handleRightColumnDragEnd} + onDrop={(e) => isDraggable && handleRightColumnDrop(e, configColIndex, "main")} + > + {isDraggable && ( + + )} + {col.label} + 작업
{col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) @@ -4913,42 +5297,40 @@ export const SplitPanelLayoutComponent: React.FC -
- {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( - - )} - {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( - - )} -
-
+
+ {rightEditVisible && ( + + )} + {rightDeleteVisible && ( + + )} +
+
- - + + {columnsToDisplay.map((col) => ( ))} {hasActions && ( - + )} @@ -5034,13 +5433,21 @@ export const SplitPanelLayoutComponent: React.FC toggleRightItemExpansion(itemId)} > {columnsToDisplay.map((col) => ( - ))} {hasActions && ( - -
{col.label} 작업 + 작업 +
+ {formatCellValue( col.name, getEntityJoinValue(item, col.name), @@ -5050,10 +5457,13 @@ export const SplitPanelLayoutComponent: React.FC -
+
+
{hasEditButton && ( - )} {hasDeleteButton && ( -
+
상세 정보
@@ -5087,15 +5503,18 @@ export const SplitPanelLayoutComponent: React.FC {allValues.map(([key, value, label]) => { const colConfig = rightColumns?.find((c) => c.name === key); const format = colConfig?.format; - const displayValue = (value === null || value === undefined || value === "") - ? "-" - : formatCellValue(key, value, rightCategoryMappings, format); + const displayValue = + value === null || value === undefined || value === "" + ? "-" + : formatCellValue(key, value, rightCategoryMappings, format); return ( - + ); })} @@ -5113,17 +5532,17 @@ export const SplitPanelLayoutComponent: React.FC ) : ( -
- {rightSearchQuery ? ( - <> -

검색 결과가 없습니다.

-

다른 검색어를 입력해보세요.

- - ) : ( - "관련 데이터가 없습니다." - )} -
- ); +
+ {rightSearchQuery ? ( + <> +

검색 결과가 없습니다.

+

다른 검색어를 입력해보세요.

+ + ) : ( + "관련 데이터가 없습니다." + )} +
+ ); } })() ) : ( @@ -5133,50 +5552,70 @@ export const SplitPanelLayoutComponent: React.FC let displayEntries: [string, any, string][] = []; if (rightColumns && rightColumns.length > 0) { - console.log("🔍 [디버깅] 상세 모드 표시 로직:"); - console.log(" 📋 rightData 전체:", rightData); - console.log(" 📋 rightData keys:", Object.keys(rightData)); - console.log( - " ⚙️ 설정된 컬럼:", - rightColumns.map((c) => `${c.name} (${c.label})`), - ); - // 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만) displayEntries = rightColumns .filter((col) => col.showInDetail !== false) .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) let value = rightData[col.name]; - console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); if (value === undefined && col.name.includes(".")) { const columnName = col.name.split(".").pop(); value = rightData[columnName || ""]; - console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); } return [col.name, value, col.label] as [string, any, string]; - }) -; // 설정된 컬럼은 null/empty여도 항상 표시 - - console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); + }); } else { // 설정 없으면 모든 컬럼 표시 displayEntries = Object.entries(rightData) .filter(([_, value]) => value !== null && value !== undefined && value !== "") .map(([key, value]) => [key, value, ""] as [string, any, string]); - console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); } + const hasDetailEditButton = + !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); + const hasDetailDeleteButton = + !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); + return (
+ {(hasDetailEditButton || hasDetailDeleteButton) && ( +
+ {hasDetailEditButton && ( + + )} + {hasDetailDeleteButton && ( + + )} +
+ )} {displayEntries.map(([key, value, label]) => (
{label || getColumnLabel(key)}
- {(value === null || value === undefined || value === "") ? - : String(value)} + {value === null || value === undefined || value === "" ? ( + - + ) : ( + String(value) + )}
))} @@ -5226,29 +5665,34 @@ export const SplitPanelLayoutComponent: React.FC {/* 우측/탭 페이징 UI */} - {rightPaginationEnabled && !isDesignMode && renderPaginationBar({ - currentPage: rightPagState.currentPage, - totalPages: rightPagState.totalPages, - total: rightPagState.total, - pageSize: rightPagState.pageSize, - pageInput: rightPageInput, - setPageInput: setRightPageInput, - onPageChange: (p) => { - if (rightPagState.isTab) { - setTabsPagination((prev) => ({ - ...prev, - [activeTabIndex]: { ...(prev[activeTabIndex] || { currentPage: 1, totalPages: 1, total: 0, pageSize: rightPageSize }), currentPage: p }, - })); - setRightPageInput(String(p)); - loadTabData(activeTabIndex, selectedLeftItem, p); - } else { - handleRightPageChange(p); - } - }, - onPageSizeChange: handleRightPageSizeChange, - commitPageInput: commitRightPageInput, - loading: isLoadingRight || (tabsLoading[activeTabIndex] ?? false), - })} + {rightPaginationEnabled && + !isDesignMode && + renderPaginationBar({ + currentPage: rightPagState.currentPage, + totalPages: rightPagState.totalPages, + total: rightPagState.total, + pageSize: rightPagState.pageSize, + pageInput: rightPageInput, + setPageInput: setRightPageInput, + onPageChange: (p) => { + if (rightPagState.isTab) { + setTabsPagination((prev) => ({ + ...prev, + [activeTabIndex]: { + ...(prev[activeTabIndex] || { currentPage: 1, totalPages: 1, total: 0, pageSize: rightPageSize }), + currentPage: p, + }, + })); + setRightPageInput(String(p)); + loadTabData(activeTabIndex, selectedLeftItem, p); + } else { + handleRightPageChange(p); + } + }, + onPageSizeChange: handleRightPageSizeChange, + commitPageInput: commitRightPageInput, + loading: isLoadingRight || (tabsLoading[activeTabIndex] ?? false), + })}
@@ -5302,7 +5746,7 @@ export const SplitPanelLayoutComponent: React.FC
}} /> )} -
); }; diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index 69bcc9cd..3a7b4dad 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -35,7 +35,11 @@ interface SingleTableWithStickyProps { editingValue?: string; onEditingValueChange?: (value: string) => void; onEditKeyDown?: (e: React.KeyboardEvent) => void; - editInputRef?: React.RefObject; + onEditSave?: () => void; + editInputRef?: React.RefObject; + // 인라인 편집 타입별 옵션 (select/category/code, number, date 지원) + columnMeta?: Record; + categoryMappings?: Record>; // 검색 하이라이트 관련 props searchHighlights?: Set; currentSearchIndex?: number; @@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC = ({ editingValue, onEditingValueChange, onEditKeyDown, + onEditSave, editInputRef, + columnMeta, + categoryMappings, // 검색 하이라이트 관련 props searchHighlights, currentSearchIndex = 0, @@ -102,9 +109,10 @@ export const SingleTableWithSticky: React.FC = ({ }} > - + {actualColumns.map((column, colIndex) => { // 왼쪽 고정 컬럼들의 누적 너비 계산 const leftFixedWidth = actualColumns @@ -125,10 +133,10 @@ export const SingleTableWithSticky: React.FC = ({ key={column.columnName} className={cn( column.columnName === "__checkbox__" - ? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2" - : "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm", + ? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2" + : "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs", `text-${column.align}`, - column.sortable && "hover:bg-primary/10", + column.sortable && "hover:bg-muted/50", // 고정 컬럼 스타일 column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm", column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm", @@ -136,28 +144,33 @@ export const SingleTableWithSticky: React.FC = ({ isDesignMode && column.hidden && "bg-muted/50 opacity-40", )} style={{ - width: getColumnWidth(column), - minWidth: "100px", // 최소 너비 보장 - maxWidth: "300px", // 최대 너비 제한 + width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column), + minWidth: column.columnName === "__checkbox__" ? "48px" : "100px", + maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px", boxSizing: "border-box", overflow: "hidden", textOverflow: "ellipsis", - whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 - backgroundColor: "hsl(var(--background))", - // sticky 위치 설정 + whiteSpace: "nowrap", + backgroundColor: "hsl(var(--muted) / 0.4)", ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), }} onClick={() => column.sortable && sortHandler(column.columnName)} > -
+
{column.columnName === "__checkbox__" ? ( checkboxConfig.selectAll && ( ) ) : ( @@ -221,8 +234,9 @@ export const SingleTableWithSticky: React.FC = ({ handleRowClick?.(row, index, e)} > @@ -266,9 +280,10 @@ export const SingleTableWithSticky: React.FC = ({ highlightArray[currentSearchIndex] === cellKey; // formatCellValue 결과 (이미지 등 JSX 반환 가능) - const rawCellValue = - formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; - // 이미지 등 JSX 반환 여부 확인 + const formattedValue = formatCellValue(row[column.columnName], column.format, column.columnName, row); + const rawCellValue = (formattedValue === null || formattedValue === undefined || formattedValue === "") + ? - + : formattedValue; const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue); // 셀 값에서 검색어 하이라이트 렌더링 @@ -317,26 +332,22 @@ export const SingleTableWithSticky: React.FC = ({ key={`cell-${column.columnName}`} id={isCurrentSearchResult ? "current-search-result" : undefined} className={cn( - "text-foreground h-10 px-3 py-1.5 align-middle text-xs transition-colors sm:px-4 sm:py-2 sm:text-sm", - // 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지) + "text-foreground h-10 align-middle text-[11px] transition-colors", + column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", !isReactElement && "whitespace-nowrap", - `text-${column.align}`, - // 고정 컬럼 스타일 + column.columnName !== "__checkbox__" && `text-${column.align}`, column.fixed === "left" && "border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm", column.fixed === "right" && "border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm", - // 편집 가능 셀 스타일 onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text", )} style={{ - width: getColumnWidth(column), - minWidth: "100px", // 최소 너비 보장 - maxWidth: "300px", // 최대 너비 제한 + width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column), + minWidth: column.columnName === "__checkbox__" ? "48px" : "100px", + maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px", boxSizing: "border-box", - // 이미지 셀은 overflow 허용 ...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }), - // sticky 위치 설정 ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), }} @@ -350,15 +361,20 @@ export const SingleTableWithSticky: React.FC = ({ {column.columnName === "__checkbox__" ? ( renderCheckboxCell?.(row, index) ) : isEditing ? ( - // 인라인 편집 입력 필드 - onEditingValueChange?.(e.target.value)} - onKeyDown={onEditKeyDown} - onBlur={() => { - // blur 시 저장 (Enter와 동일) + // 인라인 편집: inputType에 따라 select(category/code), number, date, text + (() => { + const meta = columnMeta?.[column.columnName]; + const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + const isCategoryType = inputType === "category" || inputType === "code"; + const categoryOptions = categoryMappings?.[column.columnName]; + const hasCategoryOptions = + isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0; + + // 인라인 편집: 행 높이 유지를 위해 select/input 모두 h-8(32px) 고정 + const commonInputClass = + "border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"; + const handleBlurSave = () => { if (onEditKeyDown) { const fakeEvent = { key: "Enter", @@ -366,10 +382,79 @@ export const SingleTableWithSticky: React.FC = ({ } as React.KeyboardEvent; onEditKeyDown(fakeEvent); } - }} - className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm" - onClick={(e) => e.stopPropagation()} - /> + onEditSave?.(); + }; + + // category/code 타입: select는 반드시 h-8(32px)로 행 높이 유지 + if (hasCategoryOptions) { + const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({ + value, + label: info.label, + })); + return ( + + ); + } + + if (inputType === "date" || inputType === "datetime") { + try { + const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); + return ( + onEditingValueChange?.(v)} + onSave={() => { + handleBlurSave(); + }} + onKeyDown={onEditKeyDown} + inputRef={editInputRef as React.RefObject} + /> + ); + } catch { + return ( + } + type="text" + value={editingValue ?? ""} + onChange={(e) => onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={handleBlurSave} + className={commonInputClass} + onClick={(e) => e.stopPropagation()} + /> + ); + } + } + + return ( + } + type={isNumeric ? "number" : "text"} + value={editingValue ?? ""} + onChange={(e) => onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={handleBlurSave} + className={commonInputClass} + style={isNumeric ? { textAlign: "right" } : undefined} + onClick={(e) => e.stopPropagation()} + /> + ); + })() ) : ( renderCellContent() )} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 617d88fe..5ef54bc3 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -368,6 +368,7 @@ import { CheckSquare, Trash2, Lock, + GripVertical, } from "lucide-react"; import * as XLSX from "xlsx"; import { FileText, ChevronRightIcon } from "lucide-react"; @@ -1067,10 +1068,7 @@ export const TableListComponent: React.FC = ({ const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [showGridLines, setShowGridLines] = useState(true); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); - // 체크박스 컬럼은 항상 기본 틀고정 - const [frozenColumns, setFrozenColumns] = useState( - (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [], - ); + const [frozenColumns, setFrozenColumns] = useState([]); const [frozenColumnCount, setFrozenColumnCount] = useState(0); // 🆕 Search Panel (통합 검색) 관련 상태 @@ -1373,14 +1371,10 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, // 현재 틀고정 컬럼 수 onFrozenColumnCountChange: (count: number) => { setFrozenColumnCount(count); - // 체크박스 컬럼은 항상 틀고정에 포함 - const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; - // 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정 const visibleCols = columnsToRegister .filter((col) => col.visible !== false) .map((col) => col.columnName || col.field); - const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)]; - setFrozenColumns(newFrozenColumns); + setFrozenColumns(visibleCols.slice(0, count)); }, // 탭 관련 정보 (탭 내부의 테이블인 경우) parentTabId, @@ -3280,12 +3274,8 @@ export const TableListComponent: React.FC = ({ if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) { - // 체크박스 컬럼이 항상 포함되도록 보장 - const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; - const restoredFrozenColumns = - checkboxColumn && !state.frozenColumns.includes(checkboxColumn) - ? [checkboxColumn, ...state.frozenColumns] - : state.frozenColumns; + // 체크박스 컬럼은 frozen 대상에서 제외 (배경색 이중 적용 방지) + const restoredFrozenColumns = (state.frozenColumns || []).filter((col: string) => col !== "__checkbox__"); setFrozenColumns(restoredFrozenColumns); } if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 @@ -4434,7 +4424,19 @@ export const TableListComponent: React.FC = ({ if (!tableConfig.checkbox?.selectAll) return null; if (tableConfig.checkbox?.multiple === false) return null; - return ; + return ( + + ); }; const renderCheckboxCell = (row: any, index: number) => { @@ -4446,6 +4448,12 @@ export const TableListComponent: React.FC = ({ checked={isChecked} onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)} aria-label={`행 ${index + 1} 선택`} + style={{ + width: 16, + height: 16, + borderWidth: 1.5, + borderColor: isChecked ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)", + }} /> ); }; @@ -5634,6 +5642,15 @@ export const TableListComponent: React.FC = ({ }} getColumnWidth={getColumnWidth} containerWidth={calculatedWidth} + onCellDoubleClick={handleCellDoubleClick} + editingCell={editingCell} + editingValue={editingValue} + onEditingValueChange={setEditingValue} + onEditKeyDown={handleEditKeyDown} + onEditSave={saveEditing} + editInputRef={editInputRef} + columnMeta={columnMeta} + categoryMappings={categoryMappings} />
@@ -5838,7 +5855,41 @@ export const TableListComponent: React.FC = ({ )}
- {/* 🆕 배치 편집 툴바 */} + {/* 필터 칩 바 */} + {filterGroups.length > 0 && filterGroups.some(g => g.conditions.some(c => c.column && c.value)) && ( +
+ {filterGroups.flatMap(group => + group.conditions + .filter(c => c.column && c.value) + .map(condition => { + const label = columnLabels[condition.column] || condition.column; + const opLabel = condition.operator === "equals" ? "=" : condition.operator === "contains" ? "⊃" : condition.operator === "notEquals" ? "≠" : condition.operator === "startsWith" ? "^" : condition.operator === "endsWith" ? "$" : condition.operator === "greaterThan" ? ">" : condition.operator === "lessThan" ? "<" : condition.operator; + return ( + + {label} {opLabel} {condition.value} + + + ); + }) + )} + +
+ )} + + {/* 배치 편집 툴바 */} {(editMode === "batch" || pendingChanges.size > 0) && (
@@ -5946,8 +5997,8 @@ export const TableListComponent: React.FC = ({ {/* 🆕 Multi-Level Headers (Column Bands) */} {columnBandsInfo?.hasBands && (
{visibleColumns.map((column, colIdx) => { // 이 컬럼이 속한 band 찾기 @@ -5988,9 +6039,9 @@ export const TableListComponent: React.FC = ({ )} {visibleColumns.map((column, columnIndex) => { @@ -6018,11 +6069,12 @@ export const TableListComponent: React.FC = ({ key={column.columnName} ref={(el) => (columnRefs.current[column.columnName] = el)} className={cn( - "text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm", - column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", + "group text-muted-foreground relative h-8 overflow-hidden text-[10px] font-bold uppercase tracking-[0.04em] text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-xs", + column.columnName === "__checkbox__" ? "px-0 py-1" : "px-3 py-2", column.sortable !== false && column.columnName !== "__checkbox__" && - "hover:bg-muted/70 cursor-pointer transition-colors", + "hover:text-foreground hover:bg-muted/50 cursor-pointer transition-colors", + sortColumn === column.columnName && "!text-primary", isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", // 🆕 Column Reordering 스타일 isColumnDragEnabled && @@ -6042,7 +6094,7 @@ export const TableListComponent: React.FC = ({ minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, userSelect: "none", - backgroundColor: "hsl(var(--muted))", + backgroundColor: "hsl(var(--muted) / 0.4)", ...(isFrozen && { left: `${leftPosition}px` }), }} // 🆕 Column Reordering 이벤트 @@ -6062,9 +6114,12 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
+ {isColumnDragEnabled && ( + + )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( - {sortDirection === "asc" ? "↑" : "↓"} + {sortDirection === "asc" ? "↑" : "↓"} )} {/* 🆕 헤더 필터 버튼 */} {tableConfig.headerFilter !== false && @@ -6289,7 +6344,8 @@ export const TableListComponent: React.FC = ({
handleRowClick(row, index, e)} > @@ -6320,13 +6376,14 @@ export const TableListComponent: React.FC = ({ + ); })} @@ -6426,10 +6485,10 @@ export const TableListComponent: React.FC = ({ = ({ data-row={index} data-col={colIndex} className={cn( - "text-foreground text-xs font-normal sm:text-sm", - // 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지) - inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap", - column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5", + "text-foreground text-[11px] font-normal", + inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]", + column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", - // 🆕 포커스된 셀 스타일 isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", - // 🆕 편집 중인 셀 스타일 editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0", - // 🆕 배치 편집: 수정된 셀 스타일 (노란 배경) isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", - // 🆕 유효성 에러: 빨간 테두리 및 배경 cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", - // 🆕 검색 하이라이트 스타일 (노란 배경) isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", - // 🆕 편집 불가 컬럼 스타일 (연한 회색 배경) - column.editable === false && "bg-gray-50 dark:bg-gray-900/30", + column.editable === false && "bg-muted/10 dark:bg-muted/10", + // 코드 컬럼: mono 폰트 + primary 색상 + (inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium", + // 숫자 컬럼: tabular-nums 오른쪽 정렬 + isNumeric && "tabular-nums", )} // 🆕 유효성 에러 툴팁 title={cellValidationError || undefined} @@ -6522,7 +6578,9 @@ export const TableListComponent: React.FC = ({ maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, ...(isFrozen && { left: `${leftPosition}px`, - backgroundColor: "hsl(var(--background))", + backgroundColor: index % 2 === 0 + ? "hsl(var(--background))" + : "hsl(var(--muted) / 0.2)", }), }} onClick={(e) => handleCellClick(index, colIndex, e)} @@ -6581,7 +6639,7 @@ export const TableListComponent: React.FC = ({ onChange={(e) => setEditingValue(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={saveEditing} - className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + className="border-primary bg-background h-8 w-full shrink-0 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" autoFocus > @@ -6609,7 +6667,7 @@ export const TableListComponent: React.FC = ({ ); } - // 일반 입력 필드 + // 일반 입력 필드 (행 높이 유지: h-8 고정) return ( = ({ onChange={(e) => setEditingValue(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={saveEditing} - className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + className="border-primary bg-background h-8 w-full shrink-0 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" style={{ textAlign: isNumeric ? "right" : column.align || "left", }} @@ -6627,7 +6685,9 @@ export const TableListComponent: React.FC = ({ })() : column.columnName === "__checkbox__" ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} + : (cellValue === null || cellValue === undefined || cellValue === "") + ? - + : formatCellValue(cellValue, column, row)} ); })} @@ -6687,7 +6747,7 @@ export const TableListComponent: React.FC = ({ : undefined, ...(isFrozen && { left: `${leftPosition}px`, - backgroundColor: "hsl(var(--muted) / 0.8)", + backgroundColor: "hsl(var(--muted) / 0.4)", }), }} > diff --git a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index ac6b208e..039a591c 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -48,7 +48,7 @@ const TabsDesignEditor: React.FC<{ return cn( "px-4 py-2 text-sm font-medium cursor-pointer transition-colors", isActive - ? "bg-background border-b-2 border-primary text-primary" + ? "bg-primary/20 dark:bg-primary/25 border-b-2 border-primary text-primary font-semibold" : "text-muted-foreground hover:text-foreground hover:bg-muted/50" ); }; @@ -283,7 +283,7 @@ const TabsDesignEditor: React.FC<{ return (
{/* 탭 헤더 */} -
+
{tabs.length > 0 ? ( tabs.map((tab) => (
{/* 탭 헤더 */} -
+
{tabs.length > 0 ? ( tabs.map((tab) => (
{label || getColumnLabel(key)} {displayValue} + {displayValue} +
= ({ : `${100 / visibleColumns.length}%`, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - ...(isFrozen && { - left: `${leftPosition}px`, - backgroundColor: "hsl(var(--background))", - }), - }} - > + ...(isFrozen && { + left: `${leftPosition}px`, + backgroundColor: index % 2 === 0 + ? "hsl(var(--background))" + : "hsl(var(--muted) / 0.2)", + }), + }} + > {column.columnName === "__checkbox__" ? renderCheckboxCell(row, index) : formatCellValue(cellValue, column, row)} -