ERP-node/frontend/components/screen/InteractiveDataTable.tsx

3243 lines
126 KiB
TypeScript
Raw Permalink Normal View History

2025-09-03 15:23:12 +09:00
"use client";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useSearchParams } from "next/navigation";
2025-09-03 15:23:12 +09:00
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
2025-09-03 16:38:10 +09:00
import { Checkbox } from "@/components/ui/checkbox";
2025-09-03 15:23:12 +09:00
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
2025-09-03 16:38:10 +09:00
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
2025-09-09 17:42:23 +09:00
// Card 컴포넌트 제거 - 외부 박스 없이 직접 렌더링
2025-09-03 15:23:12 +09:00
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
2025-09-05 12:04:13 +09:00
import {
Search,
ChevronLeft,
ChevronRight,
RotateCcw,
Database,
Loader2,
Plus,
Edit,
Trash2,
File,
Download,
Eye,
X,
2025-09-05 14:52:10 +09:00
ZoomIn,
ZoomOut,
RotateCw,
Folder,
FolderOpen,
2025-09-29 17:21:47 +09:00
Grid,
Filter,
2025-09-05 12:04:13 +09:00
} from "lucide-react";
2025-09-03 15:23:12 +09:00
import { tableTypeApi } from "@/lib/api/screen";
2025-09-15 15:38:48 +09:00
import { commonCodeApi } from "@/lib/api/commonCode";
2026-01-12 16:08:02 +09:00
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
2025-09-23 14:26:18 +09:00
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
2025-09-03 15:23:12 +09:00
import { cn } from "@/lib/utils";
2025-09-08 13:10:09 +09:00
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
2025-09-05 12:04:13 +09:00
import { toast } from "sonner";
import { FileUpload } from "@/components/screen/widgets/FileUpload";
2025-09-23 14:26:18 +09:00
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
import { SaveModal } from "./SaveModal";
2025-10-28 15:39:22 +09:00
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
2025-12-10 13:53:44 +09:00
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
/**
* 🔗 ( )
*/
interface CascadingDropdownInFormProps {
config: CascadingDropdownConfig;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
}
const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
config,
parentValue,
value,
onChange,
placeholder,
className,
}) => {
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
const getPlaceholder = () => {
if (!parentValue) {
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return config.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = !parentValue || loading;
return (
2026-01-12 16:08:02 +09:00
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
2025-12-10 13:53:44 +09:00
<SelectTrigger className={className}>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
2025-09-05 12:04:13 +09:00
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
2025-09-05 12:04:13 +09:00
interface FileInfo {
// AttachedFileInfo 기본 속성들
objid: string;
savedFileName: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
docType: string;
docTypeName: string;
targetObjid: string;
parentTargetObjid?: string;
companyCode: string;
writer: string;
regdate: string;
status: string;
// 추가 호환성 속성들
path?: string; // filePath와 동일
name?: string; // realFileName과 동일
id?: string; // objid와 동일
size?: number; // fileSize와 동일
type?: string; // docType과 동일
uploadedAt?: string; // regdate와 동일
2025-09-05 12:04:13 +09:00
}
interface FileColumnData {
files: FileInfo[];
totalCount: number;
totalSize: number;
lastModified: string;
}
2025-09-03 15:23:12 +09:00
interface InteractiveDataTableProps {
component: DataTableComponent;
className?: string;
style?: React.CSSProperties;
onRefresh?: () => void; // 테이블 새로고침 콜백
2025-09-03 15:23:12 +09:00
}
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
component,
className = "",
style = {},
onRefresh,
2025-09-03 15:23:12 +09:00
}) => {
2025-10-28 15:39:22 +09:00
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
2026-01-12 16:08:02 +09:00
// URL에서 menuObjid 가져오기 (카테고리 값 조회 시 필요)
const searchParams = useSearchParams();
const menuObjid = useMemo(() => {
// 1. ScreenContext에서 가져오기
if (screenContext?.menuObjid) return screenContext.menuObjid;
// 2. URL 쿼리에서 가져오기
const urlMenuObjid = searchParams.get("menuObjid");
return urlMenuObjid ? parseInt(urlMenuObjid) : undefined;
}, [screenContext?.menuObjid, searchParams]);
2026-01-12 16:08:02 +09:00
2025-09-03 15:23:12 +09:00
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
2025-09-03 16:38:10 +09:00
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const hasInitializedWidthsRef = useRef(false);
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
const isResizingRef = useRef(false);
2026-01-12 16:08:02 +09:00
// TableOptions 상태
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
// SaveModal 상태 (등록/수정 통합)
const [showSaveModal, setShowSaveModal] = useState(false);
const [saveModalData, setSaveModalData] = useState<Record<string, any> | undefined>(undefined);
const [saveModalScreenId, setSaveModalScreenId] = useState<number | undefined>(undefined);
2025-09-05 14:52:10 +09:00
// 이미지 미리보기 상태
const [previewImage, setPreviewImage] = useState<FileInfo | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
2025-09-08 13:10:09 +09:00
const [imageLoadError, setImageLoadError] = useState(false);
const [alternativeImageUrl, setAlternativeImageUrl] = useState<string | null>(null);
2025-09-05 14:52:10 +09:00
// 파일 관리 상태
const [fileStatusMap, setFileStatusMap] = useState<Record<string, { hasFiles: boolean; fileCount: number }>>({}); // 행별 파일 상태
const [showFileManagementModal, setShowFileManagementModal] = useState(false);
const [selectedRowForFiles, setSelectedRowForFiles] = useState<Record<string, any> | null>(null);
const [selectedColumnForFiles, setSelectedColumnForFiles] = useState<DataTableColumn | null>(null); // 선택된 컬럼 정보
const [linkedFiles, setLinkedFiles] = useState<any[]>([]);
2025-09-15 15:38:48 +09:00
// 공통코드 관리 상태
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ value: string; label: string }>>>({});
// 🆕 검색 필터 관련 상태 (FlowWidget과 동일)
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set()); // 검색 필터로 사용할 컬럼
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
2026-01-12 16:08:02 +09:00
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({});
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
// 테이블 등록 (Context에 등록)
const tableId = `datatable-${component.id}`;
2026-01-12 16:08:02 +09:00
useEffect(() => {
if (!component.tableName || !component.columns) return;
2026-01-12 16:08:02 +09:00
registerTable({
tableId,
label: component.title || "데이터 테이블",
tableName: component.tableName,
columns: component.columns.map((col) => ({
columnName: col.field,
columnLabel: col.label,
inputType: col.inputType || "text",
visible: col.visible !== false,
width: col.width || 150,
sortable: col.sortable,
filterable: col.filterable !== false,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
});
return () => unregisterTable(tableId);
}, [component.id, component.tableName, component.columns, component.title]);
2025-09-15 15:38:48 +09:00
// 공통코드 옵션 가져오기
const loadCodeOptions = useCallback(
async (categoryCode: string) => {
if (codeOptions[categoryCode]) {
return codeOptions[categoryCode]; // 이미 로드된 경우 캐시된 데이터 사용
}
try {
const response = await commonCodeApi.options.getOptions(categoryCode);
if (response.success && response.data) {
const options = response.data.map((code) => ({
value: code.value,
label: code.label,
}));
setCodeOptions((prev) => ({
...prev,
[categoryCode]: options,
}));
return options;
}
} catch (error) {
// console.error(`공통코드 옵션 로드 실패: ${categoryCode}`, error);
2025-09-15 15:38:48 +09:00
}
return [];
},
[codeOptions],
);
// 🆕 전역 테이블 새로고침 이벤트 리스너
useEffect(() => {
const handleRefreshTable = () => {
if (component.tableName) {
loadData(currentPage, searchValues);
}
};
window.addEventListener("refreshTable", handleRefreshTable);
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
}, [currentPage, searchValues, loadData, component.tableName]);
2025-12-16 18:02:08 +09:00
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
filterColumn: string;
filterValue: any;
} | null>(null);
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
2026-01-12 16:08:02 +09:00
2025-12-16 18:02:08 +09:00
// 이 테이블이 대상 테이블인지 확인
if (targetTable === component.tableName) {
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
tableName: component.tableName,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
}
};
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
return () => {
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
};
}, [component.tableName]);
// relatedButtonFilter 변경 시 데이터 다시 로드
useEffect(() => {
if (relatedButtonFilter) {
loadData(1, searchValues);
}
}, [relatedButtonFilter]);
// 카테고리 타입 컬럼의 값 매핑 로드
useEffect(() => {
const loadCategoryMappings = async () => {
if (!component.tableName) return;
try {
// 카테고리 타입 컬럼 찾기
const categoryColumns = component.columns?.filter((col) => {
const webType = getColumnWebType(col.columnName);
return webType === "category";
});
if (!categoryColumns || categoryColumns.length === 0) return;
// 각 카테고리 컬럼의 값 목록 조회
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const col of categoryColumns) {
try {
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
const response = await apiClient.get(
2026-01-12 16:08:02 +09:00
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
);
if (response.data.success && response.data.data) {
// valueCode -> {label, color} 매핑 생성
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
mapping[item.valueCode] = {
label: item.valueLabel,
color: item.color,
};
});
mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
}
} catch (error) {
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
}
}
console.log("📊 전체 카테고리 매핑:", mappings);
setCategoryMappings(mappings);
} catch (error) {
console.error("카테고리 매핑 로드 실패:", error);
}
};
loadCategoryMappings();
}, [component.tableName, component.columns, getColumnWebType, menuObjid]);
// 파일 상태 확인 함수
const checkFileStatus = useCallback(
async (rowData: Record<string, any>) => {
if (!component.tableName) return;
// 첫 번째 컬럼을 기본키로 사용 (실제로는 더 정교한 로직 필요)
const primaryKeyField = Object.keys(rowData)[0]; // 임시로 첫 번째 컬럼 사용
const recordId = rowData[primaryKeyField];
if (!recordId) return;
try {
const response = await getLinkedFiles(component.tableName, recordId);
const hasFiles = response.files && response.files.length > 0;
const fileCount = response.files ? response.files.length : 0;
return { hasFiles, fileCount, files: response.files || [] };
} catch (error) {
// console.error("파일 상태 확인 오류:", error);
return { hasFiles: false, fileCount: 0, files: [] };
}
},
[component.tableName],
);
// 파일 폴더 아이콘 클릭 핸들러 (전체 행 파일 관리)
const handleFileIconClick = useCallback(
async (rowData: Record<string, any>) => {
const fileStatus = await checkFileStatus(rowData);
if (fileStatus) {
setSelectedRowForFiles(rowData);
setLinkedFiles(fileStatus.files);
setShowFileManagementModal(true);
}
},
[checkFileStatus],
);
// 컬럼별 파일 상태 확인
const checkColumnFileStatus = useCallback(
async (rowData: Record<string, any>, column: DataTableColumn) => {
if (!component.tableName) return null;
const primaryKeyField = Object.keys(rowData)[0];
const recordId = rowData[primaryKeyField];
if (!recordId) return null;
try {
// 가상 파일 컬럼의 경우: tableName:recordId:columnName 형태로 target_objid 생성
const targetObjid = column.isVirtualFileColumn
? `${component.tableName}:${recordId}:${column.columnName}`
: `${component.tableName}:${recordId}`;
const response = await getLinkedFiles(component.tableName, recordId);
// 가상 파일 컬럼의 경우 해당 컬럼의 파일만 필터링
let files = response.files || [];
if (column.isVirtualFileColumn) {
// 현재 컬럼명으로 먼저 시도
files = files.filter(
(file: any) => file.targetObjid === targetObjid || file.targetObjid?.endsWith(`:${column.columnName}`), // target_objid → targetObjid
);
// 파일이 없는 경우 fallback: 모든 파일 컬럼 패턴 시도
if (files.length === 0) {
// 해당 테이블:레코드의 모든 파일 컬럼 파일들을 가져옴
files = (response.files || []).filter(
(file: any) => file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`), // target_objid → targetObjid
);
}
}
const hasFiles = files.length > 0;
const fileCount = files.length;
return { hasFiles, fileCount, files, targetObjid };
} catch (error) {
// console.error("컬럼별 파일 상태 확인 오류:", error);
return { hasFiles: false, fileCount: 0, files: [], targetObjid: null };
}
},
[component.tableName],
);
// 컬럼별 파일 클릭 핸들러
const handleColumnFileClick = useCallback(
async (rowData: Record<string, any>, column: DataTableColumn) => {
// 컬럼별 파일 상태 확인
const fileStatus = await checkColumnFileStatus(rowData, column);
setSelectedRowForFiles(rowData);
setSelectedColumnForFiles(column); // 선택된 컬럼 정보 저장
setLinkedFiles(fileStatus?.files || []);
setShowFileManagementModal(true);
// TODO: 모달에 컬럼 정보 전달하여 해당 컬럼 전용 파일 업로드 가능하게 하기
},
[checkColumnFileStatus],
);
2025-09-05 14:52:10 +09:00
// 이미지 미리보기 핸들러들
const handlePreviewImage = useCallback((fileInfo: FileInfo) => {
setPreviewImage(fileInfo);
setShowPreviewModal(true);
setZoom(1);
setRotation(0);
2025-09-08 13:10:09 +09:00
setImageLoadError(false);
setAlternativeImageUrl(null);
2025-09-05 14:52:10 +09:00
}, []);
const closePreviewModal = useCallback(() => {
setShowPreviewModal(false);
setPreviewImage(null);
setZoom(1);
setRotation(0);
2025-09-08 13:10:09 +09:00
setImageLoadError(false);
setAlternativeImageUrl(null);
2025-09-05 14:52:10 +09:00
}, []);
const handleZoom = useCallback((direction: "in" | "out") => {
setZoom((prev) => {
if (direction === "in") {
return Math.min(prev + 0.25, 3);
} else {
return Math.max(prev - 0.25, 0.25);
}
});
}, []);
const handleRotate = useCallback(() => {
setRotation((prev) => (prev + 90) % 360);
}, []);
const formatFileSize = useCallback((bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}, []);
2025-09-08 13:10:09 +09:00
// 이미지 로딩 실패 시 대체 URL 시도
const handleImageError = useCallback(() => {
if (!imageLoadError && previewImage) {
// console.error("이미지 로딩 실패:", previewImage);
2025-09-08 13:10:09 +09:00
setImageLoadError(true);
// 대체 URL 생성 (직접 파일 경로 사용)
if (previewImage.path) {
const altUrl = getDirectFileUrl(previewImage.path);
setAlternativeImageUrl(altUrl);
} else {
toast.error("이미지를 불러올 수 없습니다.");
}
} else {
toast.error("이미지를 불러올 수 없습니다.");
}
}, [imageLoadError, previewImage]);
2025-09-05 12:04:13 +09:00
const [showFileModal, setShowFileModal] = useState(false);
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
2025-09-03 16:38:10 +09:00
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 현재 사용자 정보
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
2025-09-03 15:23:12 +09:00
2025-09-03 17:12:27 +09:00
// 테이블 컬럼 타입 정보 (웹 타입 포함)
const [tableColumns, setTableColumns] = useState<any[]>([]);
// 파일 업로드 관련 상태
const [uploadingFiles, setUploadingFiles] = useState<Record<string, boolean>>({});
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File[]>>({});
2025-09-03 15:23:12 +09:00
// 검색 가능한 컬럼만 필터링
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
2025-11-05 18:08:51 +09:00
// 컬럼의 실제 웹 타입 정보 찾기 (webType 또는 input_type)
2025-09-03 17:12:27 +09:00
const getColumnWebType = useCallback(
(columnName: string) => {
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
if (componentColumn?.widgetType && componentColumn.widgetType !== "text") {
return componentColumn.widgetType;
}
// 없으면 테이블 타입 관리에서 설정된 값 찾기
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
2026-01-12 16:08:02 +09:00
2025-11-05 18:08:51 +09:00
// input_type 우선 사용 (category 등)
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
if (inputType) {
return inputType;
}
2026-01-12 16:08:02 +09:00
2025-11-05 18:08:51 +09:00
// 없으면 webType 사용
return tableColumn?.webType || "text";
2025-09-03 17:12:27 +09:00
},
[component.columns, tableColumns],
);
// 컬럼의 상세 설정 정보 찾기
const getColumnDetailSettings = useCallback(
(columnName: string) => {
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
if (componentColumn?.webTypeConfig) {
return componentColumn.webTypeConfig;
}
// 없으면 테이블 타입 관리에서 설정된 값 찾기
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
try {
return tableColumn?.detailSettings ? JSON.parse(tableColumn.detailSettings) : {};
} catch {
return {};
}
},
[component.columns, tableColumns],
);
2025-09-15 15:38:48 +09:00
// 컬럼의 코드 카테고리 가져오기
const getColumnCodeCategory = useCallback(
(columnName: string) => {
const column = component.columns.find((col) => col.columnName === columnName);
// webTypeConfig가 CodeTypeConfig인 경우 codeCategory 반환
const webTypeConfig = column?.webTypeConfig as any;
return webTypeConfig?.codeCategory || column?.codeCategory;
},
[component.columns],
);
2025-09-03 15:23:12 +09:00
// 그리드 컬럼 계산
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
// 페이지 크기 설정
const pageSize = component.pagination?.pageSize || 10;
// 초기 컬럼 너비 측정 (한 번만)
useEffect(() => {
if (!hasInitializedWidthsRef.current && visibleColumns.length > 0) {
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
const timer = setTimeout(() => {
const newWidths: Record<string, number> = {};
let hasAnyWidth = false;
visibleColumns.forEach((column) => {
const thElement = columnRefs.current[column.id];
if (thElement) {
const measuredWidth = thElement.offsetWidth;
if (measuredWidth > 0) {
newWidths[column.id] = measuredWidth;
hasAnyWidth = true;
}
}
});
if (hasAnyWidth) {
setColumnWidths(newWidths);
hasInitializedWidthsRef.current = true;
}
}, 100);
return () => clearTimeout(timer);
}
}, [visibleColumns]);
2025-09-03 15:23:12 +09:00
// 데이터 로드 함수
const loadData = useCallback(
async (page: number = 1, searchParams: Record<string, any> = {}) => {
if (!component.tableName) return;
2025-10-28 15:39:22 +09:00
// 프리뷰 모드에서는 샘플 데이터만 표시
if (isPreviewMode) {
const sampleData = Array.from({ length: 3 }, (_, i) => {
const sample: Record<string, any> = { id: i + 1 };
component.columns.forEach((col) => {
if (col.type === "number") {
sample[col.key] = Math.floor(Math.random() * 1000);
} else if (col.type === "boolean") {
sample[col.key] = i % 2 === 0 ? "Y" : "N";
} else {
sample[col.key] = `샘플 ${col.label} ${i + 1}`;
}
});
return sample;
});
setData(sampleData);
setTotal(3);
setTotalPages(1);
setCurrentPage(1);
setLoading(false);
return;
}
2025-09-03 15:23:12 +09:00
setLoading(true);
try {
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
2026-01-12 16:08:02 +09:00
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
2026-01-12 16:08:02 +09:00
(filter) =>
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
);
2026-01-12 16:08:02 +09:00
// 좌측 데이터 선택 여부 확인
2026-01-12 16:08:02 +09:00
hasSelectedLeftData =
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
const tableSpecificFilters: Record<string, any> = {};
for (const [key, value] of Object.entries(linkedFilterValues)) {
// key가 "테이블명.컬럼명" 형식인 경우
if (key.includes(".")) {
const [tableName, columnName] = key.split(".");
if (tableName === component.tableName) {
tableSpecificFilters[columnName] = value;
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
}
} else {
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
tableSpecificFilters[key] = value;
}
}
linkedFilterValues = tableSpecificFilters;
}
2026-01-12 16:08:02 +09:00
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
console.log("⚠️ [InteractiveDataTable] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
setData([]);
setTotal(0);
setTotalPages(0);
setCurrentPage(1);
setLoading(false);
return;
}
2026-01-12 16:08:02 +09:00
2025-12-16 18:02:08 +09:00
// 🆕 RelatedDataButtons 필터 적용
2026-01-12 16:08:02 +09:00
const relatedButtonFilterValues: Record<string, any> = {};
2025-12-16 18:02:08 +09:00
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
}
// 검색 파라미터와 연결 필터 병합
const mergedSearchParams = {
...searchParams,
...linkedFilterValues,
2025-12-16 18:02:08 +09:00
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
};
2026-01-12 16:08:02 +09:00
console.log("🔍 데이터 조회 시작:", {
tableName: component.tableName,
page,
pageSize,
linkedFilterValues,
2025-12-16 18:02:08 +09:00
relatedButtonFilterValues,
mergedSearchParams,
});
2026-01-12 16:08:02 +09:00
2025-09-03 15:23:12 +09:00
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: pageSize,
search: mergedSearchParams,
2025-11-04 14:33:39 +09:00
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
2025-09-03 15:23:12 +09:00
});
2026-01-12 16:08:02 +09:00
console.log("✅ 데이터 조회 완료:", {
tableName: component.tableName,
2026-01-12 16:08:02 +09:00
dataLength: result.data.length,
total: result.total,
2026-01-12 16:08:02 +09:00
page: result.page,
});
2025-09-03 15:23:12 +09:00
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
setCurrentPage(result.page);
2026-01-12 16:08:02 +09:00
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
const detectAndLoadCategoryLabels = async () => {
const categoryCodes = new Set<string>();
result.data.forEach((row: Record<string, any>) => {
Object.values(row).forEach((value) => {
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
categoryCodes.add(value);
}
});
});
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
const newCodes = Array.from(categoryCodes);
if (newCodes.length > 0) {
try {
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
if (response.data.success && response.data.data) {
setCategoryCodeLabels((prev) => {
const newLabels = {
...prev,
...response.data.data,
};
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
return newLabels;
});
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}
};
detectAndLoadCategoryLabels();
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
const primaryKeyField = Object.keys(rowData)[0];
const recordId = rowData[primaryKeyField];
if (!recordId) return { rowKey: recordId, statuses: {} };
try {
const fileResponse = await getLinkedFiles(component.tableName, recordId);
const allFiles = fileResponse.files || [];
// 전체 행에 대한 파일 상태
const rowStatus = {
hasFiles: allFiles.length > 0,
fileCount: allFiles.length,
};
// 가상 파일 컬럼별 파일 상태
const columnStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {};
// 가상 파일 컬럼 찾기
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
virtualFileColumns.forEach((column) => {
// 해당 컬럼의 파일만 필터링 (targetObjid로 수정)
let columnFiles = allFiles.filter((file: any) => file.targetObjid?.endsWith(`:${column.columnName}`));
// fallback: 컬럼명으로 찾지 못한 경우 모든 파일 컬럼 파일 포함
if (columnFiles.length === 0) {
columnFiles = allFiles.filter((file: any) =>
file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`),
);
}
const columnKey = `${recordId}_${column.columnName}`;
columnStatuses[columnKey] = {
hasFiles: columnFiles.length > 0,
fileCount: columnFiles.length,
};
});
return {
rowKey: recordId,
statuses: {
[recordId]: rowStatus, // 전체 행 상태
...columnStatuses, // 컬럼별 상태
},
};
} catch {
// 에러 시 기본값
const defaultStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {
[recordId]: { hasFiles: false, fileCount: 0 },
};
// 가상 파일 컬럼에 대해서도 기본값 설정
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
virtualFileColumns.forEach((column) => {
const columnKey = `${recordId}_${column.columnName}`;
defaultStatuses[columnKey] = { hasFiles: false, fileCount: 0 };
});
return { rowKey: recordId, statuses: defaultStatuses };
}
});
// 파일 상태 업데이트
Promise.all(fileStatusPromises).then((results) => {
const statusMap: Record<string, { hasFiles: boolean; fileCount: number }> = {};
results.forEach((result) => {
Object.assign(statusMap, result.statuses);
});
setFileStatusMap(statusMap);
});
2025-09-03 15:23:12 +09:00
} catch (error) {
// console.error("❌ 테이블 데이터 조회 실패:", error);
2025-09-03 15:23:12 +09:00
setData([]);
setTotal(0);
setTotalPages(1);
} finally {
setLoading(false);
}
},
2025-12-16 18:02:08 +09:00
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가
2025-09-03 15:23:12 +09:00
);
2025-09-03 16:38:10 +09:00
// 현재 사용자 정보 로드
useEffect(() => {
const fetchCurrentUser = async () => {
try {
const response = await getCurrentUser();
if (response.success && response.data) {
setCurrentUser(response.data);
}
} catch (error) {
// console.error("현재 사용자 정보 로드 실패:", error);
2025-09-03 16:38:10 +09:00
}
};
fetchCurrentUser();
}, []);
2025-09-26 17:12:03 +09:00
// 파일 상태 새로고침 이벤트 리스너
useEffect(() => {
const handleRefreshFileStatus = async (event: CustomEvent) => {
const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail;
2025-09-26 17:12:03 +09:00
// 현재 테이블과 일치하는지 확인
if (tableName === component.tableName) {
// 해당 행의 파일 상태 업데이트
const columnKey = `${recordId}_${columnName}`;
setFileStatusMap((prev) => ({
2025-09-26 17:12:03 +09:00
...prev,
[recordId]: { hasFiles: fileCount > 0, fileCount },
[columnKey]: { hasFiles: fileCount > 0, fileCount },
2025-09-26 17:12:03 +09:00
}));
}
};
if (typeof window !== "undefined") {
window.addEventListener("refreshFileStatus", handleRefreshFileStatus as EventListener);
2025-09-26 17:12:03 +09:00
return () => {
window.removeEventListener("refreshFileStatus", handleRefreshFileStatus as EventListener);
2025-09-26 17:12:03 +09:00
};
}
}, [component.tableName]);
2025-09-03 17:12:27 +09:00
// 테이블 컬럼 정보 로드 (웹 타입 정보 포함)
useEffect(() => {
const fetchTableColumns = async () => {
try {
const columns = await tableTypeApi.getColumns(component.tableName);
setTableColumns(columns);
2026-01-12 16:08:02 +09:00
// 🆕 전체 컬럼 목록 설정
2026-01-12 16:08:02 +09:00
const columnNames = columns.map((col) => col.columnName);
setAllAvailableColumns(columnNames);
2026-01-12 16:08:02 +09:00
// 🆕 컬럼명 -> 라벨 매핑 생성
const labels: Record<string, string> = {};
2026-01-12 16:08:02 +09:00
columns.forEach((col) => {
labels[col.columnName] = col.displayName || col.columnName;
});
setColumnLabels(labels);
2026-01-12 16:08:02 +09:00
// 🆕 localStorage에서 필터 설정 복원
if (user?.userId && component.componentId) {
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
const savedFilter = localStorage.getItem(storageKey);
if (savedFilter) {
try {
const parsed = JSON.parse(savedFilter);
setSearchFilterColumns(new Set(parsed));
} catch (e) {
console.error("필터 설정 복원 실패:", e);
}
}
}
2025-09-03 17:12:27 +09:00
} catch (error) {
// console.error("테이블 컬럼 정보 로드 실패:", error);
2025-09-03 17:12:27 +09:00
}
};
if (component.tableName) {
fetchTableColumns();
}
}, [component.tableName, component.componentId, user?.userId]);
2025-09-03 17:12:27 +09:00
2025-09-23 14:26:18 +09:00
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
const searchFilters = useMemo(() => {
return component.filters || [];
}, [component.filters]);
2025-09-03 15:23:12 +09:00
// 초기 데이터 로드
useEffect(() => {
loadData(1, searchValues);
}, [loadData]);
// 검색 실행
const handleSearch = useCallback(() => {
loadData(1, searchValues);
}, [searchValues, loadData]);
// 검색값 변경
const handleSearchValueChange = useCallback((columnName: string, value: any) => {
setSearchValues((prev) => ({
...prev,
[columnName]: value,
}));
}, []);
// 페이지 변경
const handlePageChange = useCallback(
(page: number) => {
loadData(page, searchValues);
},
[loadData, searchValues],
);
2025-09-03 16:38:10 +09:00
// 행 선택 핸들러
2026-01-12 16:08:02 +09:00
const handleRowSelect = useCallback(
(rowIndex: number, isSelected: boolean) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (isSelected) {
newSet.add(rowIndex);
} else {
newSet.delete(rowIndex);
}
return newSet;
});
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
2026-01-12 16:08:02 +09:00
},
[data, splitPanelContext, splitPanelPosition],
);
2025-09-03 16:38:10 +09:00
// 전체 선택/해제 핸들러
const handleSelectAll = useCallback(
(isSelected: boolean) => {
if (isSelected) {
setSelectedRows(new Set(Array.from({ length: data.length }, (_, i) => i)));
} else {
setSelectedRows(new Set());
}
},
[data.length],
);
// 모달에 표시할 컬럼 계산
const getDisplayColumns = useCallback(() => {
const { hiddenFields, fieldOrder, advancedFieldConfigs } = component.addModalConfig || {};
// 숨겨진 필드와 고급 설정에서 숨겨진 필드 제외
let displayColumns = visibleColumns.filter((col) => {
// 기본 숨김 필드 체크
if (hiddenFields?.includes(col.columnName)) return false;
// 고급 설정에서 숨김 체크
const config = advancedFieldConfigs?.[col.columnName];
if (config?.inputType === "hidden") return false;
return true;
});
// 필드 순서 적용
if (fieldOrder && fieldOrder.length > 0) {
const orderedColumns: typeof displayColumns = [];
const remainingColumns = [...displayColumns];
// 지정된 순서대로 추가
fieldOrder.forEach((columnName) => {
const column = remainingColumns.find((col) => col.columnName === columnName);
if (column) {
orderedColumns.push(column);
const index = remainingColumns.indexOf(column);
remainingColumns.splice(index, 1);
}
});
// 나머지 컬럼들 추가
orderedColumns.push(...remainingColumns);
displayColumns = orderedColumns;
}
return displayColumns;
}, [visibleColumns, component.addModalConfig]);
// 자동 값 생성
const generateAutoValue = useCallback(
(autoValueType: string): string => {
const now = new Date();
switch (autoValueType) {
case "current_datetime":
return now.toISOString().slice(0, 19); // YYYY-MM-DDTHH:mm:ss
case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD
case "current_time":
return now.toTimeString().slice(0, 8); // HH:mm:ss
case "current_user":
return currentUser?.userName || currentUser?.userId || "unknown_user";
case "uuid":
return crypto.randomUUID();
case "sequence":
return `SEQ_${Date.now()}`;
default:
return "";
}
},
[currentUser],
);
// 데이터 추가 핸들러
const handleAddData = useCallback(() => {
// 폼 데이터 초기화
const initialData: Record<string, any> = {};
const displayColumns = getDisplayColumns();
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
displayColumns.forEach((col) => {
const config = advancedConfigs[col.columnName];
if (config?.inputType === "auto") {
// 자동 값 설정
if (config.autoValueType === "custom") {
initialData[col.columnName] = config.customValue || "";
} else {
initialData[col.columnName] = generateAutoValue(config.autoValueType);
}
} else if (config?.defaultValue) {
// 기본값 설정
initialData[col.columnName] = config.defaultValue;
} else {
// 일반 빈 값
initialData[col.columnName] = "";
}
});
// SaveModal 열기 (등록 모드)
const screenId = component.addModalConfig?.screenId;
2025-09-03 16:38:10 +09:00
if (!screenId) {
toast.error("화면 설정이 필요합니다. 테이블 설정에서 추가 모달 화면을 지정해주세요.");
return;
}
2025-09-03 16:38:10 +09:00
// SaveModal 사용
setSaveModalData(undefined); // undefined = 등록 모드
setSaveModalScreenId(screenId);
setShowSaveModal(true);
}, [getDisplayColumns, generateAutoValue, component.addModalConfig]);
2025-09-03 16:38:10 +09:00
// 데이터 수정 핸들러 (EditModal 사용)
2025-09-03 16:38:10 +09:00
const handleEditData = useCallback(() => {
if (selectedRows.size !== 1) return;
const selectedIndex = Array.from(selectedRows)[0];
const selectedRowData = data[selectedIndex];
if (!selectedRowData) return;
const screenId = component.addModalConfig?.screenId;
if (!screenId) {
toast.error("화면 설정이 필요합니다. 테이블 설정에서 수정 모달 화면을 지정해주세요.");
return;
}
2025-09-03 16:38:10 +09:00
// 수정할 데이터로 폼 초기화
const initialData: Record<string, any> = {};
const displayColumns = getDisplayColumns();
displayColumns.forEach((col) => {
initialData[col.columnName] = selectedRowData[col.columnName] || "";
});
// 수정 모달 설정에서 제목과 설명 가져오기
const editModalTitle = component.editModalConfig?.title || "데이터 수정";
const editModalDescription = component.editModalConfig?.description || "";
// 전역 EditModal 열기 이벤트 발생
const event = new CustomEvent("openEditModal", {
detail: {
screenId,
title: editModalTitle,
description: editModalDescription,
modalSize: "lg",
editData: initialData,
onSave: () => {
loadData(); // 테이블 데이터 새로고침
},
},
});
window.dispatchEvent(event);
}, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]);
2025-09-03 16:38:10 +09:00
// 수정 폼 데이터 변경 핸들러
const handleEditFormChange = useCallback((columnName: string, value: any) => {
setEditFormData((prev) => ({
...prev,
[columnName]: value,
}));
}, []);
2025-09-03 17:12:27 +09:00
// 파일 업로드 핸들러
const handleFileUpload = useCallback(
async (columnName: string, files: FileList | null, isEdit: boolean = false) => {
if (!files || files.length === 0) return;
const detailSettings = getColumnDetailSettings(columnName);
const maxSize = detailSettings?.maxSize || 10 * 1024 * 1024; // 기본 10MB
const acceptedTypes = detailSettings?.accept
? detailSettings.accept.split(",").map((type: string) => type.trim())
: [];
const multiple = detailSettings?.multiple || false;
// 파일 검증
const validFiles: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// 크기 체크
if (file.size > maxSize) {
alert(`파일 크기가 너무 큽니다. 최대 ${Math.round(maxSize / 1024 / 1024)}MB까지 가능합니다.`);
continue;
}
// 타입 체크
if (
acceptedTypes.length > 0 &&
!acceptedTypes.some((type: string) => {
if (type.startsWith(".")) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
} else {
return file.type.includes(type);
}
})
) {
alert(`지원하지 않는 파일 형식입니다. (${acceptedTypes.join(", ")})`);
continue;
}
validFiles.push(file);
if (!multiple) break; // 단일 파일만 허용
}
if (validFiles.length === 0) return;
try {
setUploadingFiles((prev) => ({ ...prev, [columnName]: true }));
// TODO: 실제 파일 업로드 API 호출
// const uploadPromises = validFiles.map(file => uploadFileToServer(file));
// const uploadResults = await Promise.all(uploadPromises);
// 임시: 파일 정보를 로컬 상태에 저장
setUploadedFiles((prev) => ({
...prev,
[columnName]: multiple ? [...(prev[columnName] || []), ...validFiles] : validFiles,
}));
// 폼 데이터 업데이트
const fileNames = validFiles.map((file) => file.name).join(", ");
if (isEdit) {
handleEditFormChange(columnName, fileNames);
} else {
handleAddFormChange(columnName, fileNames);
}
} catch (error) {
// console.error("파일 업로드 실패:", error);
2025-09-03 17:12:27 +09:00
alert("파일 업로드에 실패했습니다.");
} finally {
setUploadingFiles((prev) => ({ ...prev, [columnName]: false }));
}
},
[getColumnDetailSettings, handleAddFormChange, handleEditFormChange],
);
// 파일 제거 핸들러
const handleFileRemove = useCallback(
(columnName: string, fileIndex: number, isEdit: boolean = false) => {
setUploadedFiles((prev) => {
const currentFiles = prev[columnName] || [];
const newFiles = currentFiles.filter((_, index) => index !== fileIndex);
// 폼 데이터 업데이트
const fileNames = newFiles.map((file) => file.name).join(", ");
if (isEdit) {
handleEditFormChange(columnName, fileNames);
} else {
handleAddFormChange(columnName, fileNames);
}
return { ...prev, [columnName]: newFiles };
});
},
[handleAddFormChange, handleEditFormChange],
);
// 파일 목록 렌더링 컴포넌트
const renderFileList = useCallback(
(columnName: string, isEdit: boolean = false) => {
const currentFiles = uploadedFiles[columnName] || [];
const isUploading = uploadingFiles[columnName];
if (currentFiles.length === 0 && !isUploading) return null;
return (
<div className="mt-2 space-y-2">
{currentFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between rounded border bg-gray-50 p-2">
<div className="flex items-center space-x-2">
<div className="text-muted-foreground text-xs">📄</div>
2025-09-03 17:12:27 +09:00
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-500">{(file.size / 1024).toFixed(1)} KB</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleFileRemove(columnName, index, isEdit)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{isUploading && (
<div className="bg-accent flex items-center space-x-2 rounded border p-2">
2025-09-03 17:12:27 +09:00
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-primary text-sm"> ...</span>
2025-09-03 17:12:27 +09:00
</div>
)}
</div>
);
},
[uploadedFiles, uploadingFiles, handleFileRemove],
);
2025-09-03 16:38:10 +09:00
// 데이터 추가 제출 핸들러
const handleAddSubmit = useCallback(async () => {
try {
setIsAdding(true);
// 실제 API 호출로 데이터 추가
await tableTypeApi.addTableData(component.tableName, addFormData);
// 모달 닫기 및 폼 초기화
setShowAddModal(false);
setAddFormData({});
// 첫 페이지로 이동하여 새 데이터 확인
loadData(1, searchValues);
} catch (error) {
// console.error("데이터 추가 실패:", error);
2025-09-03 16:38:10 +09:00
alert("데이터 추가에 실패했습니다.");
} finally {
setIsAdding(false);
}
}, [addFormData, loadData, searchValues]);
// 데이터 수정 제출 핸들러
const handleEditSubmit = useCallback(async () => {
try {
setIsEditing(true);
// 실제 API 호출로 데이터 수정
if (editingRowData) {
await tableTypeApi.editTableData(component.tableName, editingRowData, editFormData);
// 모달 닫기 및 폼 초기화
setShowEditModal(false);
setEditFormData({});
setEditingRowData(null);
setSelectedRows(new Set()); // 선택 해제
// 현재 페이지 데이터 새로고침
loadData(currentPage, searchValues);
}
} catch (error) {
// console.error("데이터 수정 실패:", error);
2025-09-03 16:38:10 +09:00
alert("데이터 수정에 실패했습니다.");
} finally {
setIsEditing(false);
}
}, [editFormData, editingRowData, component.tableName, currentPage, searchValues, loadData]);
// 추가 모달 닫기 핸들러
const handleAddModalClose = useCallback(() => {
if (!isAdding) {
setShowAddModal(false);
setAddFormData({});
2025-09-03 17:12:27 +09:00
setUploadedFiles({}); // 파일 상태 초기화
2025-09-03 16:38:10 +09:00
}
}, [isAdding]);
// 🆕 검색 필터 저장 함수
const handleSaveSearchFilter = useCallback(() => {
if (user?.userId && component.componentId) {
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
const filterArray = Array.from(searchFilterColumns);
localStorage.setItem(storageKey, JSON.stringify(filterArray));
toast.success("검색 필터 설정이 저장되었습니다.");
}
}, [user?.userId, component.componentId, searchFilterColumns]);
// 🆕 검색 필터 토글 함수
const handleToggleFilterColumn = useCallback((columnName: string) => {
setSearchFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnName)) {
newSet.delete(columnName);
} else {
newSet.add(columnName);
}
return newSet;
});
}, []);
2025-09-03 16:38:10 +09:00
// 데이터 삭제 핸들러
const handleDeleteData = useCallback(() => {
if (selectedRows.size === 0) {
alert("삭제할 데이터를 선택해주세요.");
return;
}
setShowDeleteDialog(true);
}, [selectedRows.size]);
// 삭제 확인 핸들러
const handleDeleteConfirm = useCallback(async () => {
try {
setIsDeleting(true);
// 선택된 행의 실제 데이터 가져오기
const selectedData = Array.from(selectedRows).map((index) => data[index]);
// 실제 삭제 API 호출
await tableTypeApi.deleteTableData(component.tableName, selectedData);
// 선택 해제 및 다이얼로그 닫기
setSelectedRows(new Set());
setShowDeleteDialog(false);
// 데이터 새로고침
loadData(currentPage, searchValues);
} catch (error) {
// console.error("데이터 삭제 실패:", error);
2025-09-03 16:38:10 +09:00
alert("데이터 삭제에 실패했습니다.");
} finally {
setIsDeleting(false);
}
}, [selectedRows, data, currentPage, searchValues, loadData]);
// 삭제 다이얼로그 닫기 핸들러
const handleDeleteDialogClose = useCallback(() => {
if (!isDeleting) {
setShowDeleteDialog(false);
}
}, [isDeleting]);
// 필수 필드 여부 확인
const isRequiredField = useCallback(
(columnName: string) => {
return component.addModalConfig?.requiredFields?.includes(columnName) || false;
},
[component.addModalConfig],
);
// 모달 크기 클래스 가져오기
const getModalSizeClass = useCallback(() => {
const width = component.addModalConfig?.width || "lg";
const sizeMap = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
full: "max-w-full mx-4",
};
return sizeMap[width];
}, [component.addModalConfig]);
// 레이아웃 클래스 가져오기
const getLayoutClass = useCallback(() => {
const layout = component.addModalConfig?.layout || "two-column";
const gridColumns = component.addModalConfig?.gridColumns || 2;
switch (layout) {
case "single":
return "grid grid-cols-1 gap-4";
case "two-column":
return "grid grid-cols-2 gap-4";
case "grid":
return `grid grid-cols-${Math.min(gridColumns, 4)} gap-4`;
default:
return "grid grid-cols-2 gap-4";
}
}, [component.addModalConfig]);
// 수정 폼 입력 컴포넌트 렌더링
const renderEditFormInput = (column: DataTableColumn) => {
const value = editFormData[column.columnName] || "";
const isRequired = isRequiredField(column.columnName);
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
2025-09-03 17:12:27 +09:00
// 데이터베이스에서 실제 웹 타입 가져오기
const actualWebType = getColumnWebType(column.columnName);
const detailSettings = getColumnDetailSettings(column.columnName);
2025-09-03 16:38:10 +09:00
// 자동 생성 필드는 수정에서 읽기 전용으로 처리
if (advancedConfig?.inputType === "auto") {
return (
<div className="relative">
<Input
value={value}
readOnly
className="bg-gray-50 text-gray-700"
placeholder={`${column.label} (자동 생성됨)`}
/>
<p className="mt-1 text-xs text-gray-500"> .</p>
</div>
);
}
// 읽기 전용 필드
if (advancedConfig?.inputType === "readonly") {
return (
<div className="relative">
<Input
value={value}
readOnly
className="bg-gray-50 text-gray-700"
placeholder={advancedConfig?.placeholder || `${column.label} (읽기 전용)`}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
// 일반 입력 필드 렌더링
const commonProps = {
value,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => handleEditFormChange(column.columnName, e.target.value),
placeholder: advancedConfig?.placeholder || `${column.label} 입력...`,
required: isRequired,
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
};
2025-09-03 17:12:27 +09:00
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
switch (actualWebType) {
2025-09-03 16:38:10 +09:00
case "text":
case "email":
case "tel":
return (
<div>
<Input
2025-09-03 17:12:27 +09:00
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
2025-09-03 16:38:10 +09:00
{...commonProps}
2025-09-03 17:12:27 +09:00
maxLength={detailSettings?.maxLength}
2025-09-03 16:38:10 +09:00
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "number":
case "decimal":
return (
<div>
2025-09-03 17:12:27 +09:00
<Input
type="number"
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
min={detailSettings?.min}
max={detailSettings?.max}
{...commonProps}
/>
2025-09-03 16:38:10 +09:00
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "date":
return (
<div>
2025-09-03 17:12:27 +09:00
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
2025-09-03 16:38:10 +09:00
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "datetime":
return (
<div>
2025-09-03 17:12:27 +09:00
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
2025-09-03 16:38:10 +09:00
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "select":
case "dropdown":
2025-12-10 13:53:44 +09:00
// 🆕 연쇄 드롭다운 처리
const cascadingConfig = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfig?.enabled) {
const parentValue = editFormData[cascadingConfig.parentField];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfig}
parentValue={parentValue}
value={value}
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
2026-01-12 16:08:02 +09:00
2025-09-03 17:12:27 +09:00
// 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || [];
if (options.length > 0) {
return (
<div>
<Select value={value} onValueChange={(newValue) => handleEditFormChange(column.columnName, newValue)}>
<SelectTrigger className={commonProps.className}>
<SelectValue placeholder={commonProps.placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option: any, index: number) => (
<SelectItem key={index} value={option.value || option}>
{option.label || option}
</SelectItem>
))}
</SelectContent>
</Select>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
} else {
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
}
case "radio":
// 상세 설정에서 옵션 목록 가져오기
const radioOptions = detailSettings?.options || [];
if (radioOptions.length > 0) {
return (
<div>
<div className="space-y-2">
{radioOptions.map((option: any, index: number) => (
<div key={index} className="flex items-center space-x-2">
<input
type="radio"
id={`${column.columnName}-edit-${index}`}
name={`${column.columnName}-edit`}
value={option.value || option}
checked={value === (option.value || option)}
onChange={(e) => handleEditFormChange(column.columnName, e.target.value)}
className="text-primary focus:ring-primary"
/>
<Label htmlFor={`${column.columnName}-edit-${index}`} className="text-sm">
{option.label || option}
</Label>
</div>
))}
</div>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
} else {
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
}
case "textarea":
return (
<div>
<textarea
value={value}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
handleEditFormChange(column.columnName, e.target.value)
}
placeholder={advancedConfig?.placeholder || `${column.label} 입력...`}
required={isRequired}
className={`border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
isRequired && !value ? "border-orange-300 focus:border-orange-500" : ""
}`}
rows={detailSettings?.rows || 3}
maxLength={detailSettings?.maxLength}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "boolean":
case "checkbox":
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={value === true || value === "true" || value === 1}
onCheckedChange={(checked) => handleEditFormChange(column.columnName, checked)}
/>
<Label>{column.label}</Label>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "file":
return (
<div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Input
type="file"
accept={detailSettings?.accept}
multiple={detailSettings?.multiple}
onChange={(e) => handleFileUpload(column.columnName, e.target.files, true)}
className="hidden"
id={`file-edit-${column.columnName}`}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById(`file-edit-${column.columnName}`)?.click()}
disabled={uploadingFiles[column.columnName]}
className="gap-2"
>
{uploadingFiles[column.columnName] ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
{detailSettings?.accept && <span className="text-xs text-gray-500">({detailSettings.accept})</span>}
</div>
{renderFileList(column.columnName, true)}
</div>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
2025-09-03 16:38:10 +09:00
2025-11-05 18:08:51 +09:00
case "category": {
// 카테고리 셀렉트 (동적 import)
2026-01-12 16:08:02 +09:00
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
2025-11-05 18:08:51 +09:00
return (
<div>
<CategorySelectComponent
tableName={component.tableName}
columnName={column.columnName}
value={value}
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
placeholder={advancedConfig?.placeholder || `${column.label} 선택...`}
required={isRequired}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
2025-09-03 16:38:10 +09:00
default:
return (
<div>
<Input {...commonProps} />
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
};
// 추가 폼 입력 컴포넌트 렌더링
const renderAddFormInput = (column: DataTableColumn) => {
const value = addFormData[column.columnName] || "";
const isRequired = isRequiredField(column.columnName);
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
2025-09-03 17:12:27 +09:00
// 데이터베이스에서 실제 웹 타입 가져오기
const actualWebType = getColumnWebType(column.columnName);
const detailSettings = getColumnDetailSettings(column.columnName);
2025-09-03 16:38:10 +09:00
// 읽기 전용 또는 자동 값인 경우
if (advancedConfig?.inputType === "readonly" || advancedConfig?.inputType === "auto") {
return (
<div className="relative">
<Input
value={value}
readOnly
className="bg-gray-50 text-gray-700"
placeholder={advancedConfig?.placeholder || `${column.label} (자동 생성)`}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
// 일반 입력 필드 렌더링
const commonProps = {
value,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => handleAddFormChange(column.columnName, e.target.value),
placeholder: advancedConfig?.placeholder || `${column.label} 입력...`,
required: isRequired,
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
};
2025-09-03 17:12:27 +09:00
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
switch (actualWebType) {
2025-09-03 16:38:10 +09:00
case "text":
case "email":
case "tel":
return (
<div>
<Input
2025-09-03 17:12:27 +09:00
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
2025-09-03 16:38:10 +09:00
{...commonProps}
2025-09-03 17:12:27 +09:00
maxLength={detailSettings?.maxLength}
2025-09-03 16:38:10 +09:00
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "number":
case "decimal":
return (
<div>
2025-09-03 17:12:27 +09:00
<Input
type="number"
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
min={detailSettings?.min}
max={detailSettings?.max}
{...commonProps}
/>
2025-09-03 16:38:10 +09:00
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "date":
return (
<div>
2025-09-03 17:12:27 +09:00
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
2025-09-03 16:38:10 +09:00
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "datetime":
return (
<div>
2025-09-03 17:12:27 +09:00
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
2025-09-03 16:38:10 +09:00
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "select":
case "dropdown":
2025-12-10 13:53:44 +09:00
// 🆕 연쇄 드롭다운 처리
const cascadingConfigAdd = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfigAdd?.enabled) {
const parentValueAdd = addFormData[cascadingConfigAdd.parentField];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfigAdd}
parentValue={parentValueAdd}
value={value}
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
2026-01-12 16:08:02 +09:00
2025-09-03 17:12:27 +09:00
// 상세 설정에서 옵션 목록 가져오기
2025-12-10 13:53:44 +09:00
const optionsAdd = detailSettings?.options || [];
if (optionsAdd.length > 0) {
2025-09-03 17:12:27 +09:00
return (
<div>
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
<SelectTrigger className={commonProps.className}>
<SelectValue placeholder={commonProps.placeholder} />
</SelectTrigger>
<SelectContent>
2025-12-10 13:53:44 +09:00
{optionsAdd.map((option: any, index: number) => (
2025-09-03 17:12:27 +09:00
<SelectItem key={index} value={option.value || option}>
{option.label || option}
</SelectItem>
))}
</SelectContent>
</Select>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
} else {
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
}
case "radio":
// 상세 설정에서 옵션 목록 가져오기
2025-12-10 13:53:44 +09:00
const radioOptionsAdd = detailSettings?.options || [];
const defaultValueAdd = detailSettings?.defaultValue;
2025-09-03 17:12:27 +09:00
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
2025-12-10 13:53:44 +09:00
if (radioOptionsAdd.length > 0) {
2025-09-03 17:12:27 +09:00
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
2025-12-10 13:53:44 +09:00
if (!value && defaultValueAdd) {
setTimeout(() => handleAddFormChange(column.columnName, defaultValueAdd), 0);
2025-09-03 17:12:27 +09:00
}
return (
<div>
<div className="space-y-2">
2025-12-10 13:53:44 +09:00
{radioOptionsAdd.map((option: any, index: number) => (
2025-09-03 17:12:27 +09:00
<div key={index} className="flex items-center space-x-2">
<input
type="radio"
id={`${column.columnName}-add-${index}`}
name={`${column.columnName}-add`}
value={option.value || option}
checked={value === (option.value || option)}
onChange={(e) => handleAddFormChange(column.columnName, e.target.value)}
className="text-primary focus:ring-primary"
/>
<Label htmlFor={`${column.columnName}-add-${index}`} className="text-sm">
{option.label || option}
</Label>
</div>
))}
</div>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
} else {
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
}
2025-09-03 16:38:10 +09:00
case "boolean":
case "checkbox":
return (
<div className="flex items-center space-x-2">
<Checkbox
2025-09-03 17:12:27 +09:00
checked={value === true || value === "true" || value === 1}
2025-09-03 16:38:10 +09:00
onCheckedChange={(checked) => handleAddFormChange(column.columnName, checked)}
/>
<Label>{column.label}</Label>
2025-09-03 17:12:27 +09:00
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
case "textarea":
return (
<div>
<textarea
value={value}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
handleAddFormChange(column.columnName, e.target.value)
}
placeholder={advancedConfig?.placeholder || `${column.label} 입력...`}
required={isRequired}
className={`border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
isRequired && !value ? "border-orange-300 focus:border-orange-500" : ""
}`}
rows={detailSettings?.rows || 3}
maxLength={detailSettings?.maxLength}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
2025-09-15 15:38:48 +09:00
case "code":
// 코드 카테고리에서 코드 옵션 가져오기
const codeCategory = getColumnCodeCategory(column.columnName);
if (codeCategory) {
const codeOptionsForCategory = codeOptions[codeCategory] || [];
// 코드 옵션이 없으면 로드
if (codeOptionsForCategory.length === 0) {
loadCodeOptions(codeCategory);
}
return (
<div>
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
<SelectTrigger className={commonProps.className}>
<SelectValue placeholder={`${column.label} 선택...`} />
</SelectTrigger>
<SelectContent>
{codeOptionsForCategory.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
} else {
return (
<div>
<Input {...commonProps} placeholder={`${column.label} (코드 카테고리 설정 필요)`} readOnly />
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
2025-09-03 17:12:27 +09:00
case "file":
return (
<div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Input
type="file"
accept={detailSettings?.accept}
multiple={detailSettings?.multiple}
onChange={(e) => handleFileUpload(column.columnName, e.target.files, false)}
className="hidden"
id={`file-add-${column.columnName}`}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById(`file-add-${column.columnName}`)?.click()}
disabled={uploadingFiles[column.columnName]}
className="gap-2"
>
{uploadingFiles[column.columnName] ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
{detailSettings?.accept && <span className="text-xs text-gray-500">({detailSettings.accept})</span>}
</div>
{renderFileList(column.columnName, false)}
</div>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
2025-09-03 16:38:10 +09:00
</div>
);
2025-11-05 18:08:51 +09:00
case "category": {
// 카테고리 셀렉트 (동적 import)
2026-01-12 16:08:02 +09:00
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
2025-11-05 18:08:51 +09:00
return (
<div>
<CategorySelectComponent
tableName={component.tableName}
columnName={column.columnName}
value={value}
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
placeholder={advancedConfig?.placeholder || `${column.label} 선택...`}
required={isRequired}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
2025-09-03 16:38:10 +09:00
default:
return (
2025-09-03 17:12:27 +09:00
<div>
<Input {...commonProps} />
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
2025-09-03 16:38:10 +09:00
);
}
};
2025-09-23 14:26:18 +09:00
// 기존 renderSearchFilter 함수는 AdvancedSearchFilters 컴포넌트로 대체됨
2025-09-03 15:23:12 +09:00
2025-09-05 12:04:13 +09:00
// 파일 다운로드
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
try {
// savedFileName이 없는 경우 파일 경로에서 추출 시도
const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null);
2025-09-05 21:52:19 +09:00
if (!serverFilename) {
2025-09-05 12:04:13 +09:00
// _file 속성이 있는 경우 로컬 파일로 다운로드
if ((fileInfo as any)._file) {
try {
const file = (fileInfo as any)._file;
// File 객체 유효성 검사
if (!(file instanceof File) && !(file instanceof Blob)) {
// console.error("❌ 잘못된 파일 객체:", file);
2025-09-05 12:04:13 +09:00
toast.error("파일 객체가 손상되었습니다. 파일을 다시 업로드해주세요.");
return;
}
const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.href = url;
link.download = fileInfo.name || file.name || "download";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
return;
} catch (error) {
// console.error("❌ 로컬 파일 다운로드 오류:", error);
2025-09-05 12:04:13 +09:00
toast.error("로컬 파일 다운로드에 실패했습니다. 파일을 다시 업로드해주세요.");
return;
}
}
toast.error("이 파일은 이전 버전에서 저장된 파일입니다. 파일을 다시 업로드해주세요.");
return;
}
toast.loading(`${fileInfo.name} 다운로드 중...`);
await downloadFile({
2025-09-05 21:52:19 +09:00
fileId: fileInfo.objid || fileInfo.id,
serverFilename: serverFilename,
2025-09-05 12:04:13 +09:00
originalName: fileInfo.name,
});
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
} catch (error) {
// console.error("파일 다운로드 오류:", error);
2025-09-05 12:04:13 +09:00
toast.error(`${fileInfo.name} 다운로드에 실패했습니다.`);
}
}, []);
2025-09-08 10:02:30 +09:00
// 🗑️ 연결된 파일 삭제 함수
const handleDeleteLinkedFile = useCallback(
async (fileId: string, fileName: string) => {
try {
// 삭제 확인 다이얼로그
if (!confirm(`"${fileName}" 파일을 삭제하시겠습니까?`)) {
return;
}
// API 호출로 파일 삭제 (논리적 삭제) - apiClient 사용으로 JWT 토큰 자동 추가
const apiClient = (await import("@/lib/api/client")).apiClient;
const response = await apiClient.delete(`/files/${fileId}`, {
data: {
writer: "current_user", // 현재 사용자 정보
},
});
const result = response.data;
if (!result.success) {
throw new Error(result.message || "파일 삭제 실패");
}
// 성공 메시지
toast.success(`"${fileName}" 파일이 삭제되었습니다.`);
// 파일 목록 새로고침
if (showFileManagementModal && selectedRowForFiles && component.tableName) {
const primaryKeyField = Object.keys(selectedRowForFiles)[0];
const recordId = selectedRowForFiles[primaryKeyField];
try {
const response = await getLinkedFiles(component.tableName, recordId);
setLinkedFiles(response.files || []);
} catch (error) {
// 파일 목록 새로고침 실패 시 무시
2025-09-08 10:02:30 +09:00
}
}
} catch (error) {
toast.error(`"${fileName}" 파일 삭제에 실패했습니다.`);
}
},
[showFileManagementModal, selectedRowForFiles, component.tableName],
);
2025-09-03 15:23:12 +09:00
// 셀 값 포맷팅
const formatCellValue = (value: any, column: DataTableColumn, rowData?: Record<string, any>): React.ReactNode => {
// 가상 파일 컬럼의 경우 value가 없어도 파일 아이콘을 표시해야 함
if (!column.isVirtualFileColumn && (value === null || value === undefined)) return "";
// 실제 웹 타입 가져오기 (input_type 포함)
const actualWebType = getColumnWebType(column.columnName);
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
2026-01-12 16:08:02 +09:00
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
if (isFileColumn && rowData) {
// 현재 행의 기본키 값 가져오기
const primaryKeyField = Object.keys(rowData)[0];
const recordId = rowData[primaryKeyField];
// 해당 컬럼에 대한 파일 상태 확인
const columnFileKey = `${recordId}_${column.columnName}`;
const columnFileStatus = fileStatusMap[columnFileKey];
const hasFiles = columnFileStatus?.hasFiles || false;
const fileCount = columnFileStatus?.fileCount || 0;
2025-09-05 21:52:19 +09:00
return (
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
className="hover:bg-accent h-8 w-8 p-0"
onClick={() => handleColumnFileClick(rowData, column)}
title={hasFiles ? `${fileCount}개 파일 보기` : "파일 업로드"}
>
{hasFiles ? (
<div className="relative">
<FolderOpen className="text-primary h-4 w-4" />
{fileCount > 0 && (
<div className="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
{fileCount > 9 ? "9+" : fileCount}
</div>
)}
2025-09-05 21:52:19 +09:00
</div>
) : (
<Folder className="h-4 w-4 text-gray-400" />
)}
</Button>
</div>
);
2025-09-05 21:52:19 +09:00
}
2025-09-05 12:04:13 +09:00
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
switch (actualWebType) {
case "category": {
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (!value) return "";
2026-01-12 16:08:02 +09:00
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
2026-01-12 16:08:02 +09:00
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color;
2026-01-12 16:08:02 +09:00
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>;
}
2026-01-12 16:08:02 +09:00
return (
2026-01-12 16:08:02 +09:00
<Badge
style={{
backgroundColor: displayColor,
2026-01-12 16:08:02 +09:00
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
2025-09-03 15:23:12 +09:00
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
break;
case "datetime":
if (value) {
try {
const date = new Date(value);
return date.toLocaleString("ko-KR");
} catch {
return value;
}
}
break;
case "number":
case "decimal":
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
return numValue.toLocaleString("ko-KR");
}
2025-09-03 15:23:12 +09:00
}
break;
2026-01-12 16:08:02 +09:00
default: {
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
for (const columnName of Object.keys(categoryMappings)) {
const mapping = categoryMappings[columnName];
const categoryData = mapping?.[strValue];
if (categoryData) {
// 색상이 있으면 배지로, 없으면 텍스트로 표시
if (categoryData.color && categoryData.color !== "none") {
return (
<Badge
style={{
backgroundColor: categoryData.color,
borderColor: categoryData.color,
}}
className="text-white"
>
{categoryData.label}
</Badge>
);
}
return <span className="text-sm">{categoryData.label}</span>;
}
}
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
const cachedLabel = categoryCodeLabels[strValue];
if (cachedLabel) {
return <span className="text-sm">{cachedLabel}</span>;
}
}
return strValue;
}
2025-09-03 15:23:12 +09:00
}
return String(value);
};
return (
<div
className={cn(
"flex h-full flex-col rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm",
className,
)}
style={{ ...style, minHeight: "680px" }}
>
2025-09-03 15:23:12 +09:00
{/* 헤더 */}
2025-09-09 17:42:23 +09:00
<div className="p-6 pb-3">
2025-09-03 15:23:12 +09:00
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4" />
2025-09-09 17:42:23 +09:00
<h3 className="text-lg font-semibold">{component.title || component.label}</h3>
2025-09-03 15:23:12 +09:00
{loading && (
<Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
...
</Badge>
)}
</div>
<div className="flex items-center space-x-2">
2025-09-03 16:38:10 +09:00
{/* 선택된 행 개수 표시 */}
{selectedRows.size > 0 && (
<Badge variant="secondary" className="text-xs">
{selectedRows.size}
</Badge>
)}
2025-09-03 15:23:12 +09:00
{searchFilters.length > 0 && (
<Badge variant="outline" className="text-xs">
<Search className="mr-1 h-3 w-3" />
{searchFilters.length}
</Badge>
)}
2025-09-03 16:38:10 +09:00
{/* CRUD 버튼들 */}
{component.enableAdd && (
2025-10-28 15:39:22 +09:00
<Button
size="sm"
onClick={() => {
if (isPreviewMode) {
return;
}
handleAddData();
}}
disabled={loading || isPreviewMode}
className="gap-2"
>
2025-09-03 16:38:10 +09:00
<Plus className="h-3 w-3" />
{component.addButtonText || "추가"}
</Button>
)}
{component.enableEdit && selectedRows.size === 1 && (
2025-10-28 15:39:22 +09:00
<Button
size="sm"
onClick={() => {
if (isPreviewMode) {
return;
}
handleEditData();
}}
disabled={loading || isPreviewMode}
className="gap-2"
variant="outline"
>
2025-09-03 16:38:10 +09:00
<Edit className="h-3 w-3" />
{component.editButtonText || "수정"}
</Button>
)}
{component.enableDelete && selectedRows.size > 0 && (
2025-10-28 15:39:22 +09:00
<Button
size="sm"
variant="destructive"
onClick={() => {
if (isPreviewMode) {
return;
}
handleDeleteData();
}}
disabled={loading || isPreviewMode}
className="gap-2"
>
2025-09-03 16:38:10 +09:00
<Trash2 className="h-3 w-3" />
{component.deleteButtonText || "삭제"}
</Button>
)}
2025-09-03 15:23:12 +09:00
{component.showSearchButton && (
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
<Search className="h-3 w-3" />
{component.searchButtonText || "검색"}
</Button>
)}
2025-09-03 16:38:10 +09:00
2025-09-03 15:23:12 +09:00
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
2025-09-23 14:26:18 +09:00
{/* 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
{tableColumns && tableColumns.length > 0 && (
2025-09-03 15:23:12 +09:00
<>
<Separator className="my-2" />
2025-09-23 14:26:18 +09:00
<AdvancedSearchFilters
filters={searchFilters.length > 0 ? searchFilters : []}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleSearch}
onClearFilters={() => {
setSearchValues({});
loadData(1, {});
}}
tableColumns={tableColumns}
tableName={component.tableName}
/>
2025-09-03 15:23:12 +09:00
</>
)}
2025-09-09 17:42:23 +09:00
</div>
2025-09-03 15:23:12 +09:00
{/* 테이블 내용 */}
2025-09-09 17:42:23 +09:00
<div className="flex-1 p-0">
2025-09-03 15:23:12 +09:00
<div className="flex h-full flex-col">
{visibleColumns.length > 0 ? (
<>
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
2026-01-12 16:08:02 +09:00
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="from-muted/50 to-muted border-primary/20 border-b-2 bg-gradient-to-b">
2025-09-03 15:23:12 +09:00
<TableRow>
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
2026-01-12 16:08:02 +09:00
<TableHead className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.size === data.length && data.length > 0}
onCheckedChange={handleSelectAll}
/>
</TableHead>
)}
{visibleColumns.map((column: DataTableColumn, columnIndex) => {
const columnWidth = columnWidths[column.id];
2026-01-12 16:08:02 +09:00
return (
<TableHead
key={column.id}
ref={(el) => (columnRefs.current[column.id] = el)}
2026-01-12 16:08:02 +09:00
className="text-foreground/90 hover:bg-muted/70 relative px-4 text-center font-bold transition-colors select-none"
style={{
width: columnWidth ? `${columnWidth}px` : undefined,
2026-01-12 16:08:02 +09:00
userSelect: "none",
}}
>
{column.label}
{/* 리사이즈 핸들 */}
{columnIndex < visibleColumns.length - 1 && (
<div
2026-01-12 16:08:02 +09:00
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
2026-01-12 16:08:02 +09:00
const thElement = columnRefs.current[column.id];
if (!thElement) return;
2026-01-12 16:08:02 +09:00
isResizingRef.current = true;
2026-01-12 16:08:02 +09:00
const startX = e.clientX;
const startWidth = columnWidth || thElement.offsetWidth;
2026-01-12 16:08:02 +09:00
// 드래그 중 텍스트 선택 방지
2026-01-12 16:08:02 +09:00
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
2026-01-12 16:08:02 +09:00
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(80, startWidth + diff);
2026-01-12 16:08:02 +09:00
// 직접 DOM 스타일 변경 (리렌더링 없음)
if (thElement) {
thElement.style.width = `${newWidth}px`;
}
};
2026-01-12 16:08:02 +09:00
const handleMouseUp = () => {
// 최종 너비를 state에 저장
if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth);
2026-01-12 16:08:02 +09:00
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth }));
}
2026-01-12 16:08:02 +09:00
// 텍스트 선택 복원
2026-01-12 16:08:02 +09:00
document.body.style.userSelect = "";
document.body.style.cursor = "";
// 약간의 지연 후 리사이즈 플래그 해제
setTimeout(() => {
isResizingRef.current = false;
}, 100);
2026-01-12 16:08:02 +09:00
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
2026-01-12 16:08:02 +09:00
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
)}
</TableHead>
);
})}
{/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
2025-09-03 15:23:12 +09:00
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
className="h-32 text-center"
>
<div className="text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
</TableCell>
2025-09-03 15:23:12 +09:00
</TableRow>
) : data.length > 0 ? (
data.map((row, rowIndex) => (
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
2026-01-12 16:08:02 +09:00
<TableCell className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
/>
</TableCell>
)}
{visibleColumns.map((column: DataTableColumn) => {
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
return (
2026-01-12 16:08:02 +09:00
<TableCell
key={column.id}
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900"
style={{ textAlign: isNumeric ? "right" : "left" }}
>
{formatCellValue(row[column.columnName], column, row)}
</TableCell>
);
})}
{/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
className="h-32 text-center"
>
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-8 w-8" />
<p> </p>
<p className="text-xs"> </p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
2025-09-29 17:21:47 +09:00
</div>
2025-09-03 15:23:12 +09:00
{/* 페이지네이션 */}
{component.pagination?.enabled && totalPages > 1 && (
<div className="mt-auto border-t border-gray-200/60 bg-gradient-to-r from-gray-50 to-slate-50">
2025-09-03 15:23:12 +09:00
<div className="flex items-center justify-between px-6 py-3">
{component.pagination.showPageInfo && (
<div className="text-muted-foreground text-sm">
<span className="font-medium">{total.toLocaleString()}</span> {" "}
<span className="font-medium">{((currentPage - 1) * pageSize + 1).toLocaleString()}</span>-
<span className="font-medium">{Math.min(currentPage * pageSize, total).toLocaleString()}</span>
</div>
)}
<div className="flex items-center space-x-2">
{component.pagination.showFirstLast && (
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
className="gap-1"
>
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="gap-1"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-1 text-sm font-medium">
<span>{currentPage}</span>
<span className="text-muted-foreground">/</span>
<span>{totalPages}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className="gap-1"
>
<ChevronRight className="h-3 w-3" />
</Button>
{component.pagination.showFirstLast && (
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages || loading}
className="gap-1"
>
</Button>
)}
</div>
</div>
</div>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-8 w-8" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
</div>
)}
</div>
2025-09-09 17:42:23 +09:00
</div>
2025-09-03 16:38:10 +09:00
{/* SaveModal (등록/수정 통합) */}
<SaveModal
isOpen={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setSaveModalData(undefined);
setSaveModalScreenId(undefined);
}}
screenId={saveModalScreenId}
modalSize={component.addModalConfig?.modalSize || "lg"}
initialData={saveModalData}
onSaveSuccess={() => {
// 저장 성공 시 테이블 새로고침
loadData(currentPage, searchValues); // 현재 페이지로 다시 로드
setSelectedRows(new Set()); // 선택 해제
}}
/>
{/* 기존 데이터 추가 모달 (제거 예정 - SaveModal로 대체됨) */}
<Dialog open={false} onOpenChange={() => {}}>
2025-12-05 10:46:10 +09:00
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
2025-09-03 16:38:10 +09:00
<DialogHeader>
<DialogTitle>{component.addModalConfig?.title || "새 데이터 추가"}</DialogTitle>
<DialogDescription>
{component.addModalConfig?.description ||
`${component.title || component.label}에 새로운 데이터를 추가합니다.`}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className={getLayoutClass()}>
{getDisplayColumns().map((column) => (
<div key={column.id} className="space-y-2">
<Label htmlFor={column.columnName} className="text-sm font-medium">
{column.label}
{isRequiredField(column.columnName) && <span className="ml-1 text-orange-500">*</span>}
</Label>
<div>{renderAddFormInput(column)}</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleAddModalClose} disabled={isAdding}>
{component.addModalConfig?.cancelButtonText || "취소"}
</Button>
<Button onClick={handleAddSubmit} disabled={isAdding}>
{isAdding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{component.addModalConfig?.submitButtonText || "추가"}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 기존 데이터 수정 모달 (제거 예정 - SaveModal로 대체됨) */}
<Dialog open={false} onOpenChange={() => {}}>
2025-12-05 10:46:10 +09:00
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
2025-09-03 16:38:10 +09:00
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className={getLayoutClass()}>
{getDisplayColumns().map((column) => (
<div key={column.id} className="space-y-2">
<Label htmlFor={`edit-${column.columnName}`} className="text-sm font-medium">
{column.label}
{isRequiredField(column.columnName) && <span className="ml-1 text-orange-500">*</span>}
</Label>
<div>{renderEditFormInput(column)}</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowEditModal(false);
setEditFormData({});
setEditingRowData(null);
2025-09-03 17:12:27 +09:00
setUploadedFiles({}); // 파일 상태 초기화
2025-09-03 16:38:10 +09:00
}}
disabled={isEditing}
>
</Button>
<Button onClick={handleEditSubmit} disabled={isEditing}>
{isEditing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Edit className="mr-2 h-4 w-4" />
{component.editButtonText || "수정"}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-09-05 12:04:13 +09:00
{/* 파일 정보 모달 */}
<Dialog open={showFileModal} onOpenChange={setShowFileModal}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<File className="h-5 w-5" />
- {currentFileColumn?.label || "파일"}
</DialogTitle>
<DialogDescription>
{currentFileData?.totalCount === 1
? "1개의 파일이 저장되어 있습니다."
: `${currentFileData?.totalCount}개의 파일이 저장되어 있습니다.`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
{currentFileData?.files && Array.isArray(currentFileData.files) && (
<div className="space-y-3">
{currentFileData.files.map((fileInfo: FileInfo, index: number) => {
const isImage = fileInfo.type?.startsWith("image/");
return (
<div key={index} className="rounded-lg border bg-gray-50 p-4 transition-colors hover:bg-gray-100">
<div className="flex items-start gap-4">
{/* 파일 아이콘/미리보기 */}
<div className="flex-shrink-0">
{isImage ? (
<div className="flex h-16 w-16 items-center justify-center rounded border bg-white">
<div className="text-xs font-medium text-green-600">IMG</div>
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded border bg-white">
<File className="h-8 w-8 text-gray-500" />
</div>
)}
</div>
{/* 파일 정보 */}
<div className="min-w-0 flex-1">
<h4 className="truncate font-medium text-gray-900" title={fileInfo.name}>
{fileInfo.name}
</h4>
<div className="text-muted-foreground mt-1 space-y-1 text-sm">
2025-09-05 12:04:13 +09:00
<div className="flex items-center gap-4">
<span>: {(fileInfo.size / 1024 / 1024).toFixed(2)} MB</span>
<span>: {fileInfo.type || "알 수 없음"}</span>
</div>
<div className="flex items-center gap-4">
2025-09-05 21:52:19 +09:00
{fileInfo.regdate && (
<span>: {new Date(fileInfo.regdate).toLocaleString("ko-KR")}</span>
)}
{fileInfo.writer && <span>: {fileInfo.writer}</span>}
2025-09-05 12:04:13 +09:00
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex flex-col gap-2">
{isImage && (
<Button
variant="outline"
size="sm"
className="w-full"
2025-09-05 14:52:10 +09:00
onClick={() => handlePreviewImage(fileInfo)}
2025-09-05 12:04:13 +09:00
>
<Eye className="mr-1 h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleDownloadFile(fileInfo)}
>
<Download className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* 요약 정보 */}
{currentFileData && (
<div className="border-primary/20 bg-accent mt-4 rounded-lg border p-3">
2025-09-05 12:04:13 +09:00
<h5 className="mb-2 font-medium text-blue-900"> </h5>
<div className="grid grid-cols-2 gap-4 text-sm text-blue-800">
<div>
<span className="font-medium"> :</span>
{" "}
{currentFileData.totalCount}
</div>
<div>
<span className="font-medium"> :</span>
{" "}
{(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowFileModal(false)}>
<X className="mr-1 h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-09-03 16:38:10 +09:00
{/* 데이터 삭제 확인 다이얼로그 */}
<Dialog open={showDeleteDialog} onOpenChange={handleDeleteDialogClose}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
<strong>{selectedRows.size}</strong> ?
<br />
<span className="text-destructive"> .</span>
2025-09-03 16:38:10 +09:00
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleDeleteDialogClose} disabled={isDeleting}>
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-09-05 14:52:10 +09:00
{/* 이미지 미리보기 다이얼로그 */}
<Dialog open={showPreviewModal} onOpenChange={closePreviewModal}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span className="truncate">{previewImage?.name}</span>
<div className="flex items-center space-x-2">
<Button size="sm" variant="outline" onClick={() => handleZoom("out")} disabled={zoom <= 0.25}>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="min-w-[60px] text-center text-sm text-gray-500">{Math.round(zoom * 100)}%</span>
<Button size="sm" variant="outline" onClick={() => handleZoom("in")} disabled={zoom >= 3}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleRotate}>
<RotateCw className="h-4 w-4" />
</Button>
{previewImage && (
<Button
size="sm"
variant="outline"
onClick={() => {
handleDownloadFile(previewImage);
}}
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</DialogTitle>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
{previewImage && (
<img
2025-09-08 13:10:09 +09:00
src={alternativeImageUrl || getFilePreviewUrl(previewImage.id)}
2025-09-05 14:52:10 +09:00
alt={previewImage.name}
className="max-h-full max-w-full object-contain transition-transform duration-200"
style={{
transform: `scale(${zoom}) rotate(${rotation}deg)`,
}}
2025-09-08 13:10:09 +09:00
onError={handleImageError}
2025-09-05 14:52:10 +09:00
/>
)}
</div>
{previewImage && (
<div className="flex items-center justify-between border-t pt-3 text-sm text-gray-500">
<div>: {formatFileSize(previewImage.size)}</div>
<div>: {previewImage.type}</div>
<div>: {new Date(previewImage.uploadedAt).toLocaleDateString("ko-KR")}</div>
</div>
)}
</DialogContent>
</Dialog>
{/* 파일 관리 모달 */}
<Dialog open={showFileManagementModal} onOpenChange={setShowFileManagementModal}>
2025-12-05 10:46:10 +09:00
<DialogContent className="max-h-[80vh] max-w-4xl overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Folder className="h-5 w-5" />
{selectedRowForFiles && (
<Badge variant="outline" className="ml-2">
{Object.keys(selectedRowForFiles)[0]}: {selectedRowForFiles[Object.keys(selectedRowForFiles)[0]]}
</Badge>
)}
</DialogTitle>
<DialogDescription>
{linkedFiles.length > 0
? `${linkedFiles.length}개의 파일이 연결되어 있습니다.`
: "연결된 파일이 없습니다. 새 파일을 업로드하세요."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 기존 파일 목록 */}
{linkedFiles.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
{linkedFiles.map((file: any, index: number) => (
<div key={index} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center space-x-3">
<File className="text-primary h-5 w-5" />
<div>
<div className="font-medium">{file.realFileName}</div>
<div className="text-sm text-gray-500">
{(Number(file.fileSize) / 1024 / 1024).toFixed(2)} MB {file.docTypeName}
{file.regdate && <span> {new Date(file.regdate).toLocaleString("ko-KR")}</span>}
{file.writer && <span> {file.writer}</span>}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{file.fileExt && ["jpg", "jpeg", "png", "gif"].includes(file.fileExt.toLowerCase()) && (
<Button
size="sm"
variant="outline"
onClick={() => {
// 이미지 미리보기 (기존 로직 재사용)
const fileInfo: FileInfo = {
id: file.objid,
name: file.realFileName,
size: Number(file.fileSize),
type: `image/${file.fileExt}`,
path: file.filePath,
objid: file.objid,
extension: file.fileExt,
uploadedAt: file.regdate || new Date().toISOString(),
lastModified: file.regdate || new Date().toISOString(),
};
handlePreviewImage(fileInfo);
}}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
// 파일 다운로드 (기존 로직 재사용)
const fileInfo: FileInfo = {
id: file.objid,
name: file.realFileName,
size: Number(file.fileSize),
type: `application/${file.fileExt}`,
path: file.filePath,
objid: file.objid,
savedFileName: file.savedFileName,
};
handleDownloadFile(fileInfo);
}}
>
<Download className="h-4 w-4" />
2025-09-08 10:02:30 +09:00
</Button>
{/* 🗑️ 파일 삭제 버튼 */}
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteLinkedFile(file.objid, file.realFileName)}
className="hover:bg-destructive/10 text-red-500 hover:text-red-700"
2025-09-08 10:02:30 +09:00
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 파일 업로드 섹션 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-900">{selectedColumnForFiles?.label || "파일"} </h4>
{selectedColumnForFiles?.isVirtualFileColumn && (
<Badge variant="secondary" className="text-xs">
{selectedColumnForFiles.fileColumnConfig?.docTypeName || "문서"}
</Badge>
)}
</div>
{selectedRowForFiles && selectedColumnForFiles && component.tableName && (
<div className="rounded-lg border p-4">
<FileUpload
component={{
id: `modal-file-upload-${selectedColumnForFiles.id}`,
type: "file",
position: { x: 0, y: 0 },
size: { width: 400, height: 300 },
uploadedFiles: [], // 빈 배열로 초기화
fileConfig: {
maxSize: selectedColumnForFiles.fileColumnConfig?.maxFiles || 10,
maxFiles: selectedColumnForFiles.fileColumnConfig?.maxFiles || 5,
multiple: true,
showPreview: true,
showProgress: true,
autoUpload: true, // 자동 업로드 활성화
chunkedUpload: false, // 기본 업로드 방식
dragDropText: `${selectedColumnForFiles.label} 파일을 드래그하여 업로드하거나 클릭하세요`,
uploadButtonText: "파일 업로드", // 업로드 버튼 텍스트
accept: selectedColumnForFiles.fileColumnConfig?.accept || ["*/*"],
// 문서 분류 설정
docType: selectedColumnForFiles.fileColumnConfig?.docType || "DOCUMENT",
docTypeName: selectedColumnForFiles.fileColumnConfig?.docTypeName || "일반 문서",
// 자동 연결 설정
autoLink: true,
linkedTable: component.tableName,
linkedField: Object.keys(selectedRowForFiles)[0], // 기본키 필드
recordId: selectedRowForFiles[Object.keys(selectedRowForFiles)[0]], // 기본키 값
// 가상 파일 컬럼별 구분을 위한 추가 정보
columnName: selectedColumnForFiles.columnName,
isVirtualFileColumn: selectedColumnForFiles.isVirtualFileColumn,
},
}}
onUpdateComponent={() => {
// 모달에서는 컴포넌트 업데이트가 필요 없으므로 빈 함수 제공
}}
onFileUpload={async () => {
// 파일 업로드 완료 후 연결된 파일 목록 새로고침
if (selectedRowForFiles && selectedColumnForFiles) {
const result = await checkColumnFileStatus(selectedRowForFiles, selectedColumnForFiles);
if (result) {
setLinkedFiles(result.files);
// 파일 상태 맵도 업데이트
const primaryKeyField = Object.keys(selectedRowForFiles)[0];
const recordId = selectedRowForFiles[primaryKeyField];
const columnFileKey = `${recordId}_${selectedColumnForFiles.columnName}`;
setFileStatusMap((prev) => {
const newFileStatusMap = {
...prev,
[columnFileKey]: {
hasFiles: result.hasFiles,
fileCount: result.fileCount,
},
};
return newFileStatusMap;
});
// 전체 테이블의 해당 컬럼 파일 상태도 강제 새로고침
setTimeout(() => {
// 테이블 데이터 새로고침을 위해 loadData 호출
if (data && data.length > 0) {
// 현재 데이터를 그대로 사용하되 파일 상태만 새로고침
const refreshPromises = data.map(async (row) => {
const pk = Object.keys(row)[0];
const rowId = row[pk];
const fileKey = `${rowId}_${selectedColumnForFiles.columnName}`;
const columnStatus = await checkColumnFileStatus(row, selectedColumnForFiles);
if (columnStatus) {
setFileStatusMap((prev) => ({
...prev,
[fileKey]: {
hasFiles: columnStatus.hasFiles,
fileCount: columnStatus.fileCount,
},
}));
}
});
Promise.all(refreshPromises);
}
}, 100);
}
}
}}
/>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowFileManagementModal(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-09-09 17:42:23 +09:00
</div>
2025-09-03 15:23:12 +09:00
);
};