[agent-pipeline] pipe-20260317063830-0nfs round-1
This commit is contained in:
@@ -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,53 +34,62 @@ 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<CategoryColumn[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
const [selectedColumn, setSelectedColumn] = useState<{
|
||||
uniqueKey: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
tableName: string;
|
||||
} | null>(null);
|
||||
|
||||
// 뷰 모드 상태
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(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 rightContent = (
|
||||
<>
|
||||
{/* 뷰 모드 토글 */}
|
||||
{config.showViewModeToggle && (
|
||||
<div className="mb-2 flex items-center justify-end gap-1">
|
||||
<span className="text-muted-foreground mr-2 text-xs">보기 방식:</span>
|
||||
@@ -105,8 +115,6 @@ export function V2CategoryManagerComponent({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 값 관리 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{selectedColumn ? (
|
||||
viewMode === "tree" ? (
|
||||
@@ -130,7 +138,9 @@ export function V2CategoryManagerComponent({
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
|
||||
{config.showColumnList
|
||||
? "칩에서 카테고리 컬럼을 선택하세요"
|
||||
: "카테고리 컬럼이 설정되지 않았습니다"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,24 +158,107 @@ export function V2CategoryManagerComponent({
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSplitPanel
|
||||
left={
|
||||
<CategoryColumnList
|
||||
tableName={effectiveTableName}
|
||||
selectedColumn={selectedColumn?.uniqueKey || null}
|
||||
onColumnSelect={handleColumnSelect}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
/>
|
||||
}
|
||||
right={rightContent}
|
||||
leftTitle="카테고리 컬럼"
|
||||
leftWidth={config.leftPanelWidth}
|
||||
minLeftWidth={10}
|
||||
maxLeftWidth={40}
|
||||
height={config.height}
|
||||
/>
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
style={{ height: config.height }}
|
||||
>
|
||||
{/* Stat Strip */}
|
||||
<div className="grid grid-cols-4 border-b bg-background">
|
||||
<div className="border-r py-3.5 text-center last:border-r-0">
|
||||
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||
{stats.columnCount}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
카테고리 컬럼
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-r py-3.5 text-center last:border-r-0">
|
||||
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||
{stats.totalValues}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
전체 값
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-r py-3.5 text-center last:border-r-0">
|
||||
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||
{stats.tableCount}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
테이블
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3.5 text-center">
|
||||
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||
{stats.inactiveCount}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
비활성
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* 좌측 테이블 nav: 240px */}
|
||||
<div className="flex w-[240px] shrink-0 flex-col border-r">
|
||||
<CategoryColumnList
|
||||
tableName={effectiveTableName}
|
||||
selectedColumn={selectedColumn?.uniqueKey ?? null}
|
||||
onColumnSelect={handleColumnSelect}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
selectedTable={selectedTable}
|
||||
onTableSelect={setSelectedTable}
|
||||
onColumnsLoaded={handleColumnsLoaded}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 우측: 칩 바 + 편집기 */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* 칩 바 */}
|
||||
<div className="flex flex-wrap gap-1.5 border-b bg-background px-4 py-3">
|
||||
{columnsForSelectedTable.map((col) => {
|
||||
const uniqueKey = `${col.tableName}.${col.columnName}`;
|
||||
const isActive = selectedColumn?.uniqueKey === uniqueKey;
|
||||
return (
|
||||
<button
|
||||
key={uniqueKey}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleColumnSelect(uniqueKey, col.columnLabel || col.columnName, col.tableName)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/50 hover:border-primary hover:bg-primary/5 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<span>{col.columnLabel || col.columnName}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4 rounded-full px-1.5 text-[9px] font-bold",
|
||||
isActive ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{col.valueCount ?? 0}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedTable && columnsForSelectedTable.length === 0 && (
|
||||
<span className="text-muted-foreground text-xs">이 테이블에 카테고리 컬럼이 없습니다</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 편집기 영역 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
|
||||
{rightContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default V2CategoryManagerComponent;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -21,6 +20,8 @@ import {
|
||||
Settings,
|
||||
Move,
|
||||
FileSpreadsheet,
|
||||
List,
|
||||
LayoutPanelRight,
|
||||
} from "lucide-react";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
@@ -325,6 +326,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
||||
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||
// 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 순서 변경)
|
||||
const [rightDraggedColumnIndex, setRightDraggedColumnIndex] = useState<number | null>(null);
|
||||
const [rightDropTargetColumnIndex, setRightDropTargetColumnIndex] = useState<number | null>(null);
|
||||
const [rightDragSource, setRightDragSource] = useState<"main" | number | null>(null);
|
||||
|
||||
// 데이터 상태
|
||||
const [leftData, setLeftData] = useState<any[]>([]);
|
||||
@@ -2631,6 +2636,95 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
}
|
||||
}, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]);
|
||||
|
||||
// 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 컬럼 순서 변경)
|
||||
const handleRightColumnDragStart = useCallback(
|
||||
(columnIndex: number, source: "main" | number) => {
|
||||
setRightDraggedColumnIndex(columnIndex);
|
||||
setRightDragSource(source);
|
||||
},
|
||||
[],
|
||||
);
|
||||
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 (!onUpdateComponent) {
|
||||
handleRightColumnDragEnd();
|
||||
return;
|
||||
}
|
||||
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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
handleRightColumnDragEnd();
|
||||
},
|
||||
[
|
||||
rightDraggedColumnIndex,
|
||||
rightDragSource,
|
||||
componentConfig,
|
||||
component,
|
||||
onUpdateComponent,
|
||||
handleRightColumnDragEnd,
|
||||
],
|
||||
);
|
||||
|
||||
// 수정 모달 저장
|
||||
const handleEditModalSave = useCallback(async () => {
|
||||
const tableName =
|
||||
@@ -3212,10 +3306,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.leftPanel?.title || "좌측 패널"}
|
||||
</CardTitle>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<List className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="truncate text-base font-semibold">
|
||||
{componentConfig.leftPanel?.title || "좌측 패널"}
|
||||
</CardTitle>
|
||||
{!isDesignMode && (
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{summedLeftData.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
|
||||
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
|
||||
@@ -4011,13 +4113,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{/* 리사이저: 6px 너비, 그립 핸들(2x28px bar), hover 시 primary 하이라이트 */}
|
||||
{resizable && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="group bg-border hover:bg-primary flex w-1 cursor-col-resize items-center justify-center transition-colors"
|
||||
className="group flex w-1.5 cursor-col-resize flex-col items-center justify-center gap-0.5 bg-border transition-colors hover:bg-primary"
|
||||
aria-label="분할선 드래그"
|
||||
>
|
||||
<GripVertical className="text-muted-foreground group-hover:text-primary-foreground h-4 w-4" />
|
||||
<div className="h-7 w-0.5 rounded-full bg-muted-foreground/40 transition-colors group-hover:bg-primary-foreground/80" />
|
||||
<div className="h-7 w-0.5 rounded-full bg-muted-foreground/40 transition-colors group-hover:bg-primary-foreground/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4037,9 +4141,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-0">
|
||||
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 */}
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<LayoutPanelRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */}
|
||||
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
|
||||
<div className="flex items-center gap-0">
|
||||
<button
|
||||
@@ -4069,10 +4174,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<CardTitle className="text-base font-semibold">
|
||||
<CardTitle className="truncate text-base font-semibold">
|
||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
||||
</CardTitle>
|
||||
)}
|
||||
{!isDesignMode && (
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{activeTabIndex === 0
|
||||
? Array.isArray(rightData)
|
||||
? rightData.length
|
||||
: rightData ? 1 : 0
|
||||
: (tabsData[activeTabIndex]?.length ?? 0)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!isDesignMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -4163,16 +4277,35 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||
const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||
const tabIndex = activeTabIndex - 1;
|
||||
const canDragTabColumns = isDesignMode && tabSummaryColumns.length > 0 && !!onUpdateComponent;
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-background">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{tabSummaryColumns.map((col: any) => (
|
||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||
{col.label || col.name}
|
||||
</th>
|
||||
))}
|
||||
{tabSummaryColumns.map((col: any, idx: number) => {
|
||||
const isDropTarget = rightDragSource === tabIndex && rightDropTargetColumnIndex === idx;
|
||||
const isDragging = rightDragSource === tabIndex && rightDraggedColumnIndex === idx;
|
||||
return (
|
||||
<th
|
||||
key={col.name}
|
||||
className={cn(
|
||||
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold",
|
||||
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
|
||||
canDragTabColumns && "cursor-grab active:cursor-grabbing",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
draggable={canDragTabColumns}
|
||||
onDragStart={() => canDragTabColumns && handleRightColumnDragStart(idx, tabIndex)}
|
||||
onDragOver={(e) => canDragTabColumns && handleRightColumnDragOver(e, idx)}
|
||||
onDragEnd={handleRightColumnDragEnd}
|
||||
onDrop={(e) => canDragTabColumns && handleRightColumnDrop(e, idx, tabIndex)}
|
||||
>
|
||||
{col.label || col.name}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{hasTabActions && (
|
||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||
)}
|
||||
@@ -4280,16 +4413,35 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||
const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||
const listTabIndex = activeTabIndex - 1;
|
||||
const canDragListTabColumns = isDesignMode && listSummaryColumns.length > 0 && !!onUpdateComponent;
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-background">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{listSummaryColumns.map((col: any) => (
|
||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||
{col.label || col.name}
|
||||
</th>
|
||||
))}
|
||||
{listSummaryColumns.map((col: any, idx: number) => {
|
||||
const isDropTarget = rightDragSource === listTabIndex && rightDropTargetColumnIndex === idx;
|
||||
const isDragging = rightDragSource === listTabIndex && rightDraggedColumnIndex === idx;
|
||||
return (
|
||||
<th
|
||||
key={col.name}
|
||||
className={cn(
|
||||
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold",
|
||||
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
|
||||
canDragListTabColumns && "cursor-grab active:cursor-grabbing",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
draggable={canDragListTabColumns}
|
||||
onDragStart={() => canDragListTabColumns && handleRightColumnDragStart(idx, listTabIndex)}
|
||||
onDragOver={(e) => canDragListTabColumns && handleRightColumnDragOver(e, idx)}
|
||||
onDragEnd={handleRightColumnDragEnd}
|
||||
onDrop={(e) => canDragListTabColumns && handleRightColumnDrop(e, idx, listTabIndex)}
|
||||
>
|
||||
{col.label || col.name}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{hasTabActions && (
|
||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||
)}
|
||||
@@ -4672,24 +4824,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
return sum + w;
|
||||
}, 0);
|
||||
|
||||
const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length;
|
||||
const canDragRightColumns = isDesignMode && displayColumns.length > 0 && !!onUpdateComponent;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="table-fixed" style={{ width: rightTotalColWidth > 100 ? `${rightTotalColWidth}%` : '100%' }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{columnsToShow.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
||||
textAlign: col.align || "left",
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{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 (
|
||||
<th
|
||||
key={idx}
|
||||
className={cn(
|
||||
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap",
|
||||
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
|
||||
isDraggable && "cursor-grab active:cursor-grabbing",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
style={{
|
||||
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
||||
textAlign: col.align || "left",
|
||||
}}
|
||||
draggable={isDraggable}
|
||||
onDragStart={() => isDraggable && handleRightColumnDragStart(configColIndex, "main")}
|
||||
onDragOver={(e) => isDraggable && handleRightColumnDragOver(e, configColIndex)}
|
||||
onDragEnd={handleRightColumnDragEnd}
|
||||
onDrop={(e) => isDraggable && handleRightColumnDrop(e, configColIndex, "main")}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */}
|
||||
{!isDesignMode &&
|
||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||
@@ -4705,7 +4876,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const itemId = item.id || item.ID || idx;
|
||||
|
||||
return (
|
||||
<tr key={itemId} className={cn("border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}>
|
||||
<tr key={itemId} className={cn("group/action border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}>
|
||||
{columnsToShow.map((col, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
@@ -4726,8 +4897,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
{!isDesignMode &&
|
||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
||||
<div className="flex justify-end gap-1">
|
||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap group/action">
|
||||
<div className="flex justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100">
|
||||
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||
<Button
|
||||
variant={
|
||||
@@ -4850,7 +5021,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
<React.Fragment key={itemId}>
|
||||
<tr
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-border/40 transition-colors",
|
||||
"group/action cursor-pointer border-b border-border/40 transition-colors",
|
||||
isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
@@ -4867,7 +5038,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
))}
|
||||
{hasActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100">
|
||||
{hasEditButton && (
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
@@ -4984,8 +5155,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
||||
}
|
||||
|
||||
const hasDetailEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
|
||||
const hasDetailDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(hasDetailEditButton || hasDetailDeleteButton) && (
|
||||
<div className="flex items-center justify-end gap-1 pb-1">
|
||||
{hasDetailEditButton && (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => handleEditClick("right", rightData)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
{hasDetailDeleteButton && (
|
||||
<Button size="sm" variant="ghost" className="text-destructive h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => handleDeleteClick("right", rightData)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{displayEntries.map(([key, value, label]) => (
|
||||
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||
|
||||
@@ -35,7 +35,11 @@ interface SingleTableWithStickyProps {
|
||||
editingValue?: string;
|
||||
onEditingValueChange?: (value: string) => void;
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
editInputRef?: React.RefObject<HTMLInputElement>;
|
||||
onEditSave?: () => void;
|
||||
editInputRef?: React.RefObject<HTMLInputElement | HTMLSelectElement>;
|
||||
// 인라인 편집 타입별 옵션 (select/category/code, number, date 지원)
|
||||
columnMeta?: Record<string, { inputType?: string }>;
|
||||
categoryMappings?: Record<string, Record<string, { label: string }>>;
|
||||
// 검색 하이라이트 관련 props
|
||||
searchHighlights?: Set<string>;
|
||||
currentSearchIndex?: number;
|
||||
@@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
editingValue,
|
||||
onEditingValueChange,
|
||||
onEditKeyDown,
|
||||
onEditSave,
|
||||
editInputRef,
|
||||
columnMeta,
|
||||
categoryMappings,
|
||||
// 검색 하이라이트 관련 props
|
||||
searchHighlights,
|
||||
currentSearchIndex = 0,
|
||||
@@ -350,15 +357,19 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxCell?.(row, index)
|
||||
) : isEditing ? (
|
||||
// 인라인 편집 입력 필드
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editingValue ?? ""}
|
||||
onChange={(e) => 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;
|
||||
|
||||
const commonInputClass =
|
||||
"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";
|
||||
const handleBlurSave = () => {
|
||||
if (onEditKeyDown) {
|
||||
const fakeEvent = {
|
||||
key: "Enter",
|
||||
@@ -366,10 +377,78 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
} as React.KeyboardEvent<HTMLInputElement>;
|
||||
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?.();
|
||||
};
|
||||
|
||||
if (hasCategoryOptions) {
|
||||
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
|
||||
value,
|
||||
label: info.label,
|
||||
}));
|
||||
return (
|
||||
<select
|
||||
ref={editInputRef as React.RefObject<HTMLSelectElement>}
|
||||
value={editingValue ?? ""}
|
||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||
onKeyDown={onEditKeyDown}
|
||||
onBlur={handleBlurSave}
|
||||
className={commonInputClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{selectOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (inputType === "date" || inputType === "datetime") {
|
||||
try {
|
||||
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
|
||||
return (
|
||||
<InlineCellDatePicker
|
||||
value={editingValue ?? ""}
|
||||
onChange={(v) => onEditingValueChange?.(v)}
|
||||
onSave={() => {
|
||||
handleBlurSave();
|
||||
}}
|
||||
onKeyDown={onEditKeyDown}
|
||||
inputRef={editInputRef as React.RefObject<HTMLInputElement>}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<input
|
||||
ref={editInputRef as React.RefObject<HTMLInputElement>}
|
||||
type="text"
|
||||
value={editingValue ?? ""}
|
||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||
onKeyDown={onEditKeyDown}
|
||||
onBlur={handleBlurSave}
|
||||
className={commonInputClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={editInputRef as React.RefObject<HTMLInputElement>}
|
||||
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()
|
||||
)}
|
||||
|
||||
@@ -5463,6 +5463,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
}}
|
||||
getColumnWidth={getColumnWidth}
|
||||
containerWidth={calculatedWidth}
|
||||
onCellDoubleClick={handleCellDoubleClick}
|
||||
editingCell={editingCell}
|
||||
editingValue={editingValue}
|
||||
onEditingValueChange={setEditingValue}
|
||||
onEditKeyDown={handleEditKeyDown}
|
||||
onEditSave={saveEditing}
|
||||
editInputRef={editInputRef}
|
||||
columnMeta={columnMeta}
|
||||
categoryMappings={categoryMappings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6410,7 +6419,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
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 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||||
autoFocus
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
@@ -6447,7 +6456,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
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 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",
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user