ERP-node/frontend/components/screen/panels/DataTableConfigPanel.tsx

2362 lines
106 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Table, Plus, Trash2, Settings, Filter, Columns, ChevronDown } from "lucide-react";
import { DataTableComponent, DataTableColumn, DataTableFilter, TableInfo, ColumnInfo, WebType } from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
interface DataTableConfigPanelProps {
component: DataTableComponent;
tables: TableInfo[];
activeTab?: string;
onTabChange?: (tab: string) => void;
onUpdateComponent: (updates: Partial<DataTableComponent>) => void;
}
const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
component,
tables,
activeTab: externalActiveTab,
onTabChange,
onUpdateComponent,
}) => {
// 동적 웹타입 옵션 가져오기
const { webTypes } = useWebTypes({ active: "Y" });
const webTypeOptions = webTypes.map((wt) => ({
value: wt.web_type as WebType,
label: wt.type_name,
}));
const [selectedTable, setSelectedTable] = useState<TableInfo | null>(null);
// 로컬 입력 상태 (실시간 타이핑용)
const [localValues, setLocalValues] = useState({
title: component.title || "",
searchButtonText: component.searchButtonText || "검색",
showSearchButton: component.showSearchButton ?? true,
enableExport: component.enableExport ?? true,
enableRefresh: component.enableRefresh ?? true,
enableAdd: component.enableAdd ?? true,
enableEdit: component.enableEdit ?? true,
enableDelete: component.enableDelete ?? true,
addButtonText: component.addButtonText || "추가",
editButtonText: component.editButtonText || "수정",
deleteButtonText: component.deleteButtonText || "삭제",
// 추가 모달 설정
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
// 테이블명도 로컬 상태로 관리
tableName: component.tableName || "",
modalDescription: component.addModalConfig?.description || "",
modalWidth: component.addModalConfig?.width || "lg",
modalLayout: component.addModalConfig?.layout || "two-column",
modalGridColumns: component.addModalConfig?.gridColumns || 2,
modalSubmitButtonText: component.addModalConfig?.submitButtonText || "추가",
modalCancelButtonText: component.addModalConfig?.cancelButtonText || "취소",
// 수정 모달 설정
editModalTitle: component.editModalConfig?.title || "",
editModalDescription: component.editModalConfig?.description || "",
paginationEnabled: component.pagination?.enabled ?? true,
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
showPageInfo: component.pagination?.showPageInfo ?? true,
showFirstLast: component.pagination?.showFirstLast ?? true,
});
// 컬럼별 로컬 입력 상태
const [localColumnInputs, setLocalColumnInputs] = useState<Record<string, string>>({});
// 컬럼별 체크박스 및 설정 상태
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
Record<
string,
{
visible: boolean;
sortable: boolean;
searchable: boolean;
}
>
>({});
// 컬럼별 그리드 컬럼 설정 상태
const [localColumnGridColumns, setLocalColumnGridColumns] = useState<Record<string, number>>({});
// 필터별 로컬 입력 상태
const [localFilterInputs, setLocalFilterInputs] = useState<Record<string, string>>({});
// 컬럼별 상세 설정 상태
const [localColumnDetailSettings, setLocalColumnDetailSettings] = useState<Record<string, any>>({});
// 컬럼별 상세 설정 확장/축소 상태
const [isColumnDetailOpen, setIsColumnDetailOpen] = useState<Record<string, boolean>>({});
// 모달 설정 확장/축소 상태
const [isModalConfigOpen, setIsModalConfigOpen] = useState<Record<string, boolean>>({});
// 탭 상태 관리 (외부에서 받거나 로컬 상태 사용)
const [internalActiveTab, setInternalActiveTab] = useState("basic");
const activeTab = externalActiveTab || internalActiveTab;
const setActiveTab = onTabChange || setInternalActiveTab;
// 컴포넌트 변경 시 로컬 값 동기화
useEffect(() => {
// console.log("🔄 DataTableConfig: 컴포넌트 변경 감지", {
// componentId: component.id,
// title: component.title,
// searchButtonText: component.searchButtonText,
// columnsCount: component.columns.length,
// filtersCount: component.filters.length,
// columnIds: component.columns.map((col) => col.id),
// filterColumnNames: component.filters.map((filter) => filter.columnName),
// timestamp: new Date().toISOString(),
// });
// 컬럼과 필터 상세 정보 로그
if (component.columns.length > 0) {
// console.log(
// "📋 현재 컬럼 목록:",
// component.columns.map((col) => ({
// id: col.id,
// columnName: col.columnName,
// label: col.label,
// visible: col.visible,
// gridColumns: col.gridColumns,
// })),
// );
}
// 로컬 상태 정보 로그
// console.log("🔧 로컬 상태 정보:", {
// localColumnInputsCount: Object.keys(localColumnInputs).length,
// localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length,
// localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length,
// });
if (component.filters.length > 0) {
// console.log(
// "🔍 현재 필터 목록:",
// component.filters.map((filter) => ({
// columnName: filter.columnName,
// widgetType: filter.widgetType,
// label: filter.label,
// })),
// );
}
setLocalValues({
title: component.title || "",
searchButtonText: component.searchButtonText || "검색",
showSearchButton: component.showSearchButton ?? true,
enableExport: component.enableExport ?? true,
enableRefresh: component.enableRefresh ?? true,
enableAdd: component.enableAdd ?? true,
enableEdit: component.enableEdit ?? true,
enableDelete: component.enableDelete ?? true,
addButtonText: component.addButtonText || "추가",
editButtonText: component.editButtonText || "수정",
deleteButtonText: component.deleteButtonText || "삭제",
// 추가 모달 설정
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
modalDescription: component.addModalConfig?.description || "",
modalWidth: component.addModalConfig?.width || "lg",
modalLayout: component.addModalConfig?.layout || "two-column",
modalGridColumns: component.addModalConfig?.gridColumns || 2,
modalSubmitButtonText: component.addModalConfig?.submitButtonText || "추가",
modalCancelButtonText: component.addModalConfig?.cancelButtonText || "취소",
// 수정 모달 설정
editModalTitle: component.editModalConfig?.title || "",
editModalDescription: component.editModalConfig?.description || "",
paginationEnabled: component.pagination?.enabled ?? true,
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
showPageInfo: component.pagination?.showPageInfo ?? true,
showFirstLast: component.pagination?.showFirstLast ?? true,
// 테이블명 동기화
tableName: component.tableName || "",
});
// 컬럼 라벨 로컬 상태 초기화 (기존 값이 없는 경우만)
setLocalColumnInputs((prev) => {
const newInputs = { ...prev };
component.columns.forEach((col) => {
// 기존에 로컬 입력값이 없는 경우만 초기화
if (!(col.id in newInputs)) {
newInputs[col.id] = col.label;
}
});
// 삭제된 컬럼의 로컬 상태 제거
const currentColumnIds = new Set(component.columns.map((col) => col.id));
Object.keys(newInputs).forEach((id) => {
if (!currentColumnIds.has(id)) {
delete newInputs[id];
}
});
return newInputs;
});
// 컬럼별 체크박스 상태 초기화
setLocalColumnCheckboxes((prev) => {
const newCheckboxes = { ...prev };
component.columns.forEach((col) => {
// 기존에 로컬 체크박스 상태가 없는 경우만 초기화
if (!(col.id in newCheckboxes)) {
newCheckboxes[col.id] = {
visible: col.visible,
sortable: col.sortable,
searchable: col.searchable,
};
}
});
// 삭제된 컬럼의 로컬 상태 제거
const currentColumnIds = new Set(component.columns.map((col) => col.id));
Object.keys(newCheckboxes).forEach((id) => {
if (!currentColumnIds.has(id)) {
delete newCheckboxes[id];
}
});
return newCheckboxes;
});
// 컬럼별 그리드 컬럼 설정 상태 초기화
setLocalColumnGridColumns((prev) => {
const newGridColumns = { ...prev };
component.columns.forEach((col) => {
// 기존에 로컬 그리드 컬럼 설정이 없는 경우만 초기화
if (!(col.id in newGridColumns)) {
newGridColumns[col.id] = col.gridColumns;
}
});
// 삭제된 컬럼의 로컬 상태 제거
const currentColumnIds = new Set(component.columns.map((col) => col.id));
Object.keys(newGridColumns).forEach((id) => {
if (!currentColumnIds.has(id)) {
delete newGridColumns[id];
}
});
return newGridColumns;
});
// 필터별 로컬 입력 상태 동기화 (기존 값 보존하면서 새 필터만 추가)
setLocalFilterInputs((prev) => {
const newFilterInputs = { ...prev };
component.filters?.forEach((filter, index) => {
const filterKey = `${filter.columnName}-${index}`;
if (!(filterKey in newFilterInputs)) {
newFilterInputs[filterKey] = filter.label || filter.columnName;
// console.log("🆕 새 필터 로컬 상태 추가:", {
// filterKey,
// label: filter.label,
// columnName: filter.columnName,
// });
}
});
// 삭제된 필터의 로컬 상태 제거
const currentFilterKeys = new Set(
component.filters?.map((filter, index) => `${filter.columnName}-${index}`) || [],
);
Object.keys(newFilterInputs).forEach((key) => {
if (!currentFilterKeys.has(key)) {
// console.log("🗑️ 삭제된 필터 로컬 상태 제거:", { filterKey: key });
delete newFilterInputs[key];
}
});
// console.log("📝 필터 로컬 상태 동기화 완료:", {
// prevCount: Object.keys(prev).length,
// newCount: Object.keys(newFilterInputs).length,
// newKeys: Object.keys(newFilterInputs),
// });
return newFilterInputs;
});
}, [
component.id,
component.title,
component.searchButtonText,
component.showSearchButton,
component.enableExport,
component.enableRefresh,
component.pagination,
component.columns.length, // 컬럼 개수만 감지
component.filters.length, // 필터 개수만 감지
]);
// 선택된 테이블 정보 로드
useEffect(() => {
if (component.tableName && tables.length > 0) {
const table = tables.find((t) => t.tableName === component.tableName);
setSelectedTable(table || null);
}
}, [component.tableName, tables]);
// 테이블 변경 시 컬럼 자동 설정
const handleTableChange = useCallback(
(tableName: string) => {
// 이미 같은 테이블이 선택되어 있으면 무시
if (localValues.tableName === tableName) {
return;
}
// 로컬 상태 먼저 업데이트
setLocalValues((prev) => ({ ...prev, tableName }));
const table = tables.find((t) => t.tableName === tableName);
if (!table) return;
// console.log("🔄 테이블 변경:", {
// tableName,
// currentTableName: localValues.tableName,
// table,
// columnsCount: table.columns.length,
// });
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
const defaultColumns: DataTableColumn[] = [];
// console.log("✅ 생성된 컬럼 설정:", {
// defaultColumnsCount: defaultColumns.length,
// visibleColumns: defaultColumns.filter((col) => col.visible).length,
// });
// 상태 업데이트를 한 번에 처리
setTimeout(() => {
onUpdateComponent({
tableName,
columns: defaultColumns,
filters: [], // 빈 필터 배열
});
setSelectedTable(table);
}, 0);
},
[tables, onUpdateComponent, localValues.tableName],
);
// 컬럼 타입 추론 (통합 매핑 시스템 사용)
const getWidgetTypeFromColumn = (column: ColumnInfo): WebType => {
// 통합 자동 매핑 유틸리티 사용
const { inferWebTypeFromColumn } = require("@/lib/utils/dbTypeMapping");
return inferWebTypeFromColumn(column.dataType || "text", column.columnName);
};
// 컬럼 업데이트
const updateColumn = useCallback(
(columnId: string, updates: Partial<DataTableColumn>) => {
const updatedColumns = component.columns.map((col) => (col.id === columnId ? { ...col, ...updates } : col));
onUpdateComponent({ columns: updatedColumns });
},
[component.columns, onUpdateComponent],
);
// 컬럼 상세 설정 업데이트 (테이블 타입 관리에도 반영)
const updateColumnDetailSettings = useCallback(
async (columnId: string, webTypeConfig: any) => {
// 1. 먼저 화면 컴포넌트의 컬럼 설정 업데이트
const updatedColumns = component.columns.map((col) => (col.id === columnId ? { ...col, webTypeConfig } : col));
// console.log("🔄 컬럼 상세 설정 업데이트:", { columnId, webTypeConfig, updatedColumns });
onUpdateComponent({ columns: updatedColumns });
// 2. 테이블 타입 관리에도 반영 (라디오 타입인 경우)
const targetColumn = component.columns.find((col) => col.id === columnId);
if (targetColumn && targetColumn.widgetType === "radio" && selectedTable) {
try {
// TODO: 테이블 타입 관리 API 호출하여 웹 타입과 상세 설정 업데이트
// console.log("📡 테이블 타입 관리 업데이트 필요:", {
// tableName: component.tableName,
// columnName: targetColumn.columnName,
// webType: "radio",
// detailSettings: JSON.stringify(webTypeConfig),
// });
} catch (error) {
// console.error("테이블 타입 관리 업데이트 실패:", error);
}
}
},
[component.columns, component.tableName, selectedTable, onUpdateComponent],
);
// 컬럼의 현재 웹 타입 가져오기 (테이블 타입 관리에서 설정된 값)
const getColumnWebType = useCallback(
(column: DataTableColumn) => {
// 테이블 타입 관리에서 설정된 웹 타입 찾기
if (!selectedTable) return "text";
const tableColumn = selectedTable.columns.find((col) => col.columnName === column.columnName);
return (
tableColumn?.webType ||
getWidgetTypeFromColumn(
tableColumn || {
columnName: column.columnName,
dataType: "text",
tableName: "",
isNullable: true,
},
)
);
},
[selectedTable],
);
// 컬럼의 현재 상세 설정 가져오기
const getColumnCurrentDetailSettings = useCallback((column: DataTableColumn) => {
return column.webTypeConfig || {};
}, []);
// 웹 타입별 상세 설정 렌더링
const renderColumnDetailSettings = useCallback(
(column: DataTableColumn) => {
const webType = getColumnWebType(column);
const currentSettings = getColumnCurrentDetailSettings(column);
const localSettings = localColumnDetailSettings[column.id] || currentSettings;
const updateSettings = (newSettings: any) => {
const merged = { ...localSettings, ...newSettings };
setLocalColumnDetailSettings((prev) => ({
...prev,
[column.id]: merged,
}));
updateColumnDetailSettings(column.id, merged);
};
switch (webType) {
case "select":
case "dropdown":
case "radio":
return (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
{(localSettings.options || []).map((option: any, index: number) => {
// 안전한 값 추출
const currentLabel =
typeof option === "object" && option !== null
? option.label || option.value || ""
: String(option || "");
return (
<div key={index} className="flex items-center space-x-2">
<Input
value={currentLabel}
onChange={(e) => {
const newOptions = [...(localSettings.options || [])];
newOptions[index] = { label: e.target.value, value: e.target.value };
updateSettings({ options: newOptions });
}}
placeholder="옵션명"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newOptions = (localSettings.options || []).filter((_: any, i: number) => i !== index);
updateSettings({ options: newOptions });
}}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOption = { label: "", value: "" };
updateSettings({ options: [...(localSettings.options || []), newOption] });
}}
className="h-7 text-xs" style={{ fontSize: "12px" }}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{webType === "radio" ? (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<select
value={localSettings.defaultValue || "__NONE__"}
onChange={(e) => {
const value = e.target.value;
updateSettings({ defaultValue: value === "__NONE__" ? "" : value });
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-full items-center justify-between rounded-md border px-3 py-1 text-xs focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="__NONE__"> </option>
{(localSettings.options || []).map((option: any, index: number) => {
// 안전한 문자열 변환
const getStringValue = (val: any): string => {
if (typeof val === "string") return val;
if (typeof val === "number") return String(val);
if (typeof val === "object" && val !== null) {
return val.label || val.value || val.name || JSON.stringify(val);
}
return String(val || "");
};
const optionValue = getStringValue(option.value || option.label || option) || `option-${index}`;
const optionLabel = getStringValue(option.label || option.value || option) || `옵션 ${index + 1}`;
return (
<option key={index} value={optionValue}>
{optionLabel}
</option>
);
})}
</select>
</div>
) : (
<div className="flex items-center space-x-2">
<Checkbox
checked={localSettings.multiple || false}
onCheckedChange={(checked) => updateSettings({ multiple: checked })}
/>
<Label className="text-xs"> </Label>
</div>
)}
</div>
);
case "number":
case "decimal":
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="number"
value={localSettings.min || ""}
onChange={(e) => updateSettings({ min: e.target.value ? Number(e.target.value) : undefined })}
placeholder="최소값"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="number"
value={localSettings.max || ""}
onChange={(e) => updateSettings({ max: e.target.value ? Number(e.target.value) : undefined })}
placeholder="최대값"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
</div>
{webType === "decimal" && (
<div className="space-y-1">
<Label className="text-xs"> (step)</Label>
<Input
type="number"
step="0.01"
value={localSettings.step || "0.01"}
onChange={(e) => updateSettings({ step: e.target.value })}
placeholder="0.01"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
)}
</div>
);
case "date":
case "datetime":
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="date"
value={localSettings.minDate || ""}
onChange={(e) => updateSettings({ minDate: e.target.value })}
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="date"
value={localSettings.maxDate || ""}
onChange={(e) => updateSettings({ maxDate: e.target.value })}
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
</div>
{webType === "datetime" && (
<div className="flex items-center space-x-2">
<Checkbox
checked={localSettings.showSeconds || false}
onCheckedChange={(checked) => updateSettings({ showSeconds: checked })}
/>
<Label className="text-xs"> </Label>
</div>
)}
</div>
);
case "text":
case "email":
case "tel":
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="number"
value={localSettings.maxLength || ""}
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
placeholder="최대 문자 수"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={localSettings.placeholder || ""}
onChange={(e) => updateSettings({ placeholder: e.target.value })}
placeholder="입력 안내 텍스트"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
</div>
);
case "textarea":
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="number"
value={localSettings.rows || "3"}
onChange={(e) => updateSettings({ rows: Number(e.target.value) })}
placeholder="3"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="number"
value={localSettings.maxLength || ""}
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
placeholder="최대 문자 수"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
</div>
</div>
);
case "file":
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={localSettings.accept || ""}
onChange={(e) => updateSettings({ accept: e.target.value })}
placeholder=".jpg,.png,.pdf"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> (MB)</Label>
<Input
type="number"
value={localSettings.maxSize ? localSettings.maxSize / 1024 / 1024 : "10"}
onChange={(e) => updateSettings({ maxSize: Number(e.target.value) * 1024 * 1024 })}
placeholder="10"
className="h-7 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={localSettings.multiple || false}
onCheckedChange={(checked) => updateSettings({ multiple: checked })}
/>
<Label className="text-xs"> </Label>
</div>
</div>
);
default:
return <div className="py-2 text-xs text-gray-500"> ({webType}) .</div>;
}
},
[getColumnWebType, getColumnCurrentDetailSettings, localColumnDetailSettings, updateColumnDetailSettings],
);
// 컬럼 삭제
const removeColumn = useCallback(
(columnId: string) => {
const columnToRemove = component.columns.find((col) => col.id === columnId);
const updatedColumns = component.columns.filter((col) => col.id !== columnId);
// 로컬 상태에서도 해당 컬럼 제거
setLocalColumnInputs((prev) => {
const newInputs = { ...prev };
delete newInputs[columnId];
return newInputs;
});
// 로컬 체크박스 상태에서도 해당 컬럼 제거
setLocalColumnCheckboxes((prev) => {
const newCheckboxes = { ...prev };
delete newCheckboxes[columnId];
return newCheckboxes;
});
// 로컬 그리드 컬럼 상태에서도 해당 컬럼 제거
setLocalColumnGridColumns((prev) => {
const newGridColumns = { ...prev };
delete newGridColumns[columnId];
return newGridColumns;
});
// console.log("🗑️ 컬럼 삭제:", {
// columnId,
// columnName: columnToRemove?.columnName,
// remainingColumns: updatedColumns.length,
// });
onUpdateComponent({
columns: updatedColumns,
});
},
[component.columns, onUpdateComponent],
);
// 필터 업데이트
const updateFilter = useCallback(
(index: number, updates: Partial<DataTableFilter>) => {
const updatedFilters = component.filters.map((filter, i) => (i === index ? { ...filter, ...updates } : filter));
// console.log("🔄 필터 업데이트:", { index, updates, updatedFilters });
onUpdateComponent({ filters: updatedFilters });
},
[component.filters, onUpdateComponent],
);
// 필터 추가
const addFilter = useCallback(() => {
if (!selectedTable) return;
// 필터 가능한 컬럼들 중에서 아직 필터가 없는 컬럼들만 선택
const availableColumns = selectedTable.columns.filter(
(col) =>
isFilterableWebType(getWidgetTypeFromColumn(col)) &&
!component.filters.some((filter) => filter.columnName === col.columnName),
);
if (availableColumns.length === 0) return;
const targetColumn = availableColumns[0];
const widgetType = getWidgetTypeFromColumn(targetColumn);
const newFilter: DataTableFilter = {
columnName: targetColumn.columnName,
widgetType,
label: targetColumn.columnLabel || targetColumn.columnName,
gridColumns: getDefaultGridColumns(widgetType),
// 웹타입별 추가 정보 설정
codeCategory: targetColumn.codeCategory,
referenceTable: targetColumn.referenceTable,
referenceColumn: targetColumn.referenceColumn,
displayColumn: targetColumn.displayColumn,
};
// console.log(" 필터 추가 시작:", {
// targetColumnName: targetColumn.columnName,
// targetColumnLabel: targetColumn.columnLabel,
// inferredWidgetType: widgetType,
// currentFiltersCount: component.filters.length,
// });
// console.log(" 생성된 새 필터:", {
// columnName: newFilter.columnName,
// widgetType: newFilter.widgetType,
// label: newFilter.label,
// gridColumns: newFilter.gridColumns,
// });
const updatedFilters = [...component.filters, newFilter];
// console.log("🔄 필터 업데이트 호출:", {
// filtersToAdd: 1,
// totalFiltersAfter: updatedFilters.length,
// updatedFilters: updatedFilters.map((filter) => ({
// columnName: filter.columnName,
// widgetType: filter.widgetType,
// label: filter.label,
// })),
// });
// 먼저 로컬 상태를 업데이트하고
const filterKey = `${newFilter.columnName}-${component.filters.length}`;
setLocalFilterInputs((prev) => {
const newState = {
...prev,
[filterKey]: newFilter.label,
};
// console.log("📝 필터 로컬 상태 업데이트:", {
// filterKey,
// newLabel: newFilter.label,
// prevState: prev,
// newState,
// });
return newState;
});
// 그 다음 컴포넌트 상태를 업데이트
onUpdateComponent({ filters: updatedFilters });
// 필터 추가 후 필터 탭으로 자동 이동
setActiveTab("filters");
// console.log("🔍 필터 추가 후 탭 이동:", {
// activeTab: "filters",
// isExternalControl: !!onTabChange,
// });
// 강제로 리렌더링을 트리거하기 위해 여러 방법 사용
setTimeout(() => {
setLocalFilterInputs((prev) => ({
...prev,
[filterKey]: newFilter.label,
}));
// console.log("🔄 setTimeout에서 강제 로컬 상태 업데이트:", { filterKey, label: newFilter.label });
}, 0);
// 추가적인 강제 업데이트
setTimeout(() => {
setLocalFilterInputs((prev) => {
const updated = { ...prev, [filterKey]: newFilter.label };
// console.log("🔄 두 번째 강제 업데이트:", { updated });
return updated;
});
}, 100);
// console.log("✅ 필터 추가 완료 - 로컬 상태와 컴포넌트 모두 업데이트됨", {
// filterKey,
// newFilterLabel: newFilter.label,
// switchedToTab: "filters",
// });
}, [selectedTable, component.filters, onUpdateComponent]);
// 필터 삭제
const removeFilter = useCallback(
(index: number) => {
const updatedFilters = component.filters.filter((_, i) => i !== index);
// 로컬 필터 입력 상태에서도 해당 필터 제거
setLocalFilterInputs((prev) => {
const newFilterInputs = { ...prev };
const filterKey = `${component.filters?.[index]?.columnName}-${index}`;
delete newFilterInputs[filterKey];
return newFilterInputs;
});
onUpdateComponent({ filters: updatedFilters });
},
[component.filters, onUpdateComponent],
);
// 웹 타입별 필터 가능 여부 확인
const isFilterableWebType = (webType: WebType): boolean => {
// 대부분의 웹타입은 필터링 가능 (파일, 버튼 등만 제외)
const nonFilterableTypes = ["file", "button", "image"];
return !nonFilterableTypes.includes(webType);
};
// 웹타입별 기본 컬럼 수 계산 (컴포넌트 너비 기반)
const getDefaultGridColumns = (webType: WebType): number => {
// 각 웹타입별 적절한 기본 너비 설정
const widthMap: Record<WebType, number> = {
// 텍스트 입력 계열 (넓게)
text: 4, // 1/3 (33%)
email: 4, // 1/3 (33%)
tel: 3, // 1/4 (25%)
url: 4, // 1/3 (33%)
textarea: 6, // 절반 (50%)
// 숫자/날짜 입력 (중간)
number: 2, // 2/12 (16.67%)
decimal: 2, // 2/12 (16.67%)
date: 3, // 1/4 (25%)
datetime: 3, // 1/4 (25%)
time: 2, // 2/12 (16.67%)
// 선택 입력 (중간)
select: 3, // 1/4 (25%)
radio: 3, // 1/4 (25%)
checkbox: 2, // 2/12 (16.67%)
boolean: 2, // 2/12 (16.67%)
// 코드/참조 (넓게)
code: 3, // 1/4 (25%)
entity: 4, // 1/3 (33%)
// 파일/이미지 (넓게)
file: 4, // 1/3 (33%)
image: 3, // 1/4 (25%)
// 기타
button: 2, // 2/12 (16.67%)
label: 2, // 2/12 (16.67%)
};
const defaultColumns = widthMap[webType] || 3; // 기본값 3 (1/4, 25%)
console.log("🎯 getDefaultGridColumns 호출:", { webType, defaultColumns, widthMap: widthMap[webType] });
return defaultColumns;
};
// 컬럼 추가 (테이블에서 선택)
const addColumn = useCallback(
(columnName?: string) => {
if (!selectedTable) return;
const availableColumns = selectedTable.columns.filter(
(col) => !component.columns.some((column) => column.columnName === col.columnName),
);
if (availableColumns.length === 0) return;
// 특정 컬럼이 지정되었으면 해당 컬럼을, 아니면 첫 번째 사용 가능한 컬럼을 사용
const targetColumn = columnName
? availableColumns.find((col) => col.columnName === columnName) || availableColumns[0]
: availableColumns[0];
const widgetType = getWidgetTypeFromColumn(targetColumn);
const calculatedGridColumns = getDefaultGridColumns(widgetType);
console.log(" addColumn 호출:", {
columnName: targetColumn.columnName,
widgetType,
calculatedGridColumns,
});
const newColumn: DataTableColumn = {
id: generateComponentId(),
columnName: targetColumn.columnName,
label: targetColumn.columnLabel || targetColumn.columnName,
widgetType,
gridColumns: calculatedGridColumns,
visible: true,
filterable: isFilterableWebType(widgetType),
sortable: true,
searchable: ["text", "email", "tel"].includes(widgetType),
};
console.log("✅ 생성된 newColumn:", newColumn);
// 필터는 자동으로 추가하지 않음 (사용자가 수동으로 추가)
// console.log(" 컬럼 추가 시작:", {
// targetColumnName: targetColumn.columnName,
// targetColumnLabel: targetColumn.columnLabel,
// inferredWidgetType: widgetType,
// currentColumnsCount: component.columns.length,
// currentFiltersCount: component.filters.length,
// });
// console.log(" 생성된 새 컬럼:", {
// id: newColumn.id,
// columnName: newColumn.columnName,
// label: newColumn.label,
// widgetType: newColumn.widgetType,
// filterable: newColumn.filterable,
// visible: newColumn.visible,
// sortable: newColumn.sortable,
// searchable: newColumn.searchable,
// });
// 필터는 수동으로만 추가
// 로컬 상태에 새 컬럼 입력값 추가
setLocalColumnInputs((prev) => {
const newInputs = {
...prev,
[newColumn.id]: newColumn.label,
};
// console.log("🔄 로컬 컬럼 상태 업데이트:", {
// newColumnId: newColumn.id,
// newLabel: newColumn.label,
// totalLocalInputs: Object.keys(newInputs).length,
// });
return newInputs;
});
// 로컬 체크박스 상태에 새 컬럼 추가
setLocalColumnCheckboxes((prev) => ({
...prev,
[newColumn.id]: {
visible: newColumn.visible,
sortable: newColumn.sortable,
searchable: newColumn.searchable,
},
}));
// 로컬 그리드 컬럼 상태에 새 컬럼 추가
setLocalColumnGridColumns((prev) => ({
...prev,
[newColumn.id]: newColumn.gridColumns,
}));
// 컬럼만 업데이트
const updates: Partial<DataTableComponent> = {
columns: [...component.columns, newColumn],
};
// console.log("🔄 컴포넌트 업데이트 호출:", {
// columnsToAdd: 1,
// totalColumnsAfter: updates.columns?.length,
// hasColumns: !!updates.columns,
// updateKeys: Object.keys(updates),
// });
// console.log("🔄 업데이트 상세 내용:", {
// columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })),
// });
onUpdateComponent(updates);
// 컬럼 추가 후 컬럼 탭으로 자동 이동
setActiveTab("columns");
// console.log("📋 컬럼 추가 후 탭 이동:", {
// activeTab: "columns",
// isExternalControl: !!onTabChange,
// });
// console.log("✅ 컬럼 추가 완료 - onUpdateComponent 호출됨");
},
[selectedTable, component.columns, component.filters, onUpdateComponent],
);
// 가상 파일 컬럼 추가
const addVirtualFileColumn = useCallback(() => {
const fileColumnCount = component.columns.filter((col) => col.isVirtualFileColumn).length;
const newColumnName = `file_column_${fileColumnCount + 1}`; // 순차적 번호 사용
const newColumn: DataTableColumn = {
id: generateComponentId(),
columnName: newColumnName,
label: `파일 컬럼 ${fileColumnCount + 1}`,
widgetType: "file",
gridColumns: getDefaultGridColumns("file"),
visible: true,
filterable: false, // 파일 컬럼은 필터링 불가
sortable: false, // 파일 컬럼은 정렬 불가
searchable: false, // 파일 컬럼은 검색 불가
isVirtualFileColumn: true, // 가상 파일 컬럼 표시
fileColumnConfig: {
docType: "DOCUMENT",
docTypeName: "일반 문서",
maxFiles: 5,
accept: ["*/*"],
},
};
// console.log("📁 가상 파일 컬럼 추가:", {
// columnName: newColumn.columnName,
// label: newColumn.label,
// isVirtualFileColumn: newColumn.isVirtualFileColumn,
// });
// 로컬 상태에 새 컬럼 입력값 추가
setLocalColumnInputs((prev) => ({
...prev,
[newColumn.id]: newColumn.label,
}));
// 로컬 체크박스 상태에 새 컬럼 추가
setLocalColumnCheckboxes((prev) => ({
...prev,
[newColumn.id]: {
visible: newColumn.visible,
sortable: newColumn.sortable,
searchable: newColumn.searchable,
},
}));
// 로컬 그리드 컬럼 상태에 새 컬럼 추가
setLocalColumnGridColumns((prev) => ({
...prev,
[newColumn.id]: newColumn.gridColumns,
}));
// 컬럼 업데이트
const updates: Partial<DataTableComponent> = {
columns: [...component.columns, newColumn],
};
onUpdateComponent(updates);
// 컬럼 추가 후 컬럼 탭으로 자동 이동
setActiveTab("columns");
// console.log("✅ 가상 파일 컬럼 추가 완료");
}, [component.columns, onUpdateComponent]);
return (
<div className="max-h-[80vh] p-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic"> </TabsTrigger>
<TabsTrigger value="columns"> </TabsTrigger>
<TabsTrigger value="filters"> </TabsTrigger>
<TabsTrigger value="modal"> </TabsTrigger>
</TabsList>
<TabsContent value="basic" className="mt-4 max-h-[70vh] overflow-y-auto">
{/* 기본 설정 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
<Settings className="h-4 w-4" />
<span> </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="table-select"> </Label>
<select
className="focus:border-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={localValues.tableName}
onChange={(e) => handleTableChange(e.target.value)}
>
<option value=""> </option>
{tables.map((table) => (
<option key={table.tableName} value={table.tableName}>
{table.tableLabel || table.tableName}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="title"> </Label>
<Input
id="title"
value={localValues.title}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, title: newValue }));
onUpdateComponent({ title: newValue });
}}
placeholder="테이블 제목을 입력하세요"
/>
</div>
{/* CRUD 기능 설정 */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium">CRUD </h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-add"
checked={localValues.enableAdd}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, enableAdd: checked as boolean }));
onUpdateComponent({ enableAdd: checked as boolean });
}}
/>
<Label htmlFor="enable-add" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-edit"
checked={localValues.enableEdit}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, enableEdit: checked as boolean }));
onUpdateComponent({ enableEdit: checked as boolean });
}}
/>
<Label htmlFor="enable-edit" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-delete"
checked={localValues.enableDelete}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, enableDelete: checked as boolean }));
onUpdateComponent({ enableDelete: checked as boolean });
}}
/>
<Label htmlFor="enable-delete" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="add-button-text" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="add-button-text"
value={localValues.addButtonText}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, addButtonText: newValue }));
onUpdateComponent({ addButtonText: newValue });
}}
placeholder="추가"
disabled={!localValues.enableAdd}
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-button-text" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="edit-button-text"
value={localValues.editButtonText}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, editButtonText: newValue }));
onUpdateComponent({ editButtonText: newValue });
}}
placeholder="수정"
disabled={!localValues.enableEdit}
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="delete-button-text" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="delete-button-text"
value={localValues.deleteButtonText}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, deleteButtonText: newValue }));
onUpdateComponent({ deleteButtonText: newValue });
}}
placeholder="삭제"
disabled={!localValues.enableDelete}
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
</div>
</div>
{/* 추가 모달 커스터마이징 설정 */}
{localValues.enableAdd && (
<div className="space-y-4 border-t pt-4">
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium"> </h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="modal-title" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="modal-title"
value={localValues.modalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, title: newValue },
});
}}
placeholder="새 데이터 추가"
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="modal-width" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<select
value={localValues.modalWidth}
onChange={(e) => {
const value = e.target.value;
setLocalValues((prev) => ({ ...prev, modalWidth: value as any }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, width: value as any },
});
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="sm"> (384px)</option>
<option value="md"> (448px)</option>
<option value="lg"> (512px)</option>
<option value="xl"> (576px)</option>
<option value="2xl"> (672px)</option>
<option value="full"> </option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-description" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="modal-description"
value={localValues.modalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, modalDescription: newValue }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, description: newValue },
});
}}
placeholder="모달에 표시될 설명을 입력하세요"
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="modal-layout" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<select
value={localValues.modalLayout}
onChange={(e) => {
const value = e.target.value;
setLocalValues((prev) => ({ ...prev, modalLayout: value as any }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, layout: value as any },
});
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="single"> </option>
<option value="two-column">2</option>
<option value="grid"></option>
</select>
</div>
{localValues.modalLayout === "grid" && (
<div className="space-y-2">
<Label htmlFor="modal-grid-columns" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<select
value={localValues.modalGridColumns.toString()}
onChange={(e) => {
const gridColumns = parseInt(e.target.value);
setLocalValues((prev) => ({ ...prev, modalGridColumns: gridColumns }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, gridColumns },
});
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="modal-submit-text" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="modal-submit-text"
value={localValues.modalSubmitButtonText}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, modalSubmitButtonText: newValue }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, submitButtonText: newValue },
});
}}
placeholder="추가"
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="modal-cancel-text" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="modal-cancel-text"
value={localValues.modalCancelButtonText}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, modalCancelButtonText: newValue }));
onUpdateComponent({
addModalConfig: { ...component.addModalConfig, cancelButtonText: newValue },
});
}}
placeholder="취소"
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
</div>
</div>
)}
{/* 수정 모달 설정 */}
{localValues.enableEdit && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h4 className="mb-3 text-sm font-medium text-gray-900"> </h4>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="edit-modal-title" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="edit-modal-title"
value={localValues.editModalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateComponent({
editModalConfig: { ...component.editModalConfig, title: newValue },
});
}}
placeholder="데이터 수정"
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
<p className="text-xs text-gray-500"> </p>
</div>
<div className="space-y-2">
<Label htmlFor="edit-modal-description" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
<Input
id="edit-modal-description"
value={localValues.editModalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalValues((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateComponent({
editModalConfig: { ...component.editModalConfig, description: newValue },
});
}}
placeholder="선택한 데이터를 수정합니다"
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
<p className="text-xs text-gray-500"> </p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="show-search-button"
checked={localValues.showSearchButton}
onCheckedChange={(checked) => {
// console.log("🔄 검색 버튼 표시 변경:", checked);
setLocalValues((prev) => ({ ...prev, showSearchButton: checked as boolean }));
onUpdateComponent({ showSearchButton: checked as boolean });
}}
/>
<Label htmlFor="show-search-button" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-export"
checked={localValues.enableExport}
onCheckedChange={(checked) => {
// console.log("🔄 내보내기 기능 변경:", checked);
setLocalValues((prev) => ({ ...prev, enableExport: checked as boolean }));
onUpdateComponent({ enableExport: checked as boolean });
}}
/>
<Label htmlFor="enable-export" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="columns" className="mt-4 max-h-[70vh] overflow-y-auto">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
<Columns className="h-4 w-4" />
<span> </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"> </h3>
<Badge variant="secondary">{component.columns.length}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* 파일 컬럼 추가 버튼 */}
<Button size="sm" variant="outline" onClick={addVirtualFileColumn} className="h-6 w-full px-2 py-0 text-xs">
<Plus className="h-4 w-4" />
<span className="ml-1"> </span>
</Button>
{/* 기존 DB 컬럼 추가 */}
{selectedTable &&
(() => {
const availableColumns = selectedTable.columns.filter(
(col) => !component.columns.some((column) => column.columnName === col.columnName),
);
return availableColumns.length > 0 ? (
<select
onChange={(e) => {
if (e.target.value) {
addColumn(e.target.value);
e.target.value = ""; // 선택 후 초기화
}
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-32 items-center justify-between rounded-md border px-3 py-1 text-xs focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">DB </option>
{availableColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</option>
))}
</select>
) : (
<Button size="sm" disabled>
<Plus className="h-4 w-4" />
<span className="ml-1 text-xs"> DB </span>
</Button>
);
})()}
</div>
</div>
<div className="max-h-96 space-y-3 overflow-x-hidden overflow-y-auto">
{component.columns.map((column, index) => (
<Card key={`${column.id}-${column.columnName}-${index}`} className="w-full p-2">
<div className="space-y-2">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
checked={localColumnCheckboxes[column.id]?.visible ?? column.visible}
onCheckedChange={(checked) => {
// console.log("🔄 컬럼 표시 변경:", { columnId: column.id, checked });
setLocalColumnCheckboxes((prev) => ({
...prev,
[column.id]: { ...prev[column.id], visible: checked as boolean },
}));
updateColumn(column.id, { visible: checked as boolean });
}}
/>
<span className="truncate text-sm font-medium">{column.label}</span>
</div>
<div className="flex items-center space-x-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsColumnDetailOpen((prev) => ({
...prev,
[column.id]: !prev[column.id],
}));
}}
>
<Settings className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeColumn(column.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="max-w-[120px] truncate text-xs">
{column.columnName}
</Badge>
<Badge variant="secondary" className="max-w-[80px] truncate text-xs">
{getColumnWebType(column)}
</Badge>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={
localColumnInputs[column.id] !== undefined ? localColumnInputs[column.id] : column.label
}
onChange={(e) => {
const newValue = e.target.value;
setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
updateColumn(column.id, { label: newValue });
}}
onBlur={(e) => {
// 포커스 잃을 때 빈 값이면 원본 라벨로 복원하지 않음 (사용자가 의도적으로 지운 것)
const newValue = e.target.value;
if (newValue !== localColumnInputs[column.id]) {
setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
updateColumn(column.id, { label: newValue });
}
}}
placeholder="표시명을 입력하세요"
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={(localColumnGridColumns[column.id] ?? column.gridColumns).toString()}
onValueChange={(value) => {
const newGridColumns = parseInt(value);
// console.log("🔄 컬럼 너비 변경:", { columnId: column.id, newGridColumns });
setLocalColumnGridColumns((prev) => ({
...prev,
[column.id]: newGridColumns,
}));
updateColumn(column.id, { gridColumns: newGridColumns });
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1/12 (8.33%)</SelectItem>
<SelectItem value="2">2/12 (16.67%)</SelectItem>
<SelectItem value="3">3/12 (25%)</SelectItem>
<SelectItem value="4">4/12 (33%)</SelectItem>
<SelectItem value="5">5/12 (41.67%)</SelectItem>
<SelectItem value="6">6/12 (50%)</SelectItem>
<SelectItem value="7">7/12 (58.33%)</SelectItem>
<SelectItem value="8">8/12 (66.67%)</SelectItem>
<SelectItem value="9">9/12 (75%)</SelectItem>
<SelectItem value="10">10/12 (83.33%)</SelectItem>
<SelectItem value="11">11/12 (91.67%)</SelectItem>
<SelectItem value="12">12/12 (100%)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-1">
<Checkbox
id={`sortable-${column.id}`}
checked={localColumnCheckboxes[column.id]?.sortable ?? column.sortable}
onCheckedChange={(checked) => {
// console.log("🔄 컬럼 정렬 가능 변경:", { columnId: column.id, checked });
setLocalColumnCheckboxes((prev) => ({
...prev,
[column.id]: { ...prev[column.id], sortable: checked as boolean },
}));
updateColumn(column.id, { sortable: checked as boolean });
}}
/>
<Label htmlFor={`sortable-${column.id}`} className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-1">
<Checkbox
id={`searchable-${column.id}`}
checked={localColumnCheckboxes[column.id]?.searchable ?? column.searchable}
onCheckedChange={(checked) => {
// console.log("🔄 컬럼 검색 가능 변경:", { columnId: column.id, checked });
setLocalColumnCheckboxes((prev) => ({
...prev,
[column.id]: { ...prev[column.id], searchable: checked as boolean },
}));
updateColumn(column.id, { searchable: checked as boolean });
}}
/>
<Label htmlFor={`searchable-${column.id}`} className="text-xs">
</Label>
</div>
</div>
</div>
{/* 웹 타입 상세 설정 */}
{isColumnDetailOpen[column.id] && (
<div className="border-t pt-2">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Badge variant="outline" className="text-xs">
{getColumnWebType(column)}
</Badge>
</div>
<div className="pl-2">{renderColumnDetailSettings(column)}</div>
</div>
)}
{/* 모달 전용 설정 */}
{component.enableAdd && (
<div className="border-t pt-2">
<Collapsible
open={isModalConfigOpen[column.id] || false}
onOpenChange={(open) => setIsModalConfigOpen((prev) => ({ ...prev, [column.id]: open }))}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex h-auto w-full items-center justify-between p-1"
>
<Label className="text-xs font-medium"> </Label>
<ChevronDown
className={`h-3 w-3 transition-transform ${
isModalConfigOpen[column.id] ? "rotate-180" : ""
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<div className="space-y-2">
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center space-x-1">
<Checkbox
id={`required-${column.id}`}
checked={
component.addModalConfig?.requiredFields?.includes(column.columnName) || false
}
onCheckedChange={(checked) => {
const requiredFields = component.addModalConfig?.requiredFields || [];
let newRequiredFields;
if (checked) {
newRequiredFields = [...requiredFields, column.columnName];
} else {
newRequiredFields = requiredFields.filter(
(field) => field !== column.columnName,
);
}
onUpdateComponent({
addModalConfig: {
...component.addModalConfig,
requiredFields: newRequiredFields,
},
});
}}
/>
<Label htmlFor={`required-${column.id}`} className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-1">
<Checkbox
id={`hidden-${column.id}`}
checked={
component.addModalConfig?.hiddenFields?.includes(column.columnName) || false
}
onCheckedChange={(checked) => {
const hiddenFields = component.addModalConfig?.hiddenFields || [];
let newHiddenFields;
if (checked) {
newHiddenFields = [...hiddenFields, column.columnName];
} else {
newHiddenFields = hiddenFields.filter((field) => field !== column.columnName);
}
onUpdateComponent({
addModalConfig: {
...component.addModalConfig,
hiddenFields: newHiddenFields,
},
});
}}
/>
<Label htmlFor={`hidden-${column.id}`} className="text-xs">
</Label>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={
component.addModalConfig?.advancedFieldConfigs?.[column.columnName]?.inputType ||
"normal"
}
onValueChange={(value) => {
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
const currentConfig = advancedConfigs[column.columnName] || {
columnName: column.columnName,
};
const newConfig = {
...currentConfig,
inputType: value as any,
autoValueType:
value === "auto" ? "current_datetime" : currentConfig.autoValueType || "none",
};
onUpdateComponent({
addModalConfig: {
...component.addModalConfig,
advancedFieldConfigs: {
...advancedConfigs,
[column.columnName]: newConfig,
},
},
});
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal"> </SelectItem>
<SelectItem value="readonly"> </SelectItem>
<SelectItem value="auto"> </SelectItem>
<SelectItem value="hidden"></SelectItem>
</SelectContent>
</Select>
</div>
{component.addModalConfig?.advancedFieldConfigs?.[column.columnName]?.inputType ===
"auto" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={
component.addModalConfig?.advancedFieldConfigs?.[column.columnName]
?.autoValueType || "current_datetime"
}
onValueChange={(value) => {
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
const currentConfig = advancedConfigs[column.columnName] || {
columnName: column.columnName,
};
onUpdateComponent({
addModalConfig: {
...component.addModalConfig,
advancedFieldConfigs: {
...advancedConfigs,
[column.columnName]: {
...currentConfig,
autoValueType: value as any,
},
},
},
});
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current_datetime"> </SelectItem>
<SelectItem value="current_date"> </SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="current_user"> </SelectItem>
<SelectItem value="uuid">UUID</SelectItem>
<SelectItem value="sequence">퀀</SelectItem>
<SelectItem value="custom"> </SelectItem>
</SelectContent>
</Select>
</div>
)}
{component.addModalConfig?.advancedFieldConfigs?.[column.columnName]?.autoValueType ===
"custom" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={
component.addModalConfig?.advancedFieldConfigs?.[column.columnName]
?.customValue || ""
}
onChange={(e) => {
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
const currentConfig = advancedConfigs[column.columnName] || {
columnName: column.columnName,
};
onUpdateComponent({
addModalConfig: {
...component.addModalConfig,
advancedFieldConfigs: {
...advancedConfigs,
[column.columnName]: {
...currentConfig,
customValue: e.target.value,
},
},
},
});
}}
placeholder="고정값 입력..."
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
</Card>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="filters" className="mt-4 max-h-[70vh] overflow-y-auto">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
<Filter className="h-4 w-4" />
<span> </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"> </h3>
<div className="flex items-center space-x-2">
<Badge variant="secondary">{component.filters.length}</Badge>
<Button
type="button"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
addFilter();
}}
disabled={!selectedTable}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{component.filters.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
<Filter className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-xs" style={{ fontSize: "12px" }}> </p>
<p className="text-xs"> </p>
</div>
) : (
<div className="max-h-96 space-y-3 overflow-y-auto">
{component.filters.map((filter, index) => {
const getWebTypeIcon = (webType: WebType) => {
switch (webType) {
case "text":
case "email":
case "tel":
return "📝";
case "number":
case "decimal":
return "🔢";
case "date":
case "datetime":
return "📅";
case "select":
return "📋";
default:
return "🔍";
}
};
const getWebTypeDescription = (webType: WebType) => {
switch (webType) {
case "text":
return "텍스트 검색 (부분 일치)";
case "email":
return "이메일 형식 검색";
case "tel":
return "전화번호 검색";
case "number":
return "숫자 범위 검색";
case "decimal":
return "소수점 범위 검색";
case "date":
return "날짜 범위 검색";
case "datetime":
return "날짜시간 범위 검색";
case "select":
return "선택 옵션 필터";
default:
return "기본 검색";
}
};
return (
<Card key={`filter-${filter.columnName}-${filter.widgetType}-${index}`} className="p-2">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<span className="text-lg">{getWebTypeIcon(filter.widgetType)}</span>
<div className="flex-1">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={(() => {
const filterKey = `${filter.columnName}-${index}`;
const localValue = localFilterInputs[filterKey];
const finalValue = localValue !== undefined ? localValue : filter.label;
// console.log("🎯 필터 입력 값 결정:", {
// filterKey,
// localValue,
// filterLabel: filter.label,
// finalValue,
// allLocalInputs: Object.keys(localFilterInputs),
// });
return finalValue;
})()}
onChange={(e) => {
const newValue = e.target.value;
const filterKey = `${filter.columnName}-${index}`;
setLocalFilterInputs((prev) => ({ ...prev, [filterKey]: newValue }));
updateFilter(index, { label: newValue });
}}
placeholder="필터 이름 입력..."
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
/>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{getWebTypeDescription(filter.widgetType)}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeFilter(index);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = selectedTable?.columns.find((col) => col.columnName === value);
if (column) {
const newWidgetType = getWidgetTypeFromColumn(column);
updateFilter(index, {
columnName: value,
label: column.columnLabel || column.columnName,
widgetType: newWidgetType,
});
}
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectedTable?.columns
.filter((col) => isFilterableWebType(getWidgetTypeFromColumn(col)))
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{getWebTypeIcon(getWidgetTypeFromColumn(col))}{" "}
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="bg-muted flex h-8 items-center rounded-md px-2 text-xs">
<Badge variant="outline" className="text-xs">
{webTypeOptions.find((opt) => opt.value === filter.widgetType)?.label ||
filter.widgetType}
</Badge>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={filter.gridColumns.toString()}
onValueChange={(value) => updateFilter(index, { gridColumns: parseInt(value) })}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1/12 (8.33%)</SelectItem>
<SelectItem value="2">2/12 (16.67%)</SelectItem>
<SelectItem value="3">3/12 (25%)</SelectItem>
<SelectItem value="4">4/12 (33%)</SelectItem>
<SelectItem value="5">5/12 (41.67%)</SelectItem>
<SelectItem value="6">6/12 (50%)</SelectItem>
<SelectItem value="7">7/12 (58.33%)</SelectItem>
<SelectItem value="8">8/12 (66.67%)</SelectItem>
<SelectItem value="9">9/12 (75%)</SelectItem>
<SelectItem value="10">10/12 (83.33%)</SelectItem>
<SelectItem value="11">11/12 (91.67%)</SelectItem>
<SelectItem value="12">12/12 (100%)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 웹 타입별 추가 설정 미리보기 */}
<div className="border-t pt-2">
<div className="text-muted-foreground text-xs">
{filter.widgetType === "date" || filter.widgetType === "datetime" ? (
<span>📅 ( ~ )</span>
) : filter.widgetType === "number" || filter.widgetType === "decimal" ? (
<span>🔢 ( ~ )</span>
) : filter.widgetType === "select" ? (
<span>📋 </span>
) : (
<span>🔍 </span>
)}
</div>
</div>
</div>
</Card>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="modal" className="mt-4 max-h-[70vh] overflow-y-auto">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
<Settings className="h-4 w-4" />
<span> </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 페이지네이션 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium"> </h4>
<div className="flex items-center space-x-2">
<Checkbox
id="pagination-enabled"
checked={localValues.paginationEnabled}
onCheckedChange={(checked) => {
// console.log("🔄 페이지네이션 사용 변경:", checked);
setLocalValues((prev) => ({ ...prev, paginationEnabled: checked as boolean }));
onUpdateComponent({
pagination: { ...component.pagination, enabled: checked as boolean },
});
}}
/>
<Label htmlFor="pagination-enabled"> </Label>
</div>
{component.pagination.enabled && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<select
className="focus:border-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={component.pagination.pageSize.toString()}
onChange={(e) =>
onUpdateComponent({
pagination: {
...component.pagination,
pageSize: parseInt(e.target.value),
},
})
}
>
{[5, 10, 20, 50, 100].map((size) => (
<option key={size} value={size.toString()}>
{size}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="show-page-size-selector"
checked={localValues.showPageSizeSelector}
onCheckedChange={(checked) => {
// console.log("🔄 페이지 크기 선택기 표시 변경:", checked);
setLocalValues((prev) => ({ ...prev, showPageSizeSelector: checked as boolean }));
onUpdateComponent({
pagination: {
...component.pagination,
showPageSizeSelector: checked as boolean,
},
});
}}
/>
<Label htmlFor="show-page-size-selector" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="show-page-info"
checked={localValues.showPageInfo}
onCheckedChange={(checked) => {
// console.log("🔄 페이지 정보 표시 변경:", checked);
setLocalValues((prev) => ({ ...prev, showPageInfo: checked as boolean }));
onUpdateComponent({
pagination: {
...component.pagination,
showPageInfo: checked as boolean,
},
});
}}
/>
<Label htmlFor="show-page-info" className="text-xs" style={{ fontSize: "12px" }}>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="show-first-last"
checked={localValues.showFirstLast}
onCheckedChange={(checked) => {
// console.log("🔄 처음/마지막 버튼 표시 변경:", checked);
setLocalValues((prev) => ({ ...prev, showFirstLast: checked as boolean }));
onUpdateComponent({
pagination: {
...component.pagination,
showFirstLast: checked as boolean,
},
});
}}
/>
<Label htmlFor="show-first-last" className="text-xs" style={{ fontSize: "12px" }}>
/
</Label>
</div>
</div>
</div>
)}
</div>
{/* 모달 설정은 여기에 추가 가능 */}
<div className="space-y-4">
<h4 className="text-sm font-medium"> </h4>
<p className="text-xs text-gray-500">/ .</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
// React.memo로 감싸서 불필요한 리렌더링 방지
export const DataTableConfigPanel = React.memo(DataTableConfigPanelComponent, (prevProps, nextProps) => {
// 컴포넌트 ID가 다르면 리렌더링
if (prevProps.component.id !== nextProps.component.id) {
return false;
}
// 테이블 목록이 변경되면 리렌더링
if (prevProps.tables.length !== nextProps.tables.length) {
return false;
}
// 활성 탭이 변경되면 리렌더링
if (prevProps.activeTab !== nextProps.activeTab) {
return false;
}
// 컬럼 개수나 필터 개수가 변경되면 리렌더링
if (
prevProps.component.columns?.length !== nextProps.component.columns?.length ||
prevProps.component.filters?.length !== nextProps.component.filters?.length
) {
return false;
}
// 기본 속성들이 변경되면 리렌더링
if (
prevProps.component.title !== nextProps.component.title ||
prevProps.component.tableName !== nextProps.component.tableName ||
prevProps.component.searchButtonText !== nextProps.component.searchButtonText
) {
return false;
}
// 그 외의 경우는 리렌더링하지 않음
return true;
});
export default DataTableConfigPanel;