- Refactored the handling of "in" and "not_in" operators to ensure proper array handling and prevent errors when values are not provided. - Enhanced the InteractiveDataTable component to re-fetch data when filters are applied, improving user experience. - Updated DataFilterConfigPanel to correctly manage filter values based on selected operators. - Adjusted SplitPanelLayoutComponent to apply client-side data filtering based on defined conditions. These changes aim to improve the robustness and usability of the data filtering features across the application.
3322 lines
130 KiB
TypeScript
3322 lines
130 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
// Card 컴포넌트 제거 - 외부 박스 없이 직접 렌더링
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Search,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
RotateCcw,
|
|
Database,
|
|
Loader2,
|
|
Plus,
|
|
Edit,
|
|
Trash2,
|
|
File,
|
|
Download,
|
|
Eye,
|
|
X,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
RotateCw,
|
|
Folder,
|
|
FolderOpen,
|
|
Grid,
|
|
Filter,
|
|
} from "lucide-react";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
|
import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
|
import { cn } from "@/lib/utils";
|
|
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
|
import { toast } from "sonner";
|
|
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
|
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
|
import { SaveModal } from "./SaveModal";
|
|
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";
|
|
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 (
|
|
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
|
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와 동일
|
|
}
|
|
|
|
interface FileColumnData {
|
|
files: FileInfo[];
|
|
totalCount: number;
|
|
totalSize: number;
|
|
lastModified: string;
|
|
}
|
|
|
|
interface InteractiveDataTableProps {
|
|
component: DataTableComponent;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
onRefresh?: () => void; // 테이블 새로고침 콜백
|
|
}
|
|
|
|
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|
component,
|
|
className = "",
|
|
style = {},
|
|
onRefresh,
|
|
}) => {
|
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
|
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
|
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
|
|
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
|
|
|
|
// 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]);
|
|
|
|
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);
|
|
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);
|
|
|
|
// 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);
|
|
|
|
// 이미지 미리보기 상태
|
|
const [previewImage, setPreviewImage] = useState<FileInfo | null>(null);
|
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
|
const [zoom, setZoom] = useState(1);
|
|
const [rotation, setRotation] = useState(0);
|
|
const [imageLoadError, setImageLoadError] = useState(false);
|
|
const [alternativeImageUrl, setAlternativeImageUrl] = useState<string | null>(null);
|
|
|
|
// 파일 관리 상태
|
|
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[]>([]);
|
|
|
|
// 공통코드 관리 상태
|
|
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>>({}); // 컬럼명 -> 라벨 매핑
|
|
|
|
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
|
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}`;
|
|
|
|
useEffect(() => {
|
|
if (!component.tableName || !component.columns) return;
|
|
|
|
registerTable({
|
|
tableId,
|
|
label: component.title || "데이터 테이블",
|
|
tableName: component.tableName,
|
|
columns: component.columns.map((col) => ({
|
|
columnName: col.columnName,
|
|
columnLabel: col.label,
|
|
inputType: col.widgetType || "text",
|
|
visible: col.visible !== false,
|
|
width: (col as any).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]);
|
|
|
|
// 공통코드 옵션 가져오기
|
|
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);
|
|
}
|
|
|
|
return [];
|
|
},
|
|
[codeOptions],
|
|
);
|
|
|
|
// 데이터 로드 함수 (useEffect보다 먼저 선언해야 함 - TS2448 방지)
|
|
const loadData = useCallback(
|
|
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
|
if (!component.tableName) return;
|
|
|
|
// 프리뷰 모드에서는 샘플 데이터만 표시
|
|
if (isPreviewMode) {
|
|
const sampleData = Array.from({ length: 3 }, (_, i) => {
|
|
const sample: Record<string, any> = { id: i + 1 };
|
|
component.columns.forEach((col) => {
|
|
if (col.widgetType === "number") {
|
|
sample[col.columnName] = Math.floor(Math.random() * 1000);
|
|
} else if (col.widgetType === "boolean") {
|
|
sample[col.columnName] = i % 2 === 0 ? "Y" : "N";
|
|
} else {
|
|
sample[col.columnName] = `샘플 ${col.label} ${i + 1}`;
|
|
}
|
|
});
|
|
return sample;
|
|
});
|
|
setData(sampleData);
|
|
setTotal(3);
|
|
setTotalPages(1);
|
|
setCurrentPage(1);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
|
let linkedFilterValues: Record<string, any> = {};
|
|
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
|
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
|
|
|
if (splitPanelContext) {
|
|
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
|
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
|
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
|
(filter) =>
|
|
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
|
|
);
|
|
|
|
// 좌측 데이터 선택 여부 확인
|
|
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;
|
|
}
|
|
|
|
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
|
|
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
|
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
|
|
console.log("⚠️ [InteractiveDataTable] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
|
|
setData([]);
|
|
setTotal(0);
|
|
setTotalPages(0);
|
|
setCurrentPage(1);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// 🆕 RelatedDataButtons 필터 적용
|
|
const relatedButtonFilterValues: Record<string, any> = {};
|
|
if (relatedButtonFilter) {
|
|
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
|
}
|
|
|
|
// 검색 파라미터와 연결 필터 병합
|
|
const mergedSearchParams = {
|
|
...searchParams,
|
|
...linkedFilterValues,
|
|
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
|
};
|
|
|
|
const currentPageSize = component.pagination?.pageSize || 10;
|
|
|
|
console.log("🔍 데이터 조회 시작:", {
|
|
tableName: component.tableName,
|
|
page,
|
|
pageSize: currentPageSize,
|
|
linkedFilterValues,
|
|
relatedButtonFilterValues,
|
|
mergedSearchParams,
|
|
});
|
|
|
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
|
page,
|
|
size: currentPageSize,
|
|
search: mergedSearchParams,
|
|
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
|
});
|
|
|
|
console.log("✅ 데이터 조회 완료:", {
|
|
tableName: component.tableName,
|
|
dataLength: result.data.length,
|
|
total: result.total,
|
|
page: result.page,
|
|
});
|
|
|
|
setData(result.data);
|
|
setTotal(result.total);
|
|
setTotalPages(result.totalPages);
|
|
setCurrentPage(result.page);
|
|
|
|
// 카테고리 코드 패턴(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);
|
|
});
|
|
} catch (error) {
|
|
// console.error("❌ 테이블 데이터 조회 실패:", error);
|
|
setData([]);
|
|
setTotal(0);
|
|
setTotalPages(1);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[component.tableName, component.pagination?.pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter],
|
|
);
|
|
|
|
// 🆕 전역 테이블 새로고침 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleRefreshTable = () => {
|
|
if (component.tableName) {
|
|
loadData(currentPage, searchValues);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("refreshTable", handleRefreshTable);
|
|
|
|
return () => {
|
|
window.removeEventListener("refreshTable", handleRefreshTable);
|
|
};
|
|
}, [currentPage, searchValues, loadData, component.tableName]);
|
|
|
|
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
|
|
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
|
|
filterColumn: string;
|
|
filterValue: any;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
|
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
|
|
|
// 이 테이블이 대상 테이블인지 확인
|
|
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]);
|
|
|
|
// TableOptionsContext 필터 변경 시 데이터 재조회 (TableSearchWidget 연동)
|
|
const filtersAppliedRef = useRef(false);
|
|
useEffect(() => {
|
|
// 초기 렌더 시 빈 배열은 무시 (불필요한 재조회 방지)
|
|
if (!filtersAppliedRef.current && filters.length === 0) return;
|
|
filtersAppliedRef.current = true;
|
|
|
|
const filterSearchParams: Record<string, any> = {};
|
|
filters.forEach((f) => {
|
|
if (f.value !== "" && f.value !== undefined && f.value !== null) {
|
|
filterSearchParams[f.columnName] = f.value;
|
|
}
|
|
});
|
|
loadData(1, { ...searchValues, ...filterSearchParams });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [filters]);
|
|
|
|
// 카테고리 타입 컬럼의 값 매핑 로드
|
|
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}&includeInactive=true` : "?includeInactive=true";
|
|
const response = await apiClient.get(
|
|
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
|
|
);
|
|
|
|
if (response.data.success && response.data.data) {
|
|
// valueCode 및 valueId -> {label, color} 매핑 생성
|
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
|
response.data.data.forEach((item: any) => {
|
|
// valueCode로 매핑
|
|
if (item.valueCode) {
|
|
mapping[item.valueCode] = {
|
|
label: item.valueLabel,
|
|
color: item.color,
|
|
};
|
|
}
|
|
// valueId로도 매핑 (숫자 ID 저장 시 라벨 표시용)
|
|
if (item.valueId !== undefined && item.valueId !== null) {
|
|
mapping[String(item.valueId)] = {
|
|
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],
|
|
);
|
|
|
|
// 이미지 미리보기 핸들러들
|
|
const handlePreviewImage = useCallback((fileInfo: FileInfo) => {
|
|
setPreviewImage(fileInfo);
|
|
setShowPreviewModal(true);
|
|
setZoom(1);
|
|
setRotation(0);
|
|
setImageLoadError(false);
|
|
setAlternativeImageUrl(null);
|
|
}, []);
|
|
|
|
const closePreviewModal = useCallback(() => {
|
|
setShowPreviewModal(false);
|
|
setPreviewImage(null);
|
|
setZoom(1);
|
|
setRotation(0);
|
|
setImageLoadError(false);
|
|
setAlternativeImageUrl(null);
|
|
}, []);
|
|
|
|
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];
|
|
}, []);
|
|
|
|
// 이미지 로딩 실패 시 대체 URL 시도
|
|
const handleImageError = useCallback(() => {
|
|
if (!imageLoadError && previewImage) {
|
|
// console.error("이미지 로딩 실패:", previewImage);
|
|
setImageLoadError(true);
|
|
|
|
// 대체 URL 생성 (직접 파일 경로 사용)
|
|
if (previewImage.path) {
|
|
const altUrl = getDirectFileUrl(previewImage.path);
|
|
setAlternativeImageUrl(altUrl);
|
|
} else {
|
|
toast.error("이미지를 불러올 수 없습니다.");
|
|
}
|
|
} else {
|
|
toast.error("이미지를 불러올 수 없습니다.");
|
|
}
|
|
}, [imageLoadError, previewImage]);
|
|
const [showFileModal, setShowFileModal] = useState(false);
|
|
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
|
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// 추가/수정 모달 상태
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [addFormData, setAddFormData] = useState<Record<string, any>>({});
|
|
const [editFormData, setEditFormData] = useState<Record<string, any>>({});
|
|
const [editingRowData, setEditingRowData] = useState<Record<string, any> | null>(null);
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
// 현재 사용자 정보
|
|
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
|
|
|
|
// 테이블 컬럼 타입 정보 (웹 타입 포함)
|
|
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
|
|
|
// 파일 업로드 관련 상태
|
|
const [uploadingFiles, setUploadingFiles] = useState<Record<string, boolean>>({});
|
|
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File[]>>({});
|
|
|
|
// 검색 가능한 컬럼만 필터링
|
|
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
|
|
|
// 컬럼의 실제 웹 타입 정보 찾기 (webType 또는 input_type)
|
|
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);
|
|
|
|
// input_type 우선 사용 (category 등)
|
|
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
|
|
if (inputType) {
|
|
return inputType;
|
|
}
|
|
|
|
// 없으면 webType 사용
|
|
return tableColumn?.webType || "text";
|
|
},
|
|
[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],
|
|
);
|
|
|
|
// 컬럼의 코드 카테고리 가져오기
|
|
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],
|
|
);
|
|
|
|
// 그리드 컬럼 계산
|
|
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]);
|
|
|
|
// 현재 사용자 정보 로드
|
|
useEffect(() => {
|
|
const fetchCurrentUser = async () => {
|
|
try {
|
|
const response = await getCurrentUser();
|
|
if (response.success && response.data) {
|
|
setCurrentUser(response.data);
|
|
}
|
|
} catch (error) {
|
|
// console.error("현재 사용자 정보 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
fetchCurrentUser();
|
|
}, []);
|
|
|
|
// 파일 상태 새로고침 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleRefreshFileStatus = async (event: CustomEvent) => {
|
|
const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail;
|
|
|
|
// 현재 테이블과 일치하는지 확인
|
|
if (tableName === component.tableName) {
|
|
// 해당 행의 파일 상태 업데이트
|
|
const columnKey = `${recordId}_${columnName}`;
|
|
setFileStatusMap((prev) => ({
|
|
...prev,
|
|
[recordId]: { hasFiles: fileCount > 0, fileCount },
|
|
[columnKey]: { hasFiles: fileCount > 0, fileCount },
|
|
}));
|
|
}
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("refreshFileStatus", handleRefreshFileStatus as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener("refreshFileStatus", handleRefreshFileStatus as EventListener);
|
|
};
|
|
}
|
|
}, [component.tableName]);
|
|
|
|
// 테이블 컬럼 정보 로드 (웹 타입 정보 포함)
|
|
useEffect(() => {
|
|
const fetchTableColumns = async () => {
|
|
try {
|
|
const columns = await tableTypeApi.getColumns(component.tableName);
|
|
setTableColumns(columns);
|
|
|
|
// 🆕 전체 컬럼 목록 설정
|
|
const columnNames = columns.map((col) => col.columnName);
|
|
setAllAvailableColumns(columnNames);
|
|
|
|
// 🆕 컬럼명 -> 라벨 매핑 생성
|
|
const labels: Record<string, string> = {};
|
|
columns.forEach((col) => {
|
|
labels[col.columnName] = col.displayName || col.columnName;
|
|
});
|
|
setColumnLabels(labels);
|
|
|
|
// 🆕 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);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// console.error("테이블 컬럼 정보 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
if (component.tableName) {
|
|
fetchTableColumns();
|
|
}
|
|
}, [component.tableName, component.componentId, user?.userId]);
|
|
|
|
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
|
|
const searchFilters = useMemo(() => {
|
|
return component.filters || [];
|
|
}, [component.filters]);
|
|
|
|
// 초기 데이터 로드
|
|
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],
|
|
);
|
|
|
|
// 행 선택 핸들러
|
|
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] 좌측 선택 데이터 초기화");
|
|
}
|
|
}
|
|
},
|
|
[data, splitPanelContext, splitPanelPosition],
|
|
);
|
|
|
|
// 전체 선택/해제 핸들러
|
|
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;
|
|
|
|
if (!screenId) {
|
|
toast.error("화면 설정이 필요합니다. 테이블 설정에서 추가 모달 화면을 지정해주세요.");
|
|
return;
|
|
}
|
|
|
|
// SaveModal 사용
|
|
setSaveModalData(undefined); // undefined = 등록 모드
|
|
setSaveModalScreenId(screenId);
|
|
setShowSaveModal(true);
|
|
}, [getDisplayColumns, generateAutoValue, component.addModalConfig]);
|
|
|
|
// 데이터 수정 핸들러 (EditModal 사용)
|
|
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;
|
|
}
|
|
|
|
// 수정할 데이터로 폼 초기화
|
|
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,
|
|
menuObjid, // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
|
onSave: () => {
|
|
loadData(); // 테이블 데이터 새로고침
|
|
},
|
|
},
|
|
});
|
|
window.dispatchEvent(event);
|
|
}, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData, menuObjid]);
|
|
|
|
// 수정 폼 데이터 변경 핸들러
|
|
const handleEditFormChange = useCallback((columnName: string, value: any) => {
|
|
setEditFormData((prev) => ({
|
|
...prev,
|
|
[columnName]: value,
|
|
}));
|
|
}, []);
|
|
|
|
const handleAddFormChange = useCallback((columnName: string, value: any) => {
|
|
setAddFormData((prev) => ({
|
|
...prev,
|
|
[columnName]: value,
|
|
}));
|
|
}, []);
|
|
|
|
// 파일 업로드 핸들러
|
|
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);
|
|
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-muted p-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="text-muted-foreground text-xs">📄</div>
|
|
<div>
|
|
<p className="text-sm font-medium">{file.name}</p>
|
|
<p className="text-xs text-muted-foreground">{(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">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span className="text-primary text-sm">업로드 중...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
[uploadedFiles, uploadingFiles, handleFileRemove],
|
|
);
|
|
|
|
// 데이터 추가 제출 핸들러
|
|
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);
|
|
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);
|
|
alert("데이터 수정에 실패했습니다.");
|
|
} finally {
|
|
setIsEditing(false);
|
|
}
|
|
}, [editFormData, editingRowData, component.tableName, currentPage, searchValues, loadData]);
|
|
|
|
// 추가 모달 닫기 핸들러
|
|
const handleAddModalClose = useCallback(() => {
|
|
if (!isAdding) {
|
|
setShowAddModal(false);
|
|
setAddFormData({});
|
|
setUploadedFiles({}); // 파일 상태 초기화
|
|
}
|
|
}, [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;
|
|
});
|
|
}, []);
|
|
|
|
// 데이터 삭제 핸들러
|
|
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);
|
|
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];
|
|
|
|
// 데이터베이스에서 실제 웹 타입 가져오기
|
|
const actualWebType = getColumnWebType(column.columnName);
|
|
const detailSettings = getColumnDetailSettings(column.columnName);
|
|
|
|
// 자동 생성 필드는 수정에서 읽기 전용으로 처리
|
|
if (advancedConfig?.inputType === "auto") {
|
|
return (
|
|
<div className="relative">
|
|
<Input
|
|
value={value}
|
|
readOnly
|
|
className="bg-muted text-foreground"
|
|
placeholder={`${column.label} (자동 생성됨)`}
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">자동 생성된 필드는 수정할 수 없습니다.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 읽기 전용 필드
|
|
if (advancedConfig?.inputType === "readonly") {
|
|
return (
|
|
<div className="relative">
|
|
<Input
|
|
value={value}
|
|
readOnly
|
|
className="bg-muted text-foreground"
|
|
placeholder={advancedConfig?.placeholder || `${column.label} (읽기 전용)`}
|
|
/>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{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" : "",
|
|
};
|
|
|
|
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
|
|
switch (actualWebType) {
|
|
case "text":
|
|
case "email":
|
|
case "tel":
|
|
return (
|
|
<div>
|
|
<Input
|
|
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
|
|
{...commonProps}
|
|
maxLength={detailSettings?.maxLength}
|
|
/>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "number":
|
|
case "decimal":
|
|
return (
|
|
<div>
|
|
<Input
|
|
type="number"
|
|
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
|
|
min={detailSettings?.min}
|
|
max={detailSettings?.max}
|
|
{...commonProps}
|
|
/>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "date":
|
|
return (
|
|
<div>
|
|
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "datetime":
|
|
return (
|
|
<div>
|
|
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "select":
|
|
case "dropdown":
|
|
// 🆕 연쇄 드롭다운 처리
|
|
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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 상세 설정에서 옵션 목록 가져오기
|
|
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-muted-foreground">{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-muted-foreground">{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-muted-foreground">{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-muted-foreground">{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-muted-foreground">({detailSettings.accept})</span>}
|
|
</div>
|
|
{renderFileList(column.columnName, true)}
|
|
</div>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "category": {
|
|
// 카테고리 셀렉트 (동적 import)
|
|
const {
|
|
CategorySelectComponent,
|
|
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
|
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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return (
|
|
<div>
|
|
<Input {...commonProps} />
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{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];
|
|
|
|
// 데이터베이스에서 실제 웹 타입 가져오기
|
|
const actualWebType = getColumnWebType(column.columnName);
|
|
const detailSettings = getColumnDetailSettings(column.columnName);
|
|
|
|
// 읽기 전용 또는 자동 값인 경우
|
|
if (advancedConfig?.inputType === "readonly" || advancedConfig?.inputType === "auto") {
|
|
return (
|
|
<div className="relative">
|
|
<Input
|
|
value={value}
|
|
readOnly
|
|
className="bg-muted text-foreground"
|
|
placeholder={advancedConfig?.placeholder || `${column.label} (자동 생성)`}
|
|
/>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{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" : "",
|
|
};
|
|
|
|
// 실제 웹 타입에 따라 입력 컴포넌트 렌더링
|
|
switch (actualWebType) {
|
|
case "text":
|
|
case "email":
|
|
case "tel":
|
|
return (
|
|
<div>
|
|
<Input
|
|
type={actualWebType === "email" ? "email" : actualWebType === "tel" ? "tel" : "text"}
|
|
{...commonProps}
|
|
maxLength={detailSettings?.maxLength}
|
|
/>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "number":
|
|
case "decimal":
|
|
return (
|
|
<div>
|
|
<Input
|
|
type="number"
|
|
step={actualWebType === "decimal" ? detailSettings?.step || "0.01" : "1"}
|
|
min={detailSettings?.min}
|
|
max={detailSettings?.max}
|
|
{...commonProps}
|
|
/>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "date":
|
|
return (
|
|
<div>
|
|
<Input type="date" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "datetime":
|
|
return (
|
|
<div>
|
|
<Input type="datetime-local" min={detailSettings?.minDate} max={detailSettings?.maxDate} {...commonProps} />
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "select":
|
|
case "dropdown":
|
|
// 🆕 연쇄 드롭다운 처리
|
|
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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 상세 설정에서 옵션 목록 가져오기
|
|
const optionsAdd = detailSettings?.options || [];
|
|
if (optionsAdd.length > 0) {
|
|
return (
|
|
<div>
|
|
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
|
<SelectTrigger className={commonProps.className}>
|
|
<SelectValue placeholder={commonProps.placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{optionsAdd.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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
} else {
|
|
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
|
}
|
|
|
|
case "radio":
|
|
// 상세 설정에서 옵션 목록 가져오기
|
|
const radioOptionsAdd = detailSettings?.options || [];
|
|
const defaultValueAdd = detailSettings?.defaultValue;
|
|
|
|
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
|
if (radioOptionsAdd.length > 0) {
|
|
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
|
if (!value && defaultValueAdd) {
|
|
setTimeout(() => handleAddFormChange(column.columnName, defaultValueAdd), 0);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="space-y-2">
|
|
{radioOptionsAdd.map((option: any, index: number) => (
|
|
<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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
} else {
|
|
return <Input {...commonProps} placeholder={`${column.label} 선택... (옵션 설정 필요)`} readOnly />;
|
|
}
|
|
|
|
case "boolean":
|
|
case "checkbox":
|
|
return (
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
checked={value === true || value === "true" || value === 1}
|
|
onCheckedChange={(checked) => handleAddFormChange(column.columnName, checked)}
|
|
/>
|
|
<Label>{column.label}</Label>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
} else {
|
|
return (
|
|
<div>
|
|
<Input {...commonProps} placeholder={`${column.label} (코드 카테고리 설정 필요)`} readOnly />
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{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, 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-muted-foreground">({detailSettings.accept})</span>}
|
|
</div>
|
|
{renderFileList(column.columnName, false)}
|
|
</div>
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
|
|
case "category": {
|
|
// 카테고리 셀렉트 (동적 import)
|
|
const {
|
|
CategorySelectComponent,
|
|
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
|
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-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return (
|
|
<div>
|
|
<Input {...commonProps} />
|
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-muted-foreground">{advancedConfig.helpText}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 기존 renderSearchFilter 함수는 AdvancedSearchFilters 컴포넌트로 대체됨
|
|
|
|
// 파일 다운로드
|
|
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
|
try {
|
|
// savedFileName이 없는 경우 파일 경로에서 추출 시도
|
|
const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null);
|
|
|
|
if (!serverFilename) {
|
|
// _file 속성이 있는 경우 로컬 파일로 다운로드
|
|
if ((fileInfo as any)._file) {
|
|
try {
|
|
const file = (fileInfo as any)._file;
|
|
|
|
// File 객체 유효성 검사
|
|
if (!(file instanceof File) && !(file instanceof Blob)) {
|
|
// console.error("❌ 잘못된 파일 객체:", file);
|
|
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);
|
|
toast.error("로컬 파일 다운로드에 실패했습니다. 파일을 다시 업로드해주세요.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
toast.error("이 파일은 이전 버전에서 저장된 파일입니다. 파일을 다시 업로드해주세요.");
|
|
return;
|
|
}
|
|
|
|
toast.loading(`${fileInfo.name} 다운로드 중...`);
|
|
|
|
await downloadFile({
|
|
fileId: fileInfo.objid || fileInfo.id,
|
|
serverFilename: serverFilename,
|
|
originalName: fileInfo.name,
|
|
});
|
|
|
|
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
|
} catch (error) {
|
|
// console.error("파일 다운로드 오류:", error);
|
|
toast.error(`${fileInfo.name} 다운로드에 실패했습니다.`);
|
|
}
|
|
}, []);
|
|
|
|
// 🗑️ 연결된 파일 삭제 함수
|
|
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) {
|
|
// 파일 목록 새로고침 실패 시 무시
|
|
}
|
|
}
|
|
} catch (error) {
|
|
toast.error(`"${fileName}" 파일 삭제에 실패했습니다.`);
|
|
}
|
|
},
|
|
[showFileManagementModal, selectedRowForFiles, component.tableName],
|
|
);
|
|
|
|
// 셀 값 포맷팅
|
|
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);
|
|
|
|
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
|
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
|
|
|
// 🖼️ 이미지 타입 컬럼: 썸네일로 표시
|
|
const isImageColumn = actualWebType === "image" || actualWebType === "img";
|
|
if (isImageColumn && value) {
|
|
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
|
|
// 🔑 download 대신 preview 사용 (공개 접근 허용)
|
|
const isObjid = /^\d+$/.test(String(value));
|
|
// 🔑 상대 경로(/api/...) 대신 전체 URL 사용 (Docker 환경에서 Next.js rewrite 의존 방지)
|
|
const imageUrl = isObjid
|
|
? getFilePreviewUrl(String(value))
|
|
: getFullImageUrl(String(value));
|
|
|
|
return (
|
|
<div className="flex justify-center">
|
|
<img
|
|
src={imageUrl}
|
|
alt="이미지"
|
|
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
|
|
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
// 이미지 클릭 시 크게 보기 (새 탭에서 열기)
|
|
window.open(imageUrl, "_blank");
|
|
}}
|
|
onError={(e) => {
|
|
// 이미지 로드 실패 시 기본 아이콘 표시
|
|
(e.target as HTMLImageElement).style.display = "none";
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
|
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;
|
|
|
|
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-primary text-[10px] text-white">
|
|
{fileCount > 9 ? "9+" : fileCount}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Folder className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
|
|
switch (actualWebType) {
|
|
case "category": {
|
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
|
if (!value) return "";
|
|
|
|
const mapping = categoryMappings[column.columnName];
|
|
const categoryData = mapping?.[String(value)];
|
|
|
|
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
|
|
const displayLabel = categoryData?.label || String(value);
|
|
const displayColor = categoryData?.color;
|
|
|
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
|
return <span className="text-sm">{displayLabel}</span>;
|
|
}
|
|
|
|
return (
|
|
<Badge
|
|
style={{
|
|
backgroundColor: displayColor,
|
|
borderColor: displayColor,
|
|
}}
|
|
className="text-white"
|
|
>
|
|
{displayLabel}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
break;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
return String(value);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex h-full flex-col rounded-xl border border-border/60 bg-background shadow-sm",
|
|
className,
|
|
)}
|
|
style={{ ...style, minHeight: "680px" }}
|
|
>
|
|
{/* 헤더 */}
|
|
<div className="p-6 pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<Database className="text-muted-foreground h-4 w-4" />
|
|
<h3 className="text-lg font-semibold">{component.title || component.label}</h3>
|
|
{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">
|
|
{/* 선택된 행 개수 표시 */}
|
|
{selectedRows.size > 0 && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{selectedRows.size}개 선택됨
|
|
</Badge>
|
|
)}
|
|
|
|
{searchFilters.length > 0 && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<Search className="mr-1 h-3 w-3" />
|
|
필터 {searchFilters.length}개
|
|
</Badge>
|
|
)}
|
|
|
|
{/* CRUD 버튼들 */}
|
|
{component.enableAdd && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
if (isPreviewMode) {
|
|
return;
|
|
}
|
|
handleAddData();
|
|
}}
|
|
disabled={loading || isPreviewMode}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{component.addButtonText || "추가"}
|
|
</Button>
|
|
)}
|
|
|
|
{component.enableEdit && selectedRows.size === 1 && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
if (isPreviewMode) {
|
|
return;
|
|
}
|
|
handleEditData();
|
|
}}
|
|
disabled={loading || isPreviewMode}
|
|
className="gap-2"
|
|
variant="outline"
|
|
>
|
|
<Edit className="h-3 w-3" />
|
|
{component.editButtonText || "수정"}
|
|
</Button>
|
|
)}
|
|
|
|
{component.enableDelete && selectedRows.size > 0 && (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => {
|
|
if (isPreviewMode) {
|
|
return;
|
|
}
|
|
handleDeleteData();
|
|
}}
|
|
disabled={loading || isPreviewMode}
|
|
className="gap-2"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
{component.deleteButtonText || "삭제"}
|
|
</Button>
|
|
)}
|
|
|
|
{component.showSearchButton && (
|
|
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
|
|
<Search className="h-3 w-3" />
|
|
{component.searchButtonText || "검색"}
|
|
</Button>
|
|
)}
|
|
|
|
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
|
|
<RotateCcw className="h-3 w-3" />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
|
|
{tableColumns && tableColumns.length > 0 && (
|
|
<>
|
|
<Separator className="my-2" />
|
|
<AdvancedSearchFilters
|
|
filters={searchFilters.length > 0 ? searchFilters : []}
|
|
searchValues={searchValues}
|
|
onSearchValueChange={handleSearchValueChange}
|
|
onSearch={handleSearch}
|
|
onClearFilters={() => {
|
|
setSearchValues({});
|
|
loadData(1, {});
|
|
}}
|
|
tableColumns={tableColumns}
|
|
tableName={component.tableName}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 테이블 내용 */}
|
|
<div className="flex-1 p-0">
|
|
<div className="flex h-full flex-col">
|
|
{visibleColumns.length > 0 ? (
|
|
<>
|
|
<div className="overflow-hidden rounded-lg border border-border/60 bg-white shadow-sm">
|
|
<Table style={{ tableLayout: "fixed" }}>
|
|
<TableHeader className="bg-muted/50 border-primary/20 border-b-2">
|
|
<TableRow>
|
|
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
|
{component.enableDelete && (
|
|
<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];
|
|
|
|
return (
|
|
<TableHead
|
|
key={column.id}
|
|
ref={(el) => (columnRefs.current[column.id] = el)}
|
|
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,
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
{column.label}
|
|
{/* 리사이즈 핸들 */}
|
|
{columnIndex < visibleColumns.length - 1 && (
|
|
<div
|
|
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-primary"
|
|
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const thElement = columnRefs.current[column.id];
|
|
if (!thElement) return;
|
|
|
|
isResizingRef.current = true;
|
|
|
|
const startX = e.clientX;
|
|
const startWidth = columnWidth || thElement.offsetWidth;
|
|
|
|
// 드래그 중 텍스트 선택 방지
|
|
document.body.style.userSelect = "none";
|
|
document.body.style.cursor = "col-resize";
|
|
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
moveEvent.preventDefault();
|
|
|
|
const diff = moveEvent.clientX - startX;
|
|
const newWidth = Math.max(80, startWidth + diff);
|
|
|
|
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
|
if (thElement) {
|
|
thElement.style.width = `${newWidth}px`;
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
// 최종 너비를 state에 저장
|
|
if (thElement) {
|
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
|
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth }));
|
|
}
|
|
|
|
// 텍스트 선택 복원
|
|
document.body.style.userSelect = "";
|
|
document.body.style.cursor = "";
|
|
|
|
// 약간의 지연 후 리사이즈 플래그 해제
|
|
setTimeout(() => {
|
|
isResizingRef.current = false;
|
|
}, 100);
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
}}
|
|
/>
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
{/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
|
</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>
|
|
</TableRow>
|
|
) : data.length > 0 ? (
|
|
data.map((row, rowIndex) => (
|
|
<TableRow key={rowIndex} className="transition-colors duration-150 hover:bg-muted/50">
|
|
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
|
{component.enableDelete && (
|
|
<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 (
|
|
<TableCell
|
|
key={column.id}
|
|
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-foreground"
|
|
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 opacity-40" />
|
|
<p>검색 결과가 없습니다</p>
|
|
<p className="text-xs">검색 조건을 변경하거나 새로고침을 시도해보세요</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
{component.pagination?.enabled && totalPages > 1 && (
|
|
<div className="mt-auto border-t border-border/60 bg-muted/30">
|
|
<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 opacity-40" />
|
|
<p className="text-sm">표시할 컬럼이 없습니다</p>
|
|
<p className="text-xs">테이블 설정에서 컬럼을 추가해주세요</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* SaveModal (등록/수정 통합) */}
|
|
<SaveModal
|
|
isOpen={showSaveModal}
|
|
onClose={() => {
|
|
setShowSaveModal(false);
|
|
setSaveModalData(undefined);
|
|
setSaveModalScreenId(undefined);
|
|
}}
|
|
screenId={saveModalScreenId}
|
|
modalSize={component.addModalConfig?.modalSize || "lg"}
|
|
initialData={saveModalData}
|
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
|
onSaveSuccess={() => {
|
|
// 저장 성공 시 테이블 새로고침
|
|
loadData(currentPage, searchValues); // 현재 페이지로 다시 로드
|
|
setSelectedRows(new Set()); // 선택 해제
|
|
}}
|
|
/>
|
|
|
|
{/* 기존 데이터 추가 모달 (제거 예정 - SaveModal로 대체됨) */}
|
|
<Dialog open={false} onOpenChange={() => {}}>
|
|
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
|
|
<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-amber-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={() => {}}>
|
|
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
|
|
<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-amber-500">*</span>}
|
|
</Label>
|
|
<div>{renderEditFormInput(column)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowEditModal(false);
|
|
setEditFormData({});
|
|
setEditingRowData(null);
|
|
setUploadedFiles({}); // 파일 상태 초기화
|
|
}}
|
|
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>
|
|
|
|
{/* 파일 정보 모달 */}
|
|
<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-muted p-4 transition-colors hover:bg-muted">
|
|
<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-emerald-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-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 파일 정보 */}
|
|
<div className="min-w-0 flex-1">
|
|
<h4 className="truncate font-medium text-foreground" title={fileInfo.name}>
|
|
{fileInfo.name}
|
|
</h4>
|
|
<div className="text-muted-foreground mt-1 space-y-1 text-sm">
|
|
<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">
|
|
{fileInfo.regdate && (
|
|
<span>등록일: {new Date(fileInfo.regdate).toLocaleString("ko-KR")}</span>
|
|
)}
|
|
{fileInfo.writer && <span>등록자: {fileInfo.writer}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 액션 버튼 */}
|
|
<div className="flex flex-col gap-2">
|
|
{isImage && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => handlePreviewImage(fileInfo)}
|
|
>
|
|
<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">
|
|
<h5 className="mb-2 font-medium text-primary">파일 요약</h5>
|
|
<div className="grid grid-cols-2 gap-4 text-sm text-primary">
|
|
<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>
|
|
|
|
{/* 데이터 삭제 확인 다이얼로그 */}
|
|
<Dialog open={showDeleteDialog} onOpenChange={handleDeleteDialogClose}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>데이터 삭제 확인</DialogTitle>
|
|
<DialogDescription>
|
|
선택된 <strong>{selectedRows.size}개</strong>의 데이터를 삭제하시겠습니까?
|
|
<br />
|
|
<span className="text-destructive">이 작업은 되돌릴 수 없습니다.</span>
|
|
</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>
|
|
|
|
{/* 이미지 미리보기 다이얼로그 */}
|
|
<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-muted-foreground">{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-muted p-4">
|
|
{previewImage && (
|
|
<img
|
|
src={alternativeImageUrl || getFilePreviewUrl(previewImage.id)}
|
|
alt={previewImage.name}
|
|
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
|
style={{
|
|
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
|
}}
|
|
onError={handleImageError}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{previewImage && (
|
|
<div className="flex items-center justify-between border-t pt-3 text-sm text-muted-foreground">
|
|
<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}>
|
|
<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-foreground">연결된 파일</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-muted-foreground">
|
|
{(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" />
|
|
</Button>
|
|
|
|
{/* 🗑️ 파일 삭제 버튼 */}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleDeleteLinkedFile(file.objid, file.realFileName)}
|
|
className="hover:bg-destructive/10 text-destructive hover:text-destructive"
|
|
>
|
|
<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-foreground">{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>
|
|
</div>
|
|
);
|
|
};
|