feat: 다중 선택 및 일괄 삭제 기능 추가

- 카테고리 값 관리 컴포넌트에 체크박스를 통한 다중 선택 기능을 추가하였습니다.
- 선택된 카테고리를 일괄 삭제할 수 있는 다이얼로그를 구현하였습니다.
- 테이블 관리 서비스에서 다중 선택 처리 로직을 추가하여, 파이프(|)로 구분된 값을 처리하도록 개선하였습니다.
- 관련된 로그 메시지를 추가하여 다중 선택 및 삭제 과정에서의 정보를 기록하도록 하였습니다.
This commit is contained in:
kjs 2026-01-27 11:02:20 +09:00
parent 64cc5c6772
commit 042488d51b
9 changed files with 1002 additions and 412 deletions

View File

@ -1465,6 +1465,31 @@ export class TableManagementService {
const webType = columnInfo.webType;
// 🔧 다중선택 처리: actualValue가 파이프(|)를 포함하고 날짜 타입이 아닌 경우
if (
typeof actualValue === "string" &&
actualValue.includes("|") &&
webType !== "date" &&
webType !== "datetime"
) {
const multiValues = actualValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용 (객체): ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 웹타입별 검색 조건 구성
switch (webType) {
case "date":

View File

@ -22,7 +22,7 @@ export function RegistryProvider({ children }: RegistryProviderProps) {
// V2 Core 초기화 (느슨한 결합 아키텍처)
initV2Core({
debug: process.env.NODE_ENV === "development",
debug: false,
legacyBridge: {
legacyToV2: true,
v2ToLegacy: true,

View File

@ -0,0 +1,668 @@
"use client";
import React, { useState, useEffect } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { GripVertical, Eye, EyeOff, Lock, ArrowRight, X, Settings, Filter, Layers } from "lucide-react";
import { ColumnVisibility, TableFilter, GroupSumConfig } from "@/types/table-options";
interface Props {
isOpen: boolean;
onClose: () => void;
onFiltersApplied?: (filters: TableFilter[]) => void;
screenId?: number;
}
// 컬럼 필터 설정 인터페이스
interface ColumnFilterConfig {
columnName: string;
columnLabel: string;
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number;
selectOptions?: Array<{ label: string; value: string }>;
}
export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => {
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [activeTab, setActiveTab] = useState("columns");
// 컬럼 가시성 상태
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
const [draggedColumnIndex, setDraggedColumnIndex] = useState<number | null>(null);
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 필터 상태
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
const [selectAllFilters, setSelectAllFilters] = useState(false);
const [groupSumEnabled, setGroupSumEnabled] = useState(false);
const [groupByColumn, setGroupByColumn] = useState<string>("");
// 그룹화 상태
const [selectedGroupColumns, setSelectedGroupColumns] = useState<string[]>([]);
const [draggedGroupIndex, setDraggedGroupIndex] = useState<number | null>(null);
// 테이블 정보 로드 - 컬럼 가시성
useEffect(() => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
width: col.width,
order: 0,
}))
);
setFrozenColumnCount(table.frozenColumnCount ?? 0);
}
}, [table]);
// 테이블 정보 로드 - 필터
useEffect(() => {
if (table?.columns && table?.tableName) {
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
const savedGroupSum = localStorage.getItem(groupSumKey);
if (savedGroupSum) {
try {
const parsed = JSON.parse(savedGroupSum) as GroupSumConfig;
setGroupSumEnabled(parsed.enabled);
setGroupByColumn(parsed.groupByColumn || "");
} catch {
setGroupSumEnabled(false);
setGroupByColumn("");
}
}
if (savedFilters) {
try {
const parsed = JSON.parse(savedFilters);
setColumnFilters(parsed);
setSelectAllFilters(parsed.every((f: ColumnFilterConfig) => f.enabled));
} catch {
initializeFilters();
}
} else {
initializeFilters();
}
}
}, [table?.columns, table?.tableName, screenId]);
const initializeFilters = () => {
if (!table?.columns) return;
const filters: ColumnFilterConfig[] = table.columns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => {
let filterType: "text" | "number" | "date" | "select" = "text";
const inputType = col.inputType || "";
if (["number", "decimal", "currency", "integer"].includes(inputType)) {
filterType = "number";
} else if (["date", "datetime", "time"].includes(inputType)) {
filterType = "date";
} else if (["select", "dropdown", "code", "category", "entity"].includes(inputType)) {
filterType = "select";
}
return {
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType,
enabled: false,
filterType,
width: 200,
};
});
setColumnFilters(filters);
setSelectAllFilters(false);
};
// 컬럼 가시성 핸들러
const handleVisibilityChange = (columnName: string, visible: boolean) => {
setLocalColumns((prev) =>
prev.map((col) => (col.columnName === columnName ? { ...col, visible } : col))
);
};
const handleWidthChange = (columnName: string, width: number) => {
setLocalColumns((prev) =>
prev.map((col) => (col.columnName === columnName ? { ...col, width } : col))
);
};
const moveColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...localColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setLocalColumns(newColumns);
};
// 필터 핸들러
const handleFilterEnabledChange = (columnName: string, enabled: boolean) => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, enabled } : f))
);
};
const handleFilterTypeChange = (columnName: string, filterType: "text" | "number" | "date" | "select") => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, filterType } : f))
);
};
const handleFilterWidthChange = (columnName: string, width: number) => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, width } : f))
);
};
const handleSelectAll = (checked: boolean) => {
setSelectAllFilters(checked);
setColumnFilters((prev) => prev.map((f) => ({ ...f, enabled: checked })));
};
// 그룹화 핸들러
const toggleGroupColumn = (columnName: string) => {
if (selectedGroupColumns.includes(columnName)) {
setSelectedGroupColumns(selectedGroupColumns.filter((c) => c !== columnName));
} else {
setSelectedGroupColumns([...selectedGroupColumns, columnName]);
}
};
const removeGroupColumn = (columnName: string) => {
setSelectedGroupColumns(selectedGroupColumns.filter((c) => c !== columnName));
};
const moveGroupColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...selectedGroupColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setSelectedGroupColumns(newColumns);
};
const clearGrouping = () => {
setSelectedGroupColumns([]);
table?.onGroupChange([]);
};
// 틀고정 컬럼 수 변경 핸들러
const handleFrozenColumnCountChange = (value: string) => {
const count = parseInt(value) || 0;
// 최대값은 표시 가능한 컬럼 수
const maxCount = localColumns.filter((col) => col.visible).length;
setFrozenColumnCount(Math.min(Math.max(0, count), maxCount));
};
const visibleCount = localColumns.filter((col) => col.visible).length;
// 저장
const handleSave = () => {
if (!table) return;
// 1. 컬럼 가시성 저장
table.onColumnVisibilityChange(localColumns);
// 2. 컬럼 순서 변경 콜백 호출
if (table.onColumnOrderChange) {
const newOrder = localColumns
.map((col) => col.columnName)
.filter((name) => name !== "__checkbox__");
table.onColumnOrderChange(newOrder);
}
// 3. 틀고정 컬럼 수 변경 콜백 호출 (현재 컬럼 상태도 함께 전달)
if (table.onFrozenColumnCountChange) {
const updatedColumns = localColumns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
}));
table.onFrozenColumnCountChange(frozenColumnCount, updatedColumns);
}
// 2. 필터 설정 저장
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
// 그룹별 합산 설정 저장
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
const groupSumConfig: GroupSumConfig = {
enabled: groupSumEnabled,
groupByColumn: groupByColumn || undefined,
};
localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig));
// 활성화된 필터만 콜백
const activeFilters: TableFilter[] = columnFilters
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200,
}));
onFiltersApplied?.(activeFilters);
// 3. 그룹화 저장
table.onGroupChange(selectedGroupColumns);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
, ,
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="columns" className="gap-1.5 text-xs sm:text-sm">
<Settings className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="filters" className="gap-1.5 text-xs sm:text-sm">
<Filter className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="grouping" className="gap-1.5 text-xs sm:text-sm">
<Layers className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* 컬럼 설정 탭 */}
<TabsContent value="columns" className="mt-4">
<div className="space-y-4">
{/* 상태 표시 및 틀고정 설정 */}
<div className="flex flex-col gap-3 rounded-lg border bg-muted/50 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-xs sm:text-sm">
{visibleCount}/{localColumns.length}
</div>
{/* 틀고정 설정 */}
<div className="flex items-center gap-2">
<Lock className="text-muted-foreground h-4 w-4" />
<Label className="text-muted-foreground whitespace-nowrap text-xs">
:
</Label>
<Input
type="number"
value={frozenColumnCount}
onChange={(e) => handleFrozenColumnCountChange(e.target.value)}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={0}
max={visibleCount}
placeholder="0"
/>
<span className="text-muted-foreground whitespace-nowrap text-xs">
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: true,
width: 150,
order: 0,
}))
);
setFrozenColumnCount(0);
}
}}
className="h-7 text-xs"
>
</Button>
</div>
{/* 컬럼 목록 */}
<ScrollArea className="h-[300px]">
<div className="space-y-2 pr-4">
{localColumns.map((col, index) => {
const originalCol = table?.columns.find((c) => c.columnName === col.columnName);
if (!originalCol) return null;
// 표시 가능한 컬럼 중 몇 번째인지 계산 (틀고정 표시용)
const visibleIndex = localColumns
.slice(0, index + 1)
.filter((c) => c.visible).length;
const isFrozen = col.visible && visibleIndex <= frozenColumnCount;
return (
<div
key={col.columnName}
draggable
onDragStart={() => setDraggedColumnIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (draggedColumnIndex !== null && draggedColumnIndex !== index) {
moveColumn(draggedColumnIndex, index);
setDraggedColumnIndex(index);
}
}}
onDragEnd={() => setDraggedColumnIndex(null)}
className={`flex cursor-move items-center gap-3 rounded-lg border p-3 transition-colors ${
isFrozen
? "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"
: "bg-background hover:bg-muted/50"
}`}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0" />
{/* 체크박스 */}
<Checkbox
checked={col.visible}
onCheckedChange={(checked) =>
handleVisibilityChange(col.columnName, checked as boolean)
}
/>
{/* 가시성/틀고정 아이콘 */}
{isFrozen ? (
<Lock className="h-4 w-4 shrink-0 text-blue-500" />
) : col.visible ? (
<Eye className="text-primary h-4 w-4 shrink-0" />
) : (
<EyeOff className="text-muted-foreground h-4 w-4 shrink-0" />
)}
{/* 컬럼명 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium sm:text-sm">
{originalCol.columnLabel}
</span>
{isFrozen && (
<span className="text-[10px] font-medium text-blue-600 dark:text-blue-400">
()
</span>
)}
</div>
<div className="text-muted-foreground truncate text-[10px] sm:text-xs">
{col.columnName}
</div>
</div>
{/* 너비 설정 */}
<div className="flex items-center gap-2">
<Label className="text-muted-foreground text-xs">:</Label>
<Input
type="number"
value={col.width || 150}
onChange={(e) => handleWidthChange(col.columnName, parseInt(e.target.value) || 150)}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={50}
max={500}
/>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</TabsContent>
{/* 필터 설정 탭 */}
<TabsContent value="filters" className="mt-4">
<div className="space-y-4">
{/* 전체 선택 */}
<div className="flex items-center gap-2">
<Checkbox
checked={selectAllFilters}
onCheckedChange={(checked) => handleSelectAll(checked as boolean)}
/>
<Label className="text-xs sm:text-sm"> </Label>
</div>
{/* 필터 목록 */}
<ScrollArea className="h-[300px]">
<div className="space-y-2 pr-4">
{columnFilters.map((filter) => (
<div
key={filter.columnName}
className="flex items-center gap-2 rounded-lg border bg-background p-2"
>
<Checkbox
checked={filter.enabled}
onCheckedChange={(checked) =>
handleFilterEnabledChange(filter.columnName, checked as boolean)
}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">
{filter.columnLabel}
</div>
</div>
<Select
value={filter.filterType}
onValueChange={(v) =>
handleFilterTypeChange(filter.columnName, v as "text" | "number" | "date" | "select")
}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={100}
max={400}
value={filter.width || 200}
onChange={(e) =>
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200)
}
className="h-7 w-16 text-center text-xs"
/>
<span className="text-muted-foreground text-xs">px</span>
</div>
))}
</div>
</ScrollArea>
{/* 그룹별 합산 설정 */}
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium sm:text-sm"> </div>
<div className="text-muted-foreground text-[10px] sm:text-xs">
</div>
</div>
<Switch checked={groupSumEnabled} onCheckedChange={setGroupSumEnabled} />
</div>
{groupSumEnabled && (
<div className="mt-3">
<Select value={groupByColumn} onValueChange={setGroupByColumn}>
<SelectTrigger className="h-8 text-xs sm:text-sm">
<SelectValue placeholder="그룹화 기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columnFilters.map((f) => (
<SelectItem key={f.columnName} value={f.columnName}>
{f.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
</TabsContent>
{/* 그룹 설정 탭 */}
<TabsContent value="grouping" className="mt-4">
<div className="space-y-4">
{/* 선택된 그룹화 컬럼 */}
{selectedGroupColumns.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium sm:text-sm">
({selectedGroupColumns.length})
</div>
<Button variant="ghost" size="sm" onClick={clearGrouping} className="h-7 text-xs">
</Button>
</div>
<div className="space-y-2">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
if (!col) return null;
return (
<div
key={colName}
draggable
onDragStart={() => setDraggedGroupIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (draggedGroupIndex !== null && draggedGroupIndex !== index) {
moveGroupColumn(draggedGroupIndex, index);
setDraggedGroupIndex(index);
}
}}
onDragEnd={() => setDraggedGroupIndex(null)}
className="hover:bg-primary/10 bg-primary/5 flex cursor-move items-center gap-2 rounded-lg border p-2 transition-colors"
>
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<div className="bg-primary text-primary-foreground flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-xs">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">{col.columnLabel}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeGroupColumn(colName)}
className="h-6 w-6 flex-shrink-0 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
{/* 그룹화 순서 미리보기 */}
<div className="bg-muted/30 mt-2 rounded-lg border p-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
return (
<React.Fragment key={colName}>
<span className="font-medium">{col?.columnLabel}</span>
{index < selectedGroupColumns.length - 1 && (
<ArrowRight className="text-muted-foreground h-3 w-3" />
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
)}
{/* 사용 가능한 컬럼 */}
<div>
<div className="mb-2 text-xs font-medium sm:text-sm"> </div>
<ScrollArea className={selectedGroupColumns.length > 0 ? "h-[200px]" : "h-[320px]"}>
<div className="space-y-2 pr-4">
{table?.columns
.filter((col) => !selectedGroupColumns.includes(col.columnName))
.map((col) => (
<div
key={col.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg border bg-background p-2 transition-colors"
onClick={() => toggleGroupColumn(col.columnName)}
>
<Checkbox
checked={false}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
className="flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">{col.columnLabel}</div>
<div className="text-muted-foreground truncate text-[10px] sm:text-xs">
{col.columnName}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
</TabsContent>
</Tabs>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -3,9 +3,10 @@
/**
* -
* - 3 (//)
* -
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
ChevronRight,
ChevronDown,
@ -20,6 +21,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import {
CategoryValue,
@ -65,11 +67,13 @@ interface TreeNodeProps {
expandedNodes: Set<number>;
selectedValueId?: number;
searchQuery: string;
checkedIds: Set<number>;
onToggle: (valueId: number) => void;
onSelect: (value: CategoryValue) => void;
onAdd: (parentValue: CategoryValue | null) => void;
onEdit: (value: CategoryValue) => void;
onDelete: (value: CategoryValue) => void;
onCheck: (valueId: number, checked: boolean) => void;
}
// 검색어가 노드 또는 하위에 매칭되는지 확인
@ -90,15 +94,18 @@ const TreeNode: React.FC<TreeNodeProps> = ({
expandedNodes,
selectedValueId,
searchQuery,
checkedIds,
onToggle,
onSelect,
onAdd,
onEdit,
onDelete,
onCheck,
}) => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedNodes.has(node.valueId);
const isSelected = selectedValueId === node.valueId;
const isChecked = checkedIds.has(node.valueId);
const canAddChild = node.depth < 3;
// 검색 필터링
@ -138,11 +145,22 @@ const TreeNode: React.FC<TreeNodeProps> = ({
className={cn(
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
isChecked && "bg-primary/5",
"cursor-pointer",
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={() => onSelect(node)}
>
{/* 체크박스 */}
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
onCheck(node.valueId, checked as boolean);
}}
onClick={(e) => e.stopPropagation()}
className="mr-1"
/>
{/* 확장 토글 */}
<button
type="button"
@ -233,11 +251,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
expandedNodes={expandedNodes}
selectedValueId={selectedValueId}
searchQuery={searchQuery}
checkedIds={checkedIds}
onToggle={onToggle}
onSelect={onSelect}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
onCheck={onCheck}
/>
))}
</div>
@ -259,11 +279,13 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showInactive, setShowInactive] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
// 모달 상태
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
@ -289,12 +311,52 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
}, []);
// 하위 항목 개수만 계산 (자기 자신 제외)
const countAllDescendants = useCallback((node: CategoryValue): number => {
if (!node.children || node.children.length === 0) {
return 0;
const countAllDescendants = useCallback(
(node: CategoryValue): number => {
if (!node.children || node.children.length === 0) {
return 0;
}
return countAllValues(node.children);
},
[countAllValues],
);
// 노드와 모든 하위 항목의 ID 수집
const collectNodeAndDescendantIds = useCallback((node: CategoryValue): number[] => {
const ids: number[] = [node.valueId];
if (node.children) {
for (const child of node.children) {
ids.push(...collectNodeAndDescendantIds(child));
}
}
return countAllValues(node.children);
}, [countAllValues]);
return ids;
}, []);
// 트리에서 valueId로 노드 찾기
const findNodeById = useCallback((nodes: CategoryValue[], valueId: number): CategoryValue | null => {
for (const node of nodes) {
if (node.valueId === valueId) {
return node;
}
if (node.children) {
const found = findNodeById(node.children, valueId);
if (found) return found;
}
}
return null;
}, []);
// 체크된 항목들의 총 삭제 대상 수 계산 (하위 포함)
const totalDeleteCount = useMemo(() => {
const allIds = new Set<number>();
checkedIds.forEach((id) => {
const node = findNodeById(tree, id);
if (node) {
collectNodeAndDescendantIds(node).forEach((descendantId) => allIds.add(descendantId));
}
});
return allIds.size;
}, [checkedIds, tree, findNodeById, collectNodeAndDescendantIds]);
// 활성 노드만 필터링
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
@ -306,37 +368,41 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
}));
}, []);
// 데이터 로드
const loadTree = useCallback(async () => {
if (!tableName || !columnName) return;
// 데이터 로드 (keepExpanded: true면 기존 펼침 상태 유지)
const loadTree = useCallback(
async (keepExpanded = false) => {
if (!tableName || !columnName) return;
setLoading(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
let filteredTree = response.data;
setLoading(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
let filteredTree = response.data;
// 비활성 필터링
if (!showInactive) {
filteredTree = filterActiveNodes(response.data);
// 비활성 필터링
if (!showInactive) {
filteredTree = filterActiveNodes(response.data);
}
setTree(filteredTree);
// 기존 펼침 상태 유지하지 않으면 모두 접기 (대분류만 표시)
if (!keepExpanded) {
setExpandedNodes(new Set());
}
// 전체 개수 업데이트
const totalCount = countAllValues(response.data);
onValueCountChange?.(totalCount);
}
setTree(filteredTree);
// 1단계 노드는 기본 펼침
const rootIds = new Set(filteredTree.map((n) => n.valueId));
setExpandedNodes(rootIds);
// 전체 개수 업데이트
const totalCount = countAllValues(response.data);
onValueCountChange?.(totalCount);
} catch (error) {
console.error("카테고리 트리 로드 오류:", error);
} finally {
setLoading(false);
}
} catch (error) {
console.error("카테고리 트리 로드 오류:", error);
} finally {
setLoading(false);
}
}, [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange]);
},
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange],
);
useEffect(() => {
loadTree();
@ -375,6 +441,43 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
});
};
// 체크박스 핸들러
const handleCheck = useCallback((valueId: number, checked: boolean) => {
setCheckedIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(valueId);
} else {
next.delete(valueId);
}
return next;
});
}, []);
// 전체 선택/해제
const handleSelectAll = useCallback(() => {
if (checkedIds.size === countAllValues(tree)) {
setCheckedIds(new Set());
} else {
const allIds = new Set<number>();
const collectAllIds = (nodes: CategoryValue[]) => {
for (const node of nodes) {
allIds.add(node.valueId);
if (node.children) {
collectAllIds(node.children);
}
}
};
collectAllIds(tree);
setCheckedIds(allIds);
}
}, [checkedIds.size, tree, countAllValues]);
// 선택 해제
const handleClearSelection = useCallback(() => {
setCheckedIds(new Set());
}, []);
// 추가 모달 열기
const handleOpenAddModal = (parent: CategoryValue | null) => {
setParentValue(parent);
@ -440,7 +543,9 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
if (response.success) {
toast.success("카테고리가 추가되었습니다");
setIsAddModalOpen(false);
loadTree();
// 기존 펼침 상태 유지하면서 데이터 새로고침
await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시)
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
@ -469,7 +574,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
if (response.success) {
toast.success("카테고리가 수정되었습니다");
setIsEditModalOpen(false);
loadTree();
loadTree(true); // 기존 펼침 상태 유지
} else {
toast.error(response.error || "수정 실패");
}
@ -489,7 +594,12 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
toast.success("카테고리가 삭제되었습니다");
setIsDeleteDialogOpen(false);
setSelectedValue(null);
loadTree();
setCheckedIds((prev) => {
const next = new Set(prev);
next.delete(deletingValue.valueId);
return next;
});
loadTree(true); // 기존 펼침 상태 유지
} else {
toast.error(response.error || "삭제 실패");
}
@ -499,22 +609,85 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
}
};
// 다중 삭제 처리
const handleBulkDelete = async () => {
if (checkedIds.size === 0) return;
try {
let successCount = 0;
let failCount = 0;
// 체크된 항목들을 순차적으로 삭제 (하위는 백엔드에서 자동 삭제)
for (const valueId of Array.from(checkedIds)) {
try {
const response = await deleteCategoryValue(valueId);
if (response.success) {
successCount++;
} else {
failCount++;
}
} catch {
failCount++;
}
}
setIsBulkDeleteDialogOpen(false);
setCheckedIds(new Set());
setSelectedValue(null);
loadTree(true); // 기존 펼침 상태 유지
if (failCount === 0) {
toast.success(`${successCount}개 카테고리가 삭제되었습니다 (하위 항목 포함)`);
} else {
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패`);
}
} catch (error) {
console.error("카테고리 일괄 삭제 오류:", error);
toast.error("카테고리 일괄 삭제 중 오류가 발생했습니다");
}
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between border-b pb-3">
<h3 className="text-base font-semibold">{columnLabel} </h3>
<Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
<Plus className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">{columnLabel} </h3>
{checkedIds.size > 0 && (
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
{checkedIds.size}
</span>
)}
</div>
<div className="flex items-center gap-2">
{checkedIds.size > 0 && (
<>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={handleClearSelection}>
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
<Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 툴바 */}
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{/* 검색 */}
<div className="relative max-w-xs flex-1">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={searchQuery}
@ -533,13 +706,16 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleSelectAll}>
{checkedIds.size === countAllValues(tree) && tree.length > 0 ? "전체 해제" : "전체 선택"}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadTree} title="새로고침">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => loadTree()} title="새로고침">
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
@ -568,18 +744,19 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
expandedNodes={expandedNodes}
selectedValueId={selectedValue?.valueId}
searchQuery={searchQuery}
checkedIds={checkedIds}
onToggle={handleToggle}
onSelect={setSelectedValue}
onAdd={handleOpenAddModal}
onEdit={handleOpenEditModal}
onDelete={handleOpenDeleteDialog}
onCheck={handleCheck}
/>
))}
</div>
)}
</div>
{/* 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
@ -588,7 +765,9 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
{parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{parentValue ? `${parentValue.depth + 1}단계 카테고리를 추가합니다` : "1단계 대분류 카테고리를 추가합니다"}
{parentValue
? `${parentValue.depth + 1}단계 카테고리를 추가합니다`
: "1단계 대분류 카테고리를 추가합니다"}
</DialogDescription>
</DialogHeader>
@ -631,7 +810,11 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsAddModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
<Button
variant="outline"
onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
@ -685,7 +868,11 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsEditModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
<Button
variant="outline"
onClick={() => setIsEditModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button>
<Button onClick={handleEdit} className="h-9 flex-1 text-sm sm:flex-none">
@ -714,15 +901,46 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 다중 삭제 확인 다이얼로그 */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<strong>{checkedIds.size}</strong> ?
{totalDeleteCount > checkedIds.size && (
<>
<br />
<span className="text-destructive"> {totalDeleteCount} .</span>
</>
)}
<br />
<span className="text-muted-foreground text-xs"> .</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{totalDeleteCount}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default CategoryValueManagerTree;

View File

@ -261,9 +261,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 객체인 경우 tableName 속성 추출 시도
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable);
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable);
}
tableConfig.selectedTable = finalSelectedTable;
@ -741,7 +739,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
if (hasChanges) {
console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues);
setLinkedFilterValues(newFilterValues);
// searchValues에 연결된 필터 값 병합
@ -797,13 +794,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
componentType: "table",
receiveData: async (receivedData: any[], config: DataReceiverConfig) => {
console.log("📥 TableList 데이터 수신:", {
componentId: component.id,
receivedDataCount: receivedData.length,
mode: config.mode,
currentDataCount: data.length,
});
try {
let newData: any[] = [];
@ -811,13 +801,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
case "append":
// 기존 데이터에 추가
newData = [...data, ...receivedData];
console.log("✅ Append 모드: 기존 데이터에 추가", { newDataCount: newData.length });
break;
case "replace":
// 기존 데이터를 완전히 교체
newData = receivedData;
console.log("✅ Replace 모드: 데이터 교체", { newDataCount: newData.length });
break;
case "merge":
@ -833,7 +821,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
});
newData = Array.from(existingMap.values());
console.log("✅ Merge 모드: 데이터 병합", { newDataCount: newData.length });
break;
}
@ -842,10 +829,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 총 아이템 수 업데이트
setTotalItems(newData.length);
console.log("✅ 데이터 수신 완료:", { finalDataCount: newData.length });
} catch (error) {
console.error("데이터 수신 실패:", error);
console.error("데이터 수신 실패:", error);
throw error;
}
},
@ -879,12 +864,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
componentId: component.id,
componentType: "table-list",
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", {
count: incomingData.length,
mode,
position: currentSplitPosition,
});
await dataReceiver.receiveData(incomingData, {
targetComponentId: component.id,
targetComponentType: "table-list",
@ -917,24 +896,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 컬럼의 고유 값 조회 함수
const getColumnUniqueValues = async (columnName: string) => {
console.log("🔍 [getColumnUniqueValues] 호출됨:", {
columnName,
dataLength: data.length,
columnMeta: columnMeta[columnName],
sampleData: data[0],
});
const meta = columnMeta[columnName];
const inputType = meta?.inputType || "text";
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
if (inputType === "category") {
try {
console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", {
tableName: tableConfig.selectedTable,
columnName,
});
// API 클라이언트 사용 (쿠키 인증 자동 처리)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
@ -945,24 +912,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
label: item.valueLabel, // 카멜케이스
}));
console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", {
columnName,
count: categoryOptions.length,
options: categoryOptions,
});
return categoryOptions;
} else {
console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data);
}
} catch (error: any) {
console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", {
error: error.message,
response: error.response?.data,
status: error.response?.status,
columnName,
tableName: tableConfig.selectedTable,
});
// 에러 시 현재 데이터 기반으로 fallback
}
}
@ -971,15 +923,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", {
columnName,
inputType,
isLabelType,
labelField,
hasLabelField: data[0] && labelField in data[0],
sampleLabelValue: data[0] ? data[0][labelField] : undefined,
});
// 현재 로드된 데이터에서 고유 값 추출
const uniqueValuesMap = new Map<string, string>(); // value -> label
@ -1000,15 +943,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}))
.sort((a, b) => a.label.localeCompare(b.label));
console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", {
columnName,
inputType,
isLabelType,
labelField,
uniqueCount: result.length,
values: result,
});
return result;
};
@ -1085,10 +1019,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setSortColumn(column);
setSortDirection(direction);
hasInitializedSort.current = true;
console.log("📂 localStorage에서 정렬 상태 복원:", { column, direction });
}
} catch (error) {
console.error("❌ 정렬 상태 복원 실패:", error);
// 정렬 상태 복원 실패
}
}
}, [tableConfig.selectedTable, userId]);
@ -1104,12 +1037,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (savedOrder) {
try {
const parsedOrder = JSON.parse(savedOrder);
console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder });
setColumnOrder(parsedOrder);
// 부모 컴포넌트에 초기 컬럼 순서 전달
if (onSelectedRowsChange && parsedOrder.length > 0) {
console.log("✅ 초기 컬럼 순서 전달:", parsedOrder);
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
const initialData = data.map((row: any) => {
@ -1598,48 +1529,36 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 자동 컬럼 매칭도 equals 연산자 사용
linkedFilterValues[colName] = { value: colValue, operator: "equals" };
hasLinkedFiltersConfigured = true;
console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`);
}
}
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 자동 컬럼 매칭 필터 적용:", linkedFilterValues);
}
}
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues);
}
}
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
setData([]);
setTotalItems(0);
setLoading(false);
return;
}
// 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
// RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (isRelatedButtonTarget && !relatedButtonFilter) {
console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시");
setData([]);
setTotalItems(0);
setLoading(false);
return;
}
// 🆕 RelatedDataButtons 필터 값 준비
// RelatedDataButtons 필터 값 준비
const relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
value: relatedButtonFilter.filterValue,
operator: "equals",
};
console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues);
}
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
@ -1662,8 +1581,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null;
if (connectionId) {
console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId });
// REST API 연결 정보 가져오기 및 데이터 조회
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
@ -1677,11 +1594,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
total: restApiData.total || restApiData.rows?.length || 0,
totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize),
};
console.log("✅ [TableList] REST API 응답:", {
dataLength: response.data.length,
total: response.total,
});
} else {
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
}
@ -1722,31 +1634,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
if (propFormData && propFormData[fieldName]) {
filterValue = propFormData[fieldName];
console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
// 2순위: URL 파라미터에서 값 가져오기
else if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
filterValue = urlParams.get(fieldName);
if (filterValue) {
console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
}
// 3순위: 분할 패널 부모 데이터에서 값 가져오기
if (!filterValue && splitPanelContext?.selectedLeftData) {
filterValue = splitPanelContext.selectedLeftData[fieldName];
if (filterValue) {
console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
}
}
@ -1759,7 +1655,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
filterColumn: excludeConfig.filterColumn,
filterValue: filterValue,
};
console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
}
}
@ -1874,8 +1769,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
const handleSort = (column: string) => {
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
let newSortColumn = column;
let newSortDirection: "asc" | "desc" = "asc";
@ -1889,7 +1782,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
newSortDirection = "asc";
}
// 🎯 정렬 상태를 localStorage에 저장 (사용자별)
// 정렬 상태를 localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && userId) {
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
try {
@ -1900,15 +1793,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
direction: newSortDirection,
}),
);
console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection });
} catch (error) {
console.error("❌ 정렬 상태 저장 실패:", error);
// 정렬 상태 저장 실패
}
}
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
if (onSelectedRowsChange) {
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
@ -1960,16 +1849,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return reordered;
});
console.log("✅ 정렬 정보 전달:", {
selectedRowsCount: selectedRows.size,
selectedRowsDataCount: selectedRowsData.length,
sortBy: newSortColumn,
sortOrder: newSortDirection,
columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
tableDisplayDataCount: reorderedData.length,
firstRowAfterSort: reorderedData[0]?.[newSortColumn],
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn],
});
onSelectedRowsChange(
Array.from(selectedRows),
selectedRowsData,
@ -2023,8 +1902,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
const handleClearAdvancedFilters = useCallback(() => {
console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues });
// 상태를 초기화하고 useEffect로 데이터 새로고침
setSearchValues({});
setCurrentPage(1);
@ -2173,30 +2050,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
splitPanelPosition,
currentSplitPosition,
effectiveSplitPosition,
hasSplitPanelContext: !!splitPanelContext,
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
});
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
row,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else {
// 선택 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
}
}
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
};
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
@ -2457,12 +2319,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
},
}));
console.log("📝 배치 편집 추가:", { columnName, newValue, pendingCount: pendingChanges.size + 1 });
cancelEditing();
return;
}
// 🆕 즉시 모드: 바로 저장
// 즉시 모드: 바로 저장
try {
const { apiClient } = await import("@/lib/api/client");
@ -2476,10 +2337,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 데이터 새로고침 트리거
setRefreshTrigger((prev) => prev + 1);
console.log("✅ 셀 편집 저장 완료:", { columnName, newValue });
} catch (error) {
console.error("❌ 셀 편집 저장 실패:", error);
// 셀 편집 저장 실패
}
cancelEditing();
@ -2524,21 +2383,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setRefreshTrigger((prev) => prev + 1);
toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`);
console.log("✅ 배치 저장 완료:", pendingChanges.size, "개");
} catch (error) {
console.error("❌ 배치 저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
}
}, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]);
// 🆕 배치 취소: 모든 변경사항 롤백
// 배치 취소: 모든 변경사항 롤백
const cancelBatchChanges = useCallback(() => {
if (pendingChanges.size === 0) return;
setPendingChanges(new Map());
setLocalEditedData({});
toast.info("변경사항이 취소되었습니다.");
console.log("🔄 배치 편집 취소");
}, [pendingChanges.size]);
// 🆕 특정 셀이 수정되었는지 확인
@ -2715,9 +2571,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
XLSX.writeFile(wb, fileName);
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
console.log("✅ Excel 내보내기 완료:", fileName);
} catch (error) {
console.error("❌ Excel 내보내기 실패:", error);
toast.error("Excel 내보내기 중 오류가 발생했습니다.");
}
},
@ -2783,10 +2637,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
...prev,
[rowKey]: details,
}));
console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length });
} catch (error) {
console.error("❌ 상세 데이터 로딩 실패:", error);
setDetailData((prev) => ({
...prev,
[rowKey]: [],
@ -2883,10 +2734,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
...prev,
[cacheKey]: options,
}));
console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length });
} catch (error) {
console.error("❌ Cascading options 로딩 실패:", error);
setCascadingOptions((prev) => ({
...prev,
[cacheKey]: [],
@ -3040,13 +2888,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
wsRef.current.onopen = () => {
setWsConnectionStatus("connected");
console.log("✅ WebSocket 연결됨:", tableConfig.selectedTable);
};
wsRef.current.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log("📨 WebSocket 메시지 수신:", message);
switch (message.type) {
case "insert":
@ -3069,32 +2915,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setRefreshTrigger((prev) => prev + 1);
break;
default:
console.log("알 수 없는 메시지 타입:", message.type);
// 알 수 없는 메시지 타입
break;
}
} catch (error) {
console.error("WebSocket 메시지 파싱 오류:", error);
// WebSocket 메시지 파싱 오류
}
};
wsRef.current.onclose = () => {
setWsConnectionStatus("disconnected");
console.log("🔌 WebSocket 연결 종료");
// 자동 재연결 (5초 후)
if (isRealTimeEnabled) {
reconnectTimeoutRef.current = setTimeout(() => {
console.log("🔄 WebSocket 재연결 시도...");
connectWebSocket();
}, 5000);
}
};
wsRef.current.onerror = (error) => {
console.error("❌ WebSocket 오류:", error);
wsRef.current.onerror = () => {
setWsConnectionStatus("disconnected");
};
} catch (error) {
console.error("WebSocket 연결 실패:", error);
setWsConnectionStatus("disconnected");
}
}, [isRealTimeEnabled, tableConfig.selectedTable]);
@ -3179,9 +3022,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
await navigator.clipboard.writeText(tsvContent);
toast.success(`${copyData.length}행 복사됨`);
console.log("✅ 클립보드 복사:", copyData.length, "행");
} catch (error) {
console.error("❌ 클립보드 복사 실패:", error);
toast.error("복사 실패");
}
}, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]);
@ -3532,7 +3373,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setColumnOrder(newOrder);
toast.info("컬럼 순서가 변경되었습니다.");
console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex });
handleColumnDragEnd();
},
@ -3623,10 +3463,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 로컬에서만 순서 변경 (저장 안함)
toast.info("순서가 변경되었습니다. (로컬만)");
}
console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex });
} catch (error) {
console.error("❌ 행 순서 변경 실패:", error);
toast.error("순서 변경 중 오류가 발생했습니다.");
}
@ -4712,8 +4549,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return filteredData;
}
console.log("🔍 [테이블리스트] 그룹합산 적용:", groupSumConfig);
const groupByColumn = groupSumConfig.groupByColumn;
const groupMap = new Map<string, any>();
@ -4766,11 +4601,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
const result = Array.from(groupMap.values());
console.log("🔗 [테이블리스트] 그룹별 합산 결과:", {
원본개수: filteredData.length,
그룹개수: result.length,
그룹기준: groupByColumn,
});
return result;
}, [filteredData, groupSumConfig]);
@ -4878,7 +4708,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
useEffect(() => {
const handleRefreshTable = () => {
if (tableConfig.selectedTable && !isDesignMode) {
console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침");
setRefreshTrigger((prev) => prev + 1);
}
};
@ -4904,23 +4733,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}, [tableConfig.selectedTable, isDesignMode, component.id]);
// 🆕 테이블명 변경 시 전역 레지스트리에서 확인
// 테이블명 변경 시 전역 레지스트리에서 확인
useEffect(() => {
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
if (isTarget) {
console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
}
}, [tableConfig.selectedTable]);
// 🆕 RelatedDataButtons 등록/해제 이벤트 리스너
// RelatedDataButtons 등록/해제 이벤트 리스너
useEffect(() => {
const handleRelatedButtonRegister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
};
@ -4928,7 +4755,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const handleRelatedButtonUnregister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(false);
setRelatedButtonFilter(null);
}
@ -4939,7 +4765,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
V2_EVENTS.RELATED_BUTTON_REGISTER,
(payload) => {
if (payload.targetTables.includes(tableConfig.selectedTable || "")) {
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
},
@ -4950,7 +4775,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
V2_EVENTS.RELATED_BUTTON_UNREGISTER,
(payload) => {
if (payload.buttonId) {
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(false);
setRelatedButtonFilter(null);
}
@ -4970,7 +4794,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}, [tableConfig.selectedTable, component.id]);
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
// RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
@ -4979,15 +4803,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (targetTable === tableConfig.selectedTable) {
// filterValue가 null이면 선택 해제 (빈 상태)
if (filterValue === null || filterValue === undefined) {
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
setRelatedButtonFilter(null);
setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
} else {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
setIsRelatedButtonTarget(true);
}
@ -5000,14 +4818,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
(payload) => {
if (payload.tableName === tableConfig.selectedTable) {
if (!payload.selectedData || payload.selectedData.length === 0) {
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
setRelatedButtonFilter(null);
setIsRelatedButtonTarget(true);
} else {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
selectedData: payload.selectedData,
});
// 첫 번째 선택된 데이터의 ID를 필터로 사용
const firstItem = payload.selectedData[0];
if (firstItem?.id) {

View File

@ -3,13 +3,11 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { Settings, X, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableSettingsModal } from "@/components/screen/table-options/TableSettingsModal";
import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
@ -50,24 +48,8 @@ interface TableSearchWidgetProps {
}
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
console.log("🎯🎯🎯 [TableSearchWidget] 함수 시작!", { componentId: component?.id, screenId });
// 🔧 직접 useTableOptions 호출 (에러 발생 시 catch하지 않고 그대로 throw)
const tableOptionsContext = useTableOptions();
console.log("✅ [TableSearchWidget] useTableOptions 성공", { hasContext: !!tableOptionsContext });
const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = tableOptionsContext;
// 등록된 테이블 확인 로그
console.log("🔍 [TableSearchWidget] 등록된 테이블:", {
count: registeredTables.size,
tables: Array.from(registeredTables.entries()).map(([id, t]) => ({
id,
tableName: t.tableName,
hasOnFilterChange: typeof t.onFilterChange === "function",
})),
selectedTableId,
});
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보
@ -86,9 +68,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 탭별 필터 값 저장 (탭 ID -> 필터 값)
const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({});
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const [groupingOpen, setGroupingOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// 활성화된 필터 목록
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
@ -153,24 +133,16 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => {
console.log("🔍 [TableSearchWidget] currentTable 계산:", {
selectedTableId,
tableListLength: tableList.length,
tableList: tableList.map((t) => ({ id: t.tableId, name: t.tableName, parentTabId: t.parentTabId })),
});
if (!selectedTableId) return undefined;
// 먼저 tableList(필터링된 목록)에서 찾기
const tableFromList = tableList.find((t) => t.tableId === selectedTableId);
if (tableFromList) {
console.log("✅ [TableSearchWidget] 테이블 찾음 (tableList):", tableFromList.tableName);
return tableFromList;
}
// tableList에 없으면 전체에서 찾기 (폴백)
const tableFromAll = getTable(selectedTableId);
console.log("🔄 [TableSearchWidget] 테이블 찾음 (전체):", tableFromAll?.tableName);
return tableFromAll;
}, [selectedTableId, tableList, getTable]);
@ -186,28 +158,16 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return;
}
// 🆕 탭 전환 감지: 활성 탭이 변경되었는지 확인
// 탭 전환 감지: 활성 탭이 변경되었는지 확인
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
if (tabChanged) {
console.log("🔄 [TableSearchWidget] 탭 전환 감지:", {
이전탭: prevActiveTabIdsRef.current,
현재탭: activeTabIdsStr,
가용테이블: tableList.map((t) => ({ id: t.tableId, tableName: t.tableName, parentTabId: t.parentTabId })),
현재선택테이블: selectedTableId,
});
prevActiveTabIdsRef.current = activeTabIdsStr;
// 🆕 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
// 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
const targetTable = activeTabTable || tableList[0];
if (targetTable) {
console.log("✅ [TableSearchWidget] 탭 전환으로 테이블 강제 선택:", {
테이블ID: targetTable.tableId,
테이블명: targetTable.tableName,
탭ID: targetTable.parentTabId,
이전테이블: selectedTableId,
});
setSelectedTableId(targetTable.tableId);
}
return; // 탭 전환 시에는 여기서 종료
@ -222,11 +182,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const targetTable = activeTabTable || tableList[0];
if (targetTable && targetTable.tableId !== selectedTableId) {
console.log("✅ [TableSearchWidget] 테이블 자동 선택 (초기):", {
테이블ID: targetTable.tableId,
테이블명: targetTable.tableName,
탭ID: targetTable.parentTabId,
});
setSelectedTableId(targetTable.tableId);
}
}
@ -270,13 +225,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => {
console.log("📋 [TableSearchWidget] 필터 설정 useEffect 실행:", {
currentTable: currentTable?.tableName,
currentTableTabId,
filterMode,
selectedTableId,
컬럼수: currentTable?.columns?.length,
});
if (!currentTable?.tableName) return;
// 고정 모드: presetFilters를 activeFilters로 설정
@ -317,13 +265,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
: `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(filterConfigKey);
console.log("🔑 [TableSearchWidget] 필터 설정 키 확인:", {
filterConfigKey,
savedFilters: savedFilters ? `${savedFilters.substring(0, 100)}...` : null,
screenId,
tableName: currentTable.tableName,
});
if (savedFilters) {
try {
const parsed = JSON.parse(savedFilters) as Array<{
@ -346,13 +287,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
width: f.width || 200,
}));
console.log("📌 [TableSearchWidget] 필터 설정 로드:", {
filterConfigKey,
총필터수: parsed.length,
활성화필터수: activeFiltersList.length,
활성화필터: activeFiltersList.map((f) => f.columnName),
});
setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원
@ -382,10 +316,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}
} else {
// 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화
console.log("⚠️ [TableSearchWidget] 저장된 필터 설정 없음 - 필터 초기화:", {
tableName: currentTable.tableName,
filterConfigKey,
});
setActiveFilters([]);
setFilterValues({});
setSelectOptions({});
@ -540,7 +470,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
if (filter.filterType === "select" && Array.isArray(filterValue)) {
// filterType에 관계없이 배열이면 파이프로 연결
if (Array.isArray(filterValue)) {
filterValue = filterValue.join("|");
}
@ -553,26 +484,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 빈 값 체크
if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false;
if (Array.isArray(f.value) && f.value.length === 0) return false;
return true;
});
console.log("🔍 [TableSearchWidget] applyFilters 호출:", {
currentTableId: currentTable?.tableId,
currentTableName: currentTable?.tableName,
hasOnFilterChange: !!currentTable?.onFilterChange,
filtersCount: filtersWithValues.length,
filters: filtersWithValues.map((f) => ({
col: f.columnName,
op: f.operator,
val: f.value,
})),
});
if (currentTable?.onFilterChange) {
currentTable.onFilterChange(filtersWithValues);
} else {
console.warn("⚠️ [TableSearchWidget] onFilterChange가 없음!", { currentTable });
}
};
@ -771,54 +687,28 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</div>
)}
{/* 동적 모드일 때만 설정 버튼 표시 (미리보기에서는 비활성화) */}
{/* 동적 모드일 때만 설정 버튼 표시 (미리보기에서는 비활성화) */}
{filterMode === "dynamic" && (
<>
<Button
variant="outline"
size="sm"
onClick={() => !isPreviewMode && setColumnVisibilityOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => !isPreviewMode && setFilterOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => !isPreviewMode && setGroupingOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</>
<Button
variant="outline"
size="sm"
onClick={() => !isPreviewMode && setSettingsOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
)}
</div>
{/* 패널들 */}
<ColumnVisibilityPanel isOpen={columnVisibilityOpen} onClose={() => setColumnVisibilityOpen(false)} />
<FilterPanel
isOpen={filterOpen}
onClose={() => setFilterOpen(false)}
{/* 통합 설정 모달 */}
<TableSettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
onFiltersApplied={(filters) => setActiveFilters(filters)}
screenId={screenId}
/>
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
</div>
);
}

View File

@ -529,8 +529,6 @@ export class ButtonActionExecutor {
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData keys:", Object.keys(context.formData || {}));
// 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) {
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
@ -549,13 +547,13 @@ export class ButtonActionExecutor {
);
if (hasTableSectionData) {
console.log("📋 [handleSave] _tableSection_ 데이터 감지 - onSave 콜백 건너뛰고 테이블 섹션 저장 로직 사용");
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
if (onSave && !hasTableSectionData) {
console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행 (테이블 섹션 데이터 없음)");
try {
await onSave();
return true;
@ -2214,14 +2212,6 @@ export class ButtonActionExecutor {
// 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용
const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData;
console.log(`🔍 [DELETE 비교] 섹션 ${sectionId}:`, {
sectionOriginalKey,
sectionOriginalCount: sectionOriginalData.length,
globalOriginalCount: originalGroupedData.length,
usingData: sectionOriginalData.length > 0 ? "섹션별 원본" : "전역 원본",
currentCount: currentItems.length,
});
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean));
const deletedItems = originalDataForDelete.filter((orig) => orig.id && !currentIds.has(String(orig.id)));

View File

@ -319,8 +319,6 @@ class LegacyEventAdapter {
this.config = { ...this.config, ...options };
}
console.log("[LegacyEventAdapter] 초기화 시작", this.config);
EVENT_MAPPINGS.forEach((mapping) => {
// 레거시 → V2 브릿지
if (this.config.legacyToV2) {
@ -334,9 +332,6 @@ class LegacyEventAdapter {
});
this.isActive = true;
console.log(
`[LegacyEventAdapter] 초기화 완료 (${EVENT_MAPPINGS.length}개 매핑)`
);
}
private setupLegacyToV2Bridge(mapping: EventMapping): void {
@ -411,8 +406,6 @@ class LegacyEventAdapter {
this.bridgedEvents.clear();
this.isActive = false;
console.log("[LegacyEventAdapter] 정리 완료");
}
/**

View File

@ -55,8 +55,6 @@ export function initV2Core(options?: V2CoreOptions): void {
legacyBridge = { legacyToV2: true, v2ToLegacy: true },
} = options ?? {};
console.log("[V2Core] 초기화 시작...");
// 디버그 모드 설정
v2EventBus.debug = debug;
@ -64,11 +62,6 @@ export function initV2Core(options?: V2CoreOptions): void {
legacyEventAdapter.init(legacyBridge);
isInitialized = true;
console.log("[V2Core] 초기화 완료", {
debug,
legacyBridge: legacyEventAdapter.active,
});
}
/**