diff --git a/.cursor/rules/table-list-component-guide.mdc b/.cursor/rules/table-list-component-guide.mdc new file mode 100644 index 00000000..5d3f0e1f --- /dev/null +++ b/.cursor/rules/table-list-component-guide.mdc @@ -0,0 +1,310 @@ +# TableListComponent 개발 가이드 + +## 개요 + +`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다. + +**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx` + +--- + +## 핵심 기능 목록 + +### 1. 인라인 편집 (Inline Editing) + +- 셀 더블클릭 또는 F2 키로 편집 모드 진입 +- 직접 타이핑으로도 편집 모드 진입 가능 +- Enter로 저장, Escape로 취소 +- **컬럼별 편집 가능 여부 설정** (`editable` 속성) + +```typescript +// ColumnConfig에서 editable 속성 사용 +interface ColumnConfig { + editable?: boolean; // false면 해당 컬럼 인라인 편집 불가 +} +``` + +**편집 불가 컬럼 체크 필수 위치**: +1. `handleCellDoubleClick` - 더블클릭 편집 +2. `onKeyDown` F2 케이스 - 키보드 편집 +3. `onKeyDown` default 케이스 - 직접 타이핑 편집 +4. 컨텍스트 메뉴 "셀 편집" 옵션 + +### 2. 배치 편집 (Batch Editing) + +- 여러 셀 수정 후 일괄 저장/취소 +- `pendingChanges` Map으로 변경사항 추적 +- 저장 전 유효성 검증 + +### 3. 데이터 유효성 검증 (Validation) + +```typescript +type ValidationRule = { + required?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; +}; +``` + +### 4. 컬럼 헤더 필터 (Header Filter) + +- 각 컬럼 헤더에 필터 아이콘 +- 고유값 목록에서 다중 선택 필터링 +- `headerFilters` Map으로 필터 상태 관리 + +### 5. 필터 빌더 (Filter Builder) + +```typescript +interface FilterCondition { + id: string; + column: string; + operator: "equals" | "notEquals" | "contains" | "notContains" | + "startsWith" | "endsWith" | "greaterThan" | "lessThan" | + "greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty"; + value: string; +} + +interface FilterGroup { + id: string; + logic: "AND" | "OR"; + conditions: FilterCondition[]; +} +``` + +### 6. 검색 패널 (Search Panel) + +- 전체 데이터 검색 +- 검색어 하이라이팅 +- `searchHighlights` Map으로 하이라이트 위치 관리 + +### 7. 엑셀 내보내기 (Excel Export) + +- `xlsx` 라이브러리 사용 +- 현재 표시 데이터 또는 전체 데이터 내보내기 + +```typescript +import * as XLSX from "xlsx"; + +// 사용 예시 +const worksheet = XLSX.utils.json_to_sheet(exportData); +const workbook = XLSX.utils.book_new(); +XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1"); +XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`); +``` + +### 8. 클립보드 복사 (Copy to Clipboard) + +- 선택된 행 또는 전체 데이터 복사 +- 탭 구분자로 엑셀 붙여넣기 호환 + +### 9. 컨텍스트 메뉴 (Context Menu) + +- 우클릭으로 메뉴 표시 +- 셀 편집, 행 복사, 행 삭제 등 옵션 +- 편집 불가 컬럼은 "(잠김)" 표시 + +### 10. 키보드 네비게이션 + +| 키 | 동작 | +|---|---| +| Arrow Keys | 셀 이동 | +| Tab | 다음 셀 | +| Shift+Tab | 이전 셀 | +| F2 | 편집 모드 | +| Enter | 저장 후 아래로 이동 | +| Escape | 편집 취소 | +| Ctrl+C | 복사 | +| Delete | 셀 값 삭제 | + +### 11. 컬럼 리사이징 + +- 컬럼 헤더 경계 드래그로 너비 조절 +- `columnWidths` 상태로 관리 +- localStorage에 저장 + +### 12. 컬럼 순서 변경 + +- 드래그 앤 드롭으로 컬럼 순서 변경 +- `columnOrder` 상태로 관리 +- localStorage에 저장 + +### 13. 상태 영속성 (State Persistence) + +```typescript +// localStorage 키 패턴 +const stateKey = `tableState_${tableName}_${userId}`; + +// 저장되는 상태 +interface TableState { + columnWidths: Record; + columnOrder: string[]; + sortBy: string; + sortOrder: "asc" | "desc"; + frozenColumns: string[]; + columnVisibility: Record; +} +``` + +### 14. 그룹화 및 그룹 소계 + +```typescript +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; + summary?: Record; +} +``` + +### 15. 총계 요약 (Total Summary) + +- 숫자 컬럼의 합계, 평균, 개수 표시 +- 테이블 하단에 요약 행 렌더링 + +--- + +## 캐싱 전략 + +```typescript +// 테이블 컬럼 캐시 +const tableColumnCache = new Map(); +const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 + +// API 호출 디바운싱 +const debouncedApiCall = ( + key: string, + fn: (...args: T) => Promise, + delay: number = 300 +) => { ... }; +``` + +--- + +## 필수 Import + +```typescript +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { TableListConfig, ColumnConfig } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { codeCache } from "@/lib/caching/codeCache"; +import * as XLSX from "xlsx"; +import { toast } from "sonner"; +``` + +--- + +## 주요 상태 (State) + +```typescript +// 데이터 관련 +const [tableData, setTableData] = useState([]); +const [filteredData, setFilteredData] = useState([]); +const [loading, setLoading] = useState(false); + +// 편집 관련 +const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; +} | null>(null); +const [editingValue, setEditingValue] = useState(""); +const [pendingChanges, setPendingChanges] = useState>>(new Map()); +const [validationErrors, setValidationErrors] = useState>>(new Map()); + +// 필터 관련 +const [headerFilters, setHeaderFilters] = useState>>(new Map()); +const [filterGroups, setFilterGroups] = useState([]); +const [globalSearchText, setGlobalSearchText] = useState(""); +const [searchHighlights, setSearchHighlights] = useState>(new Map()); + +// 컬럼 관련 +const [columnWidths, setColumnWidths] = useState>({}); +const [columnOrder, setColumnOrder] = useState([]); +const [columnVisibility, setColumnVisibility] = useState>({}); +const [frozenColumns, setFrozenColumns] = useState([]); + +// 선택 관련 +const [selectedRows, setSelectedRows] = useState>(new Set()); +const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + +// 정렬 관련 +const [sortBy, setSortBy] = useState(""); +const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + +// 페이지네이션 +const [currentPage, setCurrentPage] = useState(1); +const [pageSize, setPageSize] = useState(20); +const [totalCount, setTotalCount] = useState(0); +``` + +--- + +## 편집 불가 컬럼 구현 체크리스트 + +새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요: + +- [ ] `column.editable === false` 체크 추가 +- [ ] 편집 불가 시 `toast.warning()` 메시지 표시 +- [ ] `return` 또는 `break`로 편집 모드 진입 방지 + +```typescript +// 표준 편집 불가 체크 패턴 +const column = visibleColumns.find((col) => col.columnName === columnName); +if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); + return; +} +``` + +--- + +## 시각적 표시 + +### 편집 불가 컬럼 표시 + +```tsx +// 헤더에 잠금 아이콘 +{column.editable === false && ( + +)} + +// 셀 배경색 +className={cn( + column.editable === false && "bg-gray-50 dark:bg-gray-900/30" +)} +``` + +--- + +## 성능 최적화 + +1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값 +2. **useCallback 사용**: 이벤트 핸들러 함수들 +3. **디바운싱**: API 호출, 검색, 필터링 +4. **캐싱**: 테이블 컬럼 정보, 코드 데이터 + +--- + +## 주의사항 + +1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함 +2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인 +3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성 +4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리) + +--- + +## 관련 파일 + +- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의 +- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널 +- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달 +- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블 diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index e324c332..a03478b9 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -632,6 +632,9 @@ export class DashboardController { validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) }; + // 연결 정보 (응답에 포함용) + let connectionInfo: { saveToHistory?: boolean } | null = null; + // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 if (externalConnectionId) { try { @@ -652,6 +655,11 @@ export class DashboardController { if (connectionResult.success && connectionResult.data) { const connection = connectionResult.data; + // 연결 정보 저장 (응답에 포함) + connectionInfo = { + saveToHistory: connection.save_to_history === "Y", + }; + // 인증 헤더 생성 (DB 토큰 등) const authHeaders = await ExternalRestApiConnectionService.getAuthHeaders( @@ -709,9 +717,9 @@ export class DashboardController { } // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 - const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + const isKmaApi = urlObj.hostname.includes("kma.go.kr"); if (isKmaApi) { - requestConfig.responseType = 'arraybuffer'; + requestConfig.responseType = "arraybuffer"; } const response = await axios(requestConfig); @@ -727,18 +735,22 @@ export class DashboardController { // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) if (isKmaApi && Buffer.isBuffer(data)) { - const iconv = require('iconv-lite'); + const iconv = require("iconv-lite"); const buffer = Buffer.from(data); - const utf8Text = buffer.toString('utf-8'); - + const utf8Text = buffer.toString("utf-8"); + // UTF-8로 정상 디코딩되었는지 확인 - if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || - (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { - data = { text: utf8Text, contentType, encoding: 'utf-8' }; + if ( + utf8Text.includes("특보") || + utf8Text.includes("경보") || + utf8Text.includes("주의보") || + (utf8Text.includes("#START7777") && !utf8Text.includes("�")) + ) { + data = { text: utf8Text, contentType, encoding: "utf-8" }; } else { // EUC-KR로 디코딩 - const eucKrText = iconv.decode(buffer, 'EUC-KR'); - data = { text: eucKrText, contentType, encoding: 'euc-kr' }; + const eucKrText = iconv.decode(buffer, "EUC-KR"); + data = { text: eucKrText, contentType, encoding: "euc-kr" }; } } // 텍스트 응답인 경우 포맷팅 @@ -749,6 +761,7 @@ export class DashboardController { res.status(200).json({ success: true, data, + connectionInfo, // 외부 연결 정보 (saveToHistory 등) }); } catch (error: any) { const status = error.response?.status || 500; diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 30364189..97cd2cc1 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -492,7 +492,7 @@ export const saveLocationHistory = async ( res: Response ): Promise => { try { - const { companyCode, userId } = req.user as any; + const { companyCode, userId: loginUserId } = req.user as any; const { latitude, longitude, @@ -508,10 +508,17 @@ export const saveLocationHistory = async ( destinationName, recordedAt, vehicleId, + userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등) } = req.body; + // 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등) + // 없으면 로그인한 사용자의 userId 사용 + const userId = requestUserId || loginUserId; + console.log("📍 [saveLocationHistory] 요청:", { userId, + requestUserId, + loginUserId, companyCode, latitude, longitude, diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 2632a6e6..6f0b1239 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService { connection_name, description, base_url, endpoint_path, default_headers, default_method, default_request_body, auth_type, auth_config, timeout, retry_count, retry_delay, - company_code, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + company_code, is_active, created_by, save_to_history + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * `; @@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService { data.company_code || "*", data.is_active || "Y", data.created_by || "system", + data.save_to_history || "N", ]; // 디버깅: 저장하려는 데이터 로깅 @@ -377,6 +378,12 @@ export class ExternalRestApiConnectionService { paramIndex++; } + if (data.save_to_history !== undefined) { + updateFields.push(`save_to_history = $${paramIndex}`); + params.push(data.save_to_history); + paramIndex++; + } + if (data.updated_by !== undefined) { updateFields.push(`updated_by = $${paramIndex}`); params.push(data.updated_by); diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 8d95a4a6..416cbe6f 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -53,6 +53,9 @@ export interface ExternalRestApiConnection { retry_delay?: number; company_code: string; is_active: string; + + // 위치 이력 저장 설정 (지도 위젯용) + save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 created_date?: Date; created_by?: string; updated_date?: Date; diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 95ac6e76..0a9cecd0 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -53,6 +53,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: const [retryCount, setRetryCount] = useState(0); const [retryDelay, setRetryDelay] = useState(1000); const [isActive, setIsActive] = useState(true); + const [saveToHistory, setSaveToHistory] = useState(false); // 위치 이력 저장 설정 // UI 상태 const [showAdvanced, setShowAdvanced] = useState(false); @@ -80,6 +81,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setRetryCount(connection.retry_count || 0); setRetryDelay(connection.retry_delay || 1000); setIsActive(connection.is_active === "Y"); + setSaveToHistory(connection.save_to_history === "Y"); // 테스트 초기값 설정 setTestEndpoint(""); @@ -100,6 +102,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setRetryCount(0); setRetryDelay(1000); setIsActive(true); + setSaveToHistory(false); // 테스트 초기값 설정 setTestEndpoint(""); @@ -234,6 +237,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: retry_delay: retryDelay, // company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정 is_active: isActive ? "Y" : "N", + save_to_history: saveToHistory ? "Y" : "N", }; console.log("저장하려는 데이터:", { @@ -376,6 +380,16 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: 활성 상태 + +
+ + + + (지도 위젯에서 이 API 데이터를 vehicle_location_history에 저장) + +
{/* 헤더 관리 */} diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 86da8fe7..f92e440a 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -850,6 +851,23 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M )} + {/* 위치 이력 저장 설정 (지도 위젯용) */} +
+
+ +

+ REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장합니다 +

+
+ onChange({ saveToHistory: checked })} + /> +
+ {/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */} {testResult?.success && availableColumns.length > 0 && (
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 19599b69..bc52ecb8 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -183,6 +183,9 @@ export interface ChartDataSource { label: string; // 표시할 한글명 (예: 차량 번호) format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷 }[]; + + // REST API 위치 데이터 저장 설정 (MapTestWidgetV2용) + saveToHistory?: boolean; // REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 } export interface ChartConfig { diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 02cafe2b..9b0db43a 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -94,12 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); - + // 이동경로 상태 const [routePoints, setRoutePoints] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); const [routeLoading, setRouteLoading] = useState(false); - const [routeDate, setRouteDate] = useState(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식 + const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { @@ -122,62 +122,59 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }, []); // 이동경로 로드 함수 - const loadRoute = useCallback(async (userId: string, date?: string) => { - if (!userId) { - console.log("🛣️ 이동경로 조회 불가: userId 없음"); - return; - } + const loadRoute = useCallback( + async (userId: string, date?: string) => { + if (!userId) { + return; + } - setRouteLoading(true); - setSelectedUserId(userId); + setRouteLoading(true); + setSelectedUserId(userId); - try { - // 선택한 날짜 기준으로 이동경로 조회 - const targetDate = date || routeDate; - const startOfDay = `${targetDate}T00:00:00.000Z`; - const endOfDay = `${targetDate}T23:59:59.999Z`; - - const query = `SELECT latitude, longitude, recorded_at + try { + // 선택한 날짜 기준으로 이동경로 조회 + const targetDate = date || routeDate; + const startOfDay = `${targetDate}T00:00:00.000Z`; + const endOfDay = `${targetDate}T23:59:59.999Z`; + + const query = `SELECT latitude, longitude, recorded_at FROM vehicle_location_history WHERE user_id = '${userId}' AND recorded_at >= '${startOfDay}' AND recorded_at <= '${endOfDay}' ORDER BY recorded_at ASC`; - console.log("🛣️ 이동경로 쿼리:", query); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, - }, - body: JSON.stringify({ query }), - }); + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const points: RoutePoint[] = result.data.rows.map((row: any) => ({ + lat: parseFloat(row.latitude), + lng: parseFloat(row.longitude), + recordedAt: row.recorded_at, + })); - if (response.ok) { - const result = await response.json(); - if (result.success && result.data.rows.length > 0) { - const points: RoutePoint[] = result.data.rows.map((row: any) => ({ - lat: parseFloat(row.latitude), - lng: parseFloat(row.longitude), - recordedAt: row.recorded_at, - })); - - console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`); - setRoutePoints(points); - } else { - console.log("🛣️ 이동경로 데이터 없음"); - setRoutePoints([]); + setRoutePoints(points); + } else { + setRoutePoints([]); + } } + } catch { + setRoutePoints([]); } - } catch (error) { - console.error("이동경로 로드 실패:", error); - setRoutePoints([]); - } - setRouteLoading(false); - }, [routeDate]); + setRouteLoading(false); + }, + [routeDate], + ); // 이동경로 숨기기 const clearRoute = useCallback(() => { @@ -297,6 +294,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }); } + // Request Body 파싱 + let requestBody: any = undefined; + if (source.body) { + try { + requestBody = JSON.parse(source.body); + } catch { + // JSON 파싱 실패시 문자열 그대로 사용 + requestBody = source.body; + } + } + // 백엔드 프록시를 통해 API 호출 const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", @@ -309,6 +317,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { method: source.method || "GET", headers, queryParams, + body: requestBody, + externalConnectionId: source.externalConnectionId, }), }); @@ -344,14 +354,81 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } } + // 데이터가 null/undefined면 빈 결과 반환 + if (data === null || data === undefined) { + return { markers: [], polygons: [] }; + } + const rows = Array.isArray(data) ? data : [data]; // 컬럼 매핑 적용 const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) - const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); - return finalResult; + const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + + // ✅ REST API 데이터를 vehicle_location_history에 자동 저장 (경로 보기용) + // - 모든 REST API 차량 위치 데이터는 자동으로 저장됨 + if (mapData.markers.length > 0) { + try { + const authToken = typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""; + + // 마커 데이터를 vehicle_location_history에 저장 + for (const marker of mapData.markers) { + // user_id 추출 (마커 description에서 파싱) + let userId = ""; + let vehicleId: number | undefined = undefined; + let vehicleName = ""; + + if (marker.description) { + try { + const parsed = JSON.parse(marker.description); + // 다양한 필드명 지원 (plate_no 우선 - 차량 번호판으로 경로 구분) + userId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || + parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || + parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || + parsed.id || parsed.code || ""; + vehicleId = parsed.vehicle_id || parsed.vehicleId || parsed.car_id || parsed.carId; + vehicleName = parsed.plate_no || parsed.plateNo || parsed.car_name || parsed.carName || + parsed.vehicle_name || parsed.vehicleName || parsed.name || parsed.title || ""; + } catch { + // 파싱 실패 시 무시 + } + } + + // user_id가 없으면 마커 이름이나 ID를 사용 + if (!userId) { + userId = marker.name || marker.id || `marker_${Date.now()}`; + } + + // vehicle_location_history에 저장 + await fetch(getApiUrl("/api/dynamic-form/location-history"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + credentials: "include", + body: JSON.stringify({ + latitude: marker.lat, + longitude: marker.lng, + userId: userId, + vehicleId: vehicleId, + tripStatus: "api_tracking", // REST API에서 가져온 데이터 표시 + departureName: source.name || "REST API", + destinationName: vehicleName || marker.name, + }), + }); + + console.log("📍 [saveToHistory] 저장 완료:", { userId, lat: marker.lat, lng: marker.lng }); + } + } catch (saveError) { + console.error("❌ [saveToHistory] 저장 실패:", saveError); + // 저장 실패해도 마커 표시는 계속 + } + } + + return mapData; }; // Database 데이터 로딩 @@ -485,6 +562,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const polygons: PolygonData[] = []; rows.forEach((row, index) => { + // null/undefined 체크 + if (!row) { + return; + } + // 텍스트 데이터 체크 (기상청 API 등) if (row && typeof row === "object" && row.text && typeof row.text === "string") { const parsedData = parseTextData(row.text); @@ -1098,13 +1180,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }} className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none" /> - - ({routePoints.length}개) - -
@@ -1409,12 +1486,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 트럭 마커 // 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요 const rotation = heading - 90; - + // 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로) // 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함 const normalizedRotation = ((rotation % 360) + 360) % 360; const isFlipped = normalizedRotation > 90 && normalizedRotation < 270; - const transformStyle = isFlipped + const transformStyle = isFlipped ? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)` : `translate(-50%, -50%) rotate(${rotation}deg)`; @@ -1645,18 +1722,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {(() => { try { const parsed = JSON.parse(marker.description || "{}"); - const userId = parsed.user_id; - if (userId) { + // 다양한 필드명 지원 (plate_no 우선) + const visibleUserId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || + parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || + parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || + parsed.id || parsed.code || marker.name; + if (visibleUserId) { return (
); diff --git a/frontend/lib/api/externalRestApiConnection.ts b/frontend/lib/api/externalRestApiConnection.ts index f907ee85..d58545f6 100644 --- a/frontend/lib/api/externalRestApiConnection.ts +++ b/frontend/lib/api/externalRestApiConnection.ts @@ -45,6 +45,9 @@ export interface ExternalRestApiConnection { retry_delay?: number; company_code: string; is_active: string; + + // 위치 이력 저장 설정 (지도 위젯용) + save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 created_date?: Date; created_by?: string; updated_date?: Date; diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 5dc4a165..3f1a723b 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -343,8 +343,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -387,8 +392,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} @@ -419,8 +429,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -451,8 +466,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} @@ -479,8 +499,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -508,8 +533,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 64e6e540..982a7f8c 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -22,7 +22,19 @@ import { X, Layers, ChevronDown, + Filter, + Check, + Download, + FileSpreadsheet, + Copy, + ClipboardCopy, + Edit, + CheckSquare, + Trash2, + Lock, } from "lucide-react"; +import * as XLSX from "xlsx"; +import { FileText, ChevronRightIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -62,6 +74,7 @@ interface GroupedData { groupValues: Record; items: any[]; count: number; + summary?: Record; // 🆕 그룹별 소계 } // ======================================== @@ -125,6 +138,35 @@ const debouncedApiCall = (key: string, fn: (...args: T) => P }; }; +// ======================================== +// Filter Builder 인터페이스 +// ======================================== + +interface FilterCondition { + id: string; + column: string; + operator: + | "equals" + | "notEquals" + | "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "greaterThan" + | "lessThan" + | "greaterOrEqual" + | "lessOrEqual" + | "isEmpty" + | "isNotEmpty"; + value: string; +} + +interface FilterGroup { + id: string; + logic: "AND" | "OR"; + conditions: FilterCondition[]; +} + // ======================================== // Props 인터페이스 // ======================================== @@ -328,28 +370,155 @@ export const TableListComponent: React.FC = ({ } }, [columnVisibility, tableConfig.selectedTable, currentUserId]); + // 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용) + const [columnOrder, setColumnOrder] = useState([]); + + // 🆕 visibleColumns를 상단에서 정의 (다른 useCallback/useMemo에서 사용하기 위해) + const visibleColumns = useMemo(() => { + let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); + + // columnVisibility가 있으면 가시성 적용 + if (columnVisibility.length > 0) { + cols = cols.filter((col) => { + const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName); + return visibilityConfig ? visibilityConfig.visible : true; + }); + } + + // 체크박스 컬럼 (나중에 위치 결정) + // 기본값: enabled가 undefined면 true로 처리 + let checkboxCol: ColumnConfig | null = null; + if (tableConfig.checkbox?.enabled ?? true) { + checkboxCol = { + columnName: "__checkbox__", + displayName: "", + visible: true, + sortable: false, + searchable: false, + width: 40, + align: "center" as const, + order: -1, + editable: false, // 체크박스는 편집 불가 + }; + } + + // columnOrder가 있으면 해당 순서로 정렬 + if (columnOrder.length > 0) { + const orderMap = new Map(columnOrder.map((name, idx) => [name, idx])); + cols = [...cols].sort((a, b) => { + const aIdx = orderMap.get(a.columnName) ?? 9999; + const bIdx = orderMap.get(b.columnName) ?? 9999; + return aIdx - bIdx; + }); + } + + // 체크박스 위치 결정 + if (checkboxCol) { + const checkboxPosition = tableConfig.checkbox?.position || "left"; + if (checkboxPosition === "left") { + return [checkboxCol, ...cols]; + } else { + return [...cols, checkboxCol]; + } + } + + return cols; + }, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]); + const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) + const [headerFilters, setHeaderFilters] = useState>>({}); + const [openFilterColumn, setOpenFilterColumn] = useState(null); + + // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 + const [filterGroups, setFilterGroups] = useState([]); + + // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 const filteredData = useMemo(() => { - // 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우에만 필터링 + let result = data; + + // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { const addedIds = splitPanelContext.addedItemIds; - const filtered = data.filter((row) => { + result = result.filter((row) => { const rowId = String(row.id || row.po_item_id || row.item_id || ""); return !addedIds.has(rowId); }); - console.log("🔍 [TableList] 우측 추가 항목 필터링:", { - originalCount: data.length, - filteredCount: filtered.length, - addedIdsCount: addedIds.size, - }); - return filtered; } - return data; - }, [data, splitPanelPosition, splitPanelContext?.addedItemIds]); + + // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) + if (Object.keys(headerFilters).length > 0) { + result = result.filter((row) => { + return Object.entries(headerFilters).every(([columnName, values]) => { + if (values.size === 0) return true; + + // 여러 가능한 컬럼명 시도 + const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; + const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; + + return values.has(cellStr); + }); + }); + } + + // 3. 🆕 Filter Builder 적용 + if (filterGroups.length > 0) { + result = result.filter((row) => { + return filterGroups.every((group) => { + const validConditions = group.conditions.filter( + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + ); + if (validConditions.length === 0) return true; + + const evaluateCondition = (value: any, condition: typeof group.conditions[0]): boolean => { + const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; + const condValue = condition.value.toLowerCase(); + + switch (condition.operator) { + case "equals": + return strValue === condValue; + case "notEquals": + return strValue !== condValue; + case "contains": + return strValue.includes(condValue); + case "notContains": + return !strValue.includes(condValue); + case "startsWith": + return strValue.startsWith(condValue); + case "endsWith": + return strValue.endsWith(condValue); + case "greaterThan": + return parseFloat(strValue) > parseFloat(condValue); + case "lessThan": + return parseFloat(strValue) < parseFloat(condValue); + case "greaterOrEqual": + return parseFloat(strValue) >= parseFloat(condValue); + case "lessOrEqual": + return parseFloat(strValue) <= parseFloat(condValue); + case "isEmpty": + return strValue === "" || value === null || value === undefined; + case "isNotEmpty": + return strValue !== "" && value !== null && value !== undefined; + default: + return true; + } + }; + + if (group.logic === "AND") { + return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); + } else { + return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); + } + }); + }); + } + + return result; + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); + const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); @@ -377,7 +546,7 @@ export const TableListComponent: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); const [refreshTrigger, setRefreshTrigger] = useState(0); - const [columnOrder, setColumnOrder] = useState([]); + // columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요) const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); const hasInitializedWidths = useRef(false); @@ -387,16 +556,117 @@ export const TableListComponent: React.FC = ({ const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + // 🆕 키보드 네비게이션 관련 상태 + const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + const tableContainerRef = useRef(null); + + // 🆕 인라인 셀 편집 관련 상태 + const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; + } | null>(null); + const [editingValue, setEditingValue] = useState(""); + const editInputRef = useRef(null); + + // 🆕 배치 편집 관련 상태 + const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드 + const [pendingChanges, setPendingChanges] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + const [localEditedData, setLocalEditedData] = useState>>({}); // 로컬 수정 데이터 + + // 🆕 유효성 검사 관련 상태 + const [validationErrors, setValidationErrors] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + + // 🆕 유효성 검사 규칙 타입 + type ValidationRule = { + required?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; // 커스텀 검증 함수 (에러 메시지 또는 null) + }; + + // 🆕 Cascading Lookups 관련 상태 + const [cascadingOptions, setCascadingOptions] = useState>({}); + const [loadingCascading, setLoadingCascading] = useState>({}); + + // 🆕 Multi-Level Headers (Column Bands) 타입 + type ColumnBand = { + caption: string; + columns: string[]; // 포함될 컬럼명 배열 + }; + // 그룹 설정 관련 상태 const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + // 🆕 Master-Detail 관련 상태 + const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 + const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 + + // 🆕 Drag & Drop 재정렬 관련 상태 + const [draggedRowIndex, setDraggedRowIndex] = useState(null); + const [dropTargetIndex, setDropTargetIndex] = useState(null); + const [isDragEnabled, setIsDragEnabled] = useState((tableConfig as any).enableRowDrag ?? false); + + // 🆕 Virtual Scrolling 관련 상태 + const [isVirtualScrollEnabled] = useState((tableConfig as any).virtualScroll ?? false); + const [scrollTop, setScrollTop] = useState(0); + const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀) + const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수 + const scrollContainerRef = useRef(null); + + // 🆕 Column Reordering 관련 상태 + const [draggedColumnIndex, setDraggedColumnIndex] = useState(null); + const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); + const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); + + // 🆕 State Persistence: 통합 상태 키 + const tableStateKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `tableState_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); + + // 🆕 Real-Time Updates 관련 상태 + const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); + const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected"); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + // 🆕 Context Menu 관련 상태 + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + rowIndex: number; + colIndex: number; + row: any; + } | null>(null); + // 사용자 옵션 모달 관련 상태 const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [showGridLines, setShowGridLines] = useState(true); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); const [frozenColumns, setFrozenColumns] = useState([]); + // 🆕 Search Panel (통합 검색) 관련 상태 + const [globalSearchTerm, setGlobalSearchTerm] = useState(""); + const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); + const [searchHighlights, setSearchHighlights] = useState>(new Set()); // "rowIndex-colIndex" 형식 + + // 🆕 Filter Builder (고급 필터) 관련 상태 추가 + const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false); + const [activeFilterCount, setActiveFilterCount] = useState(0); + // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) useEffect(() => { const linkedFilters = tableConfig.linkedFilters; @@ -800,9 +1070,10 @@ export const TableListComponent: React.FC = ({ // 전역 저장소에 데이터 저장 if (tableConfig.selectedTable) { - // 컬럼 라벨 매핑 생성 + // 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); @@ -815,7 +1086,7 @@ export const TableListComponent: React.FC = ({ { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, @@ -1418,21 +1689,23 @@ export const TableListComponent: React.FC = ({ setError(null); // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) + // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); tableDisplayStore.setTableData( tableConfig.selectedTable, response.data || [], - visibleColumns.map((col) => col.columnName), + cols.map((col) => col.columnName), sortBy, sortOrder, { filterConditions: filters, searchTerm: search, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: page, pageSize: pageSize, @@ -1552,9 +1825,11 @@ export const TableListComponent: React.FC = ({ }); // 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬 + // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const reorderedData = sortedData.map((row: any) => { const reordered: any = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { if (col.columnName in row) { reordered[col.columnName] = row[col.columnName]; } @@ -1590,12 +1865,12 @@ export const TableListComponent: React.FC = ({ // 전역 저장소에 정렬된 데이터 저장 if (tableConfig.selectedTable) { const cleanColumnOrder = ( - columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName) + columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName) ).filter((col) => col !== "__checkbox__"); // 컬럼 라벨 정보도 함께 저장 const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); @@ -1608,7 +1883,7 @@ export const TableListComponent: React.FC = ({ { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, @@ -1783,6 +2058,1502 @@ export const TableListComponent: React.FC = ({ console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; + // 🆕 셀 클릭 핸들러 (포커스 설정) + const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { + e.stopPropagation(); + setFocusedCell({ rowIndex, colIndex }); + // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) + tableContainerRef.current?.focus(); + }; + + // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 + const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { + // 체크박스 컬럼은 편집 불가 + if (columnName === "__checkbox__") return; + + // 🆕 편집 불가 컬럼 체크 + const column = visibleColumns.find((col) => col.columnName === columnName); + if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); + return; + } + + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); + setEditingValue(value !== null && value !== undefined ? String(value) : ""); + setFocusedCell({ rowIndex, colIndex }); + }, [visibleColumns]); + + // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) + const startEditingRef = useRef<() => void>(() => {}); + + // 🆕 각 컬럼의 고유값 목록 계산 + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + + if (data.length === 0) return result; + + (tableConfig.columns || []).forEach((column: { columnName: string }) => { + if (column.columnName === "__checkbox__") return; + + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const values = new Set(); + + data.forEach((row) => { + const val = row[mappedColumnName]; + if (val !== null && val !== undefined && val !== "") { + values.add(String(val)); + } + }); + + result[column.columnName] = Array.from(values).sort(); + }); + + return result; + }, [data, tableConfig.columns, joinColumnMapping]); + + // 🆕 헤더 필터 토글 + const toggleHeaderFilter = useCallback((columnName: string, value: string) => { + setHeaderFilters((prev) => { + const current = prev[columnName] || new Set(); + const newSet = new Set(current); + + if (newSet.has(value)) { + newSet.delete(value); + } else { + newSet.add(value); + } + + return { ...prev, [columnName]: newSet }; + }); + }, []); + + // 🆕 헤더 필터 초기화 + const clearHeaderFilter = useCallback((columnName: string) => { + setHeaderFilters((prev) => { + const newFilters = { ...prev }; + delete newFilters[columnName]; + return newFilters; + }); + setOpenFilterColumn(null); + }, []); + + // 🆕 모든 헤더 필터 초기화 + const clearAllHeaderFilters = useCallback(() => { + setHeaderFilters({}); + setOpenFilterColumn(null); + }, []); + + // 🆕 데이터 요약 (Total Summaries) 설정 + // 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } + const summaryConfig = useMemo(() => { + const config: Record = {}; + + // tableConfig에서 summary 설정 읽기 + if (tableConfig.summaries) { + tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { + config[summary.columnName] = { type: summary.type, label: summary.label }; + }); + } + + return config; + }, [tableConfig.summaries]); + + // 🆕 요약 데이터 계산 + const summaryData = useMemo(() => { + if (Object.keys(summaryConfig).length === 0 || data.length === 0) { + return null; + } + + const result: Record = {}; + + Object.entries(summaryConfig).forEach(([columnName, config]) => { + const values = data + .map((row) => { + const mappedColumnName = joinColumnMapping[columnName] || columnName; + const val = row[mappedColumnName]; + return typeof val === "number" ? val : parseFloat(val); + }) + .filter((v) => !isNaN(v)); + + let value: number | string = 0; + let label = config.label || ""; + + switch (config.type) { + case "sum": + value = values.reduce((acc, v) => acc + v, 0); + label = label || "합계"; + break; + case "avg": + value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0; + label = label || "평균"; + break; + case "count": + value = data.length; + label = label || "개수"; + break; + case "min": + value = values.length > 0 ? Math.min(...values) : 0; + label = label || "최소"; + break; + case "max": + value = values.length > 0 ? Math.max(...values) : 0; + label = label || "최대"; + break; + default: + value = 0; + } + + result[columnName] = { value, label }; + }); + + return result; + }, [data, summaryConfig, joinColumnMapping]); + + // 🆕 편집 취소 + const cancelEditing = useCallback(() => { + setEditingCell(null); + setEditingValue(""); + tableContainerRef.current?.focus(); + }, []); + + // 🆕 편집 저장 (즉시 저장 또는 배치 저장) + const saveEditing = useCallback(async () => { + if (!editingCell) return; + + const { rowIndex, columnName, originalValue } = editingCell; + const newValue = editingValue; + + // 값이 변경되지 않았으면 그냥 닫기 + if (String(originalValue ?? "") === newValue) { + setCellValidationError(rowIndex, columnName, null); // 에러 초기화 + cancelEditing(); + return; + } + + // 현재 행 데이터 가져오기 + const row = data[rowIndex]; + if (!row || !tableConfig.selectedTable) { + cancelEditing(); + return; + } + + // 🆕 유효성 검사 실행 + const validationError = validateValue(newValue === "" ? null : newValue, columnName, row); + if (validationError) { + setCellValidationError(rowIndex, columnName, validationError); + toast.error(validationError); + // 편집 상태 유지 (에러 수정 가능하도록) + return; + } + // 유효성 통과 시 에러 초기화 + setCellValidationError(rowIndex, columnName, null); + + // 기본 키 필드 찾기 (id 또는 첫 번째 컬럼) + const primaryKeyField = tableConfig.primaryKey || "id"; + const primaryKeyValue = row[primaryKeyField]; + + if (primaryKeyValue === undefined || primaryKeyValue === null) { + console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField); + cancelEditing(); + return; + } + + // 🆕 배치 모드: 변경사항을 pending에 저장 + if (editMode === "batch") { + const changeKey = `${rowIndex}-${columnName}`; + setPendingChanges((prev) => { + const newMap = new Map(prev); + newMap.set(changeKey, { + rowIndex, + columnName, + originalValue, + newValue: newValue === "" ? null : newValue, + primaryKeyValue, + }); + return newMap; + }); + + // 로컬 수정 데이터 업데이트 (화면 표시용) + setLocalEditedData((prev) => ({ + ...prev, + [rowIndex]: { + ...(prev[rowIndex] || {}), + [columnName]: newValue === "" ? null : newValue, + }, + })); + + console.log("📝 배치 편집 추가:", { columnName, newValue, pendingCount: pendingChanges.size + 1 }); + cancelEditing(); + return; + } + + // 🆕 즉시 모드: 바로 저장 + try { + const { apiClient } = await import("@/lib/api/client"); + + await apiClient.put(`/dynamic-form/update-field`, { + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: primaryKeyValue, + updateField: columnName, + updateValue: newValue === "" ? null : newValue, + }); + + // 데이터 새로고침 트리거 + setRefreshTrigger((prev) => prev + 1); + + console.log("✅ 셀 편집 저장 완료:", { columnName, newValue }); + } catch (error) { + console.error("❌ 셀 편집 저장 실패:", error); + } + + cancelEditing(); + }, [editingCell, editingValue, data, tableConfig.selectedTable, tableConfig.primaryKey, cancelEditing, editMode, pendingChanges.size]); + + // 🆕 배치 저장: 모든 변경사항 한번에 저장 + const saveBatchChanges = useCallback(async () => { + if (pendingChanges.size === 0) { + toast.info("저장할 변경사항이 없습니다."); + return; + } + + try { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // 모든 변경사항 저장 + const savePromises = Array.from(pendingChanges.values()).map((change) => + apiClient.put(`/dynamic-form/update-field`, { + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: change.primaryKeyValue, + updateField: change.columnName, + updateValue: change.newValue, + }) + ); + + await Promise.all(savePromises); + + // 상태 초기화 + setPendingChanges(new Map()); + setLocalEditedData({}); + setRefreshTrigger((prev) => prev + 1); + + toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`); + console.log("✅ 배치 저장 완료:", pendingChanges.size, "개"); + } catch (error) { + console.error("❌ 배치 저장 실패:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); + + // 🆕 배치 취소: 모든 변경사항 롤백 + const cancelBatchChanges = useCallback(() => { + if (pendingChanges.size === 0) return; + + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("변경사항이 취소되었습니다."); + console.log("🔄 배치 편집 취소"); + }, [pendingChanges.size]); + + // 🆕 특정 셀이 수정되었는지 확인 + const isCellModified = useCallback((rowIndex: number, columnName: string) => { + return pendingChanges.has(`${rowIndex}-${columnName}`); + }, [pendingChanges]); + + // 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선) + const getDisplayValue = useCallback((row: any, rowIndex: number, columnName: string) => { + const localValue = localEditedData[rowIndex]?.[columnName]; + if (localValue !== undefined) { + return localValue; + } + return row[columnName]; + }, [localEditedData]); + + // 🆕 유효성 검사 함수 + const validateValue = useCallback(( + value: any, + columnName: string, + row: any + ): string | null => { + // tableConfig.validation에서 컬럼별 규칙 가져오기 + const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; + if (!rules) return null; + + const strValue = value !== null && value !== undefined ? String(value) : ""; + const numValue = parseFloat(strValue); + + // 필수 검사 + if (rules.required && (!strValue || strValue.trim() === "")) { + return rules.customMessage || "필수 입력 항목입니다."; + } + + // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) + if (!strValue || strValue.trim() === "") return null; + + // 최소값 검사 + if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { + return rules.customMessage || `최소값은 ${rules.min}입니다.`; + } + + // 최대값 검사 + if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { + return rules.customMessage || `최대값은 ${rules.max}입니다.`; + } + + // 최소 길이 검사 + if (rules.minLength !== undefined && strValue.length < rules.minLength) { + return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; + } + + // 최대 길이 검사 + if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { + return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; + } + + // 패턴 검사 + if (rules.pattern && !rules.pattern.test(strValue)) { + return rules.customMessage || "입력 형식이 올바르지 않습니다."; + } + + // 커스텀 검증 + if (rules.validate) { + const customError = rules.validate(value, row); + if (customError) return customError; + } + + return null; + }, [tableConfig]); + + // 🆕 셀 유효성 에러 여부 확인 + const getCellValidationError = useCallback((rowIndex: number, columnName: string): string | null => { + return validationErrors.get(`${rowIndex}-${columnName}`) || null; + }, [validationErrors]); + + // 🆕 유효성 검사 에러 설정 + const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { + setValidationErrors((prev) => { + const newMap = new Map(prev); + const key = `${rowIndex}-${columnName}`; + if (error) { + newMap.set(key, error); + } else { + newMap.delete(key); + } + return newMap; + }); + }, []); + + // 🆕 모든 유효성 에러 초기화 + const clearAllValidationErrors = useCallback(() => { + setValidationErrors(new Map()); + }, []); + + // 🆕 Excel 내보내기 함수 + const exportToExcel = useCallback((exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 (선택된 행만 또는 전체) + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + // 선택된 행만 내보내기 + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } + + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 헤더 행 생성 + const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); + + // 데이터 행 생성 + const rows = exportData.map((row) => { + return exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + const value = row[mappedColumnName]; + + // 카테고리 매핑된 값 처리 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) { + return mapping.label; + } + } + + // null/undefined 처리 + if (value === null || value === undefined) { + return ""; + } + + return value; + }); + }); + + // 워크시트 생성 + const wsData = [headers, ...rows]; + const ws = XLSX.utils.aoa_to_sheet(wsData); + + // 컬럼 너비 자동 조정 + const colWidths = exportColumns.map((col, idx) => { + const headerLength = headers[idx]?.length || 10; + const maxDataLength = Math.max( + ...rows.map((row) => String(row[idx] ?? "").length) + ); + return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; + }); + ws["!cols"] = colWidths; + + // 워크북 생성 + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); + + // 파일명 생성 + const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; + + // 파일 다운로드 + XLSX.writeFile(wb, fileName); + + toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); + console.log("✅ Excel 내보내기 완료:", fileName); + } catch (error) { + console.error("❌ Excel 내보내기 실패:", error); + toast.error("Excel 내보내기 중 오류가 발생했습니다."); + } + }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, tableLabel, tableConfig.selectedTable, getRowKey]); + + // 🆕 행 확장/축소 토글 + const toggleRowExpand = useCallback(async (rowKey: string, row: any) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowKey)) { + newSet.delete(rowKey); + } else { + newSet.add(rowKey); + // 상세 데이터 로딩 (아직 없는 경우) + if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { + loadDetailData(rowKey, row); + } + } + return newSet; + }); + }, [detailData, tableConfig]); + + // 🆕 상세 데이터 로딩 + const loadDetailData = useCallback(async (rowKey: string, row: any) => { + const masterDetailConfig = (tableConfig as any).masterDetail; + if (!masterDetailConfig?.detailTable) return; + + try { + const { apiClient } = await import("@/lib/api/client"); + + // masterKey 값 가져오기 + const masterKeyField = masterDetailConfig.masterKey || "id"; + const masterKeyValue = row[masterKeyField]; + + // 상세 테이블에서 데이터 조회 + const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { + page: 1, + size: 100, + search: { + [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, + }, + autoFilter: true, + }); + + const details = response.data?.data?.data || []; + + setDetailData((prev) => ({ + ...prev, + [rowKey]: details, + })); + + console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); + } catch (error) { + console.error("❌ 상세 데이터 로딩 실패:", error); + setDetailData((prev) => ({ + ...prev, + [rowKey]: [], + })); + } + }, [tableConfig]); + + // 🆕 모든 행 확장/축소 + const expandAllRows = useCallback(() => { + if (expandedRows.size === filteredData.length) { + // 모두 축소 + setExpandedRows(new Set()); + } else { + // 모두 확장 + const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); + setExpandedRows(allKeys); + } + }, [expandedRows.size, filteredData, getRowKey]); + + // 🆕 Multi-Level Headers: Band 정보 계산 + const columnBandsInfo = useMemo(() => { + const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined; + if (!bands || bands.length === 0) return null; + + // 각 band의 시작 인덱스와 colspan 계산 + const bandInfo = bands.map((band) => { + const visibleBandColumns = band.columns.filter((colName) => + visibleColumns.some((vc) => vc.columnName === colName) + ); + + const startIndex = visibleColumns.findIndex( + (vc) => visibleBandColumns.includes(vc.columnName) + ); + + return { + caption: band.caption, + columns: visibleBandColumns, + colSpan: visibleBandColumns.length, + startIndex, + }; + }).filter((b) => b.colSpan > 0); + + // Band에 포함되지 않은 컬럼 찾기 + const bandedColumns = new Set(bands.flatMap((b) => b.columns)); + const unbandedColumns = visibleColumns + .map((vc, idx) => ({ columnName: vc.columnName, index: idx })) + .filter((c) => !bandedColumns.has(c.columnName)); + + return { + bands: bandInfo, + unbandedColumns, + hasBands: bandInfo.length > 0, + }; + }, [tableConfig, visibleColumns]); + + // 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩 + const loadCascadingOptions = useCallback(async ( + columnName: string, + parentColumnName: string, + parentValue: any + ) => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return; + + const cacheKey = `${columnName}_${parentValue}`; + + // 이미 로딩 중이면 스킵 + if (loadingCascading[cacheKey]) return; + + // 이미 캐시된 데이터가 있으면 스킵 + if (cascadingOptions[cacheKey]) return; + + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); + + try { + const { apiClient } = await import("@/lib/api/client"); + + // API에서 연계 옵션 로딩 + const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { + page: 1, + size: 1000, + search: { + [cascadingConfig.parentKeyField || parentColumnName]: parentValue, + }, + autoFilter: true, + }); + + const items = response.data?.data?.data || []; + const options = items.map((item: any) => ({ + value: item[cascadingConfig.valueField || "id"], + label: item[cascadingConfig.labelField || "name"], + })); + + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: options, + })); + + console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); + } catch (error) { + console.error("❌ Cascading options 로딩 실패:", error); + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: [], + })); + } finally { + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); + } + }, [tableConfig, cascadingOptions, loadingCascading]); + + // 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기 + const getCascadingOptions = useCallback((columnName: string, row: any): { value: string; label: string }[] => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return []; + + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue === undefined || parentValue === null) return []; + + const cacheKey = `${columnName}_${parentValue}`; + return cascadingOptions[cacheKey] || []; + }, [tableConfig, cascadingOptions]); + + // 🆕 Virtual Scrolling: 보이는 행 범위 계산 + const virtualScrollInfo = useMemo(() => { + if (!isVirtualScrollEnabled || filteredData.length === 0) { + return { + startIndex: 0, + endIndex: filteredData.length, + visibleData: filteredData, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + totalHeight: filteredData.length * ROW_HEIGHT, + }; + } + + const containerHeight = scrollContainerRef.current?.clientHeight || 600; + const totalRows = filteredData.length; + const totalHeight = totalRows * ROW_HEIGHT; + + // 현재 보이는 행 범위 계산 + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; + const endIndex = Math.min(totalRows, startIndex + visibleRowCount); + + return { + startIndex, + endIndex, + visibleData: filteredData.slice(startIndex, endIndex), + topSpacerHeight: startIndex * ROW_HEIGHT, + bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, + totalHeight, + }; + }, [isVirtualScrollEnabled, filteredData, scrollTop, ROW_HEIGHT, OVERSCAN]); + + // 🆕 Virtual Scrolling: 스크롤 핸들러 + const handleVirtualScroll = useCallback((e: React.UIEvent) => { + if (!isVirtualScrollEnabled) return; + setScrollTop(e.currentTarget.scrollTop); + }, [isVirtualScrollEnabled]); + + // 🆕 State Persistence: 통합 상태 저장 + const saveTableState = useCallback(() => { + if (!tableStateKey) return; + + const state = { + columnWidths, + columnOrder, + sortColumn, + sortDirection, + groupByColumns, + frozenColumns, + showGridLines, + headerFilters: Object.fromEntries( + Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]) + ), + pageSize: localPageSize, + timestamp: Date.now(), + }; + + try { + localStorage.setItem(tableStateKey, JSON.stringify(state)); + console.log("✅ 테이블 상태 저장:", tableStateKey); + } catch (error) { + console.error("❌ 테이블 상태 저장 실패:", error); + } + }, [tableStateKey, columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters, localPageSize]); + + // 🆕 State Persistence: 통합 상태 복원 + const loadTableState = useCallback(() => { + if (!tableStateKey) return; + + try { + const saved = localStorage.getItem(tableStateKey); + if (!saved) return; + + const state = JSON.parse(saved); + + if (state.columnWidths) setColumnWidths(state.columnWidths); + if (state.columnOrder) setColumnOrder(state.columnOrder); + if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); + if (state.sortDirection) setSortDirection(state.sortDirection); + if (state.groupByColumns) setGroupByColumns(state.groupByColumns); + if (state.frozenColumns) setFrozenColumns(state.frozenColumns); + if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); + if (state.headerFilters) { + const filters: Record> = {}; + Object.entries(state.headerFilters).forEach(([key, values]) => { + filters[key] = new Set(values as string[]); + }); + setHeaderFilters(filters); + } + + console.log("✅ 테이블 상태 복원:", tableStateKey); + } catch (error) { + console.error("❌ 테이블 상태 복원 실패:", error); + } + }, [tableStateKey]); + + // 🆕 State Persistence: 상태 초기화 + const resetTableState = useCallback(() => { + if (!tableStateKey) return; + + try { + localStorage.removeItem(tableStateKey); + setColumnWidths({}); + setColumnOrder([]); + setSortColumn(null); + setSortDirection("asc"); + setGroupByColumns([]); + setFrozenColumns([]); + setShowGridLines(true); + setHeaderFilters({}); + toast.success("테이블 설정이 초기화되었습니다."); + console.log("✅ 테이블 상태 초기화:", tableStateKey); + } catch (error) { + console.error("❌ 테이블 상태 초기화 실패:", error); + } + }, [tableStateKey]); + + // 🆕 State Persistence: 컴포넌트 마운트 시 상태 복원 + useEffect(() => { + loadTableState(); + }, [tableStateKey]); // loadTableState는 의존성에서 제외 (무한 루프 방지) + + // 🆕 Real-Time Updates: WebSocket 연결 + const connectWebSocket = useCallback(() => { + if (!isRealTimeEnabled || !tableConfig.selectedTable) return; + + const wsUrl = (tableConfig as any).wsUrl || + `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; + + try { + setWsConnectionStatus("connecting"); + wsRef.current = new WebSocket(wsUrl); + + wsRef.current.onopen = () => { + setWsConnectionStatus("connected"); + console.log("✅ WebSocket 연결됨:", tableConfig.selectedTable); + }; + + wsRef.current.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + console.log("📨 WebSocket 메시지 수신:", message); + + switch (message.type) { + case "insert": + // 새 데이터 추가 + setRefreshTrigger((prev) => prev + 1); + toast.info("새 데이터가 추가되었습니다."); + break; + case "update": + // 데이터 업데이트 + setRefreshTrigger((prev) => prev + 1); + toast.info("데이터가 업데이트되었습니다."); + break; + case "delete": + // 데이터 삭제 + setRefreshTrigger((prev) => prev + 1); + toast.info("데이터가 삭제되었습니다."); + break; + case "refresh": + // 전체 새로고침 + setRefreshTrigger((prev) => prev + 1); + break; + default: + console.log("알 수 없는 메시지 타입:", message.type); + } + } catch (error) { + console.error("WebSocket 메시지 파싱 오류:", error); + } + }; + + wsRef.current.onclose = () => { + setWsConnectionStatus("disconnected"); + console.log("🔌 WebSocket 연결 종료"); + + // 자동 재연결 (5초 후) + if (isRealTimeEnabled) { + reconnectTimeoutRef.current = setTimeout(() => { + console.log("🔄 WebSocket 재연결 시도..."); + connectWebSocket(); + }, 5000); + } + }; + + wsRef.current.onerror = (error) => { + console.error("❌ WebSocket 오류:", error); + setWsConnectionStatus("disconnected"); + }; + } catch (error) { + console.error("WebSocket 연결 실패:", error); + setWsConnectionStatus("disconnected"); + } + }, [isRealTimeEnabled, tableConfig.selectedTable]); + + // 🆕 Real-Time Updates: 연결 관리 + useEffect(() => { + if (isRealTimeEnabled) { + connectWebSocket(); + } + + return () => { + // 정리 + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [isRealTimeEnabled, tableConfig.selectedTable]); + + // 🆕 State Persistence: 상태 변경 시 자동 저장 (디바운스) + useEffect(() => { + const timeoutId = setTimeout(() => { + saveTableState(); + }, 1000); // 1초 후 저장 (디바운스) + + return () => clearTimeout(timeoutId); + }, [columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters]); + + // 🆕 Clipboard: 선택된 데이터 복사 + const handleCopy = useCallback(async () => { + try { + // 선택된 행 데이터 가져오기 + let copyData: any[]; + + if (selectedRows.size > 0) { + // 선택된 행만 + copyData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } else if (focusedCell) { + // 포커스된 셀만 + const row = filteredData[focusedCell.rowIndex]; + if (row) { + const column = visibleColumns[focusedCell.colIndex]; + const value = row[column?.columnName]; + await navigator.clipboard.writeText(String(value ?? "")); + toast.success("셀 복사됨"); + return; + } + return; + } else { + toast.info("복사할 데이터를 선택해주세요."); + return; + } + + // TSV 형식으로 변환 (탭으로 구분) + const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); + const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); + const rows = copyData.map((row) => + exportColumns.map((c) => { + const value = row[c.columnName]; + return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; + }).join("\t") + ); + + const tsvContent = [headers.join("\t"), ...rows].join("\n"); + await navigator.clipboard.writeText(tsvContent); + + toast.success(`${copyData.length}행 복사됨`); + console.log("✅ 클립보드 복사:", copyData.length, "행"); + } catch (error) { + console.error("❌ 클립보드 복사 실패:", error); + toast.error("복사 실패"); + } + }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); + + // 🆕 전체 행 선택 + const handleSelectAllRows = useCallback(() => { + if (selectedRows.size === filteredData.length) { + // 전체 해제 + setSelectedRows(new Set()); + setIsAllSelected(false); + } else { + // 전체 선택 + const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); + setSelectedRows(allKeys); + setIsAllSelected(true); + } + }, [selectedRows.size, filteredData, getRowKey]); + + // 🆕 Context Menu: 열기 + const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => { + e.preventDefault(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + rowIndex, + colIndex, + row, + }); + }, []); + + // 🆕 Context Menu: 닫기 + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + // 🆕 Context Menu: 외부 클릭 시 닫기 + useEffect(() => { + if (contextMenu) { + const handleClick = () => closeContextMenu(); + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + } + }, [contextMenu, closeContextMenu]); + + // 🆕 Search Panel: 통합 검색 실행 + const executeGlobalSearch = useCallback((term: string) => { + if (!term.trim()) { + setSearchHighlights(new Set()); + return; + } + + const lowerTerm = term.toLowerCase(); + const highlights = new Set(); + + filteredData.forEach((row, rowIndex) => { + visibleColumns.forEach((col, colIndex) => { + const value = row[col.columnName]; + if (value !== null && value !== undefined) { + const strValue = String(value).toLowerCase(); + if (strValue.includes(lowerTerm)) { + highlights.add(`${rowIndex}-${colIndex}`); + } + } + }); + }); + + setSearchHighlights(highlights); + + // 첫 번째 검색 결과로 포커스 이동 + if (highlights.size > 0) { + const firstHighlight = Array.from(highlights)[0]; + const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + toast.success(`${highlights.size}개 검색 결과`); + } else { + toast.info("검색 결과가 없습니다"); + } + }, [filteredData, visibleColumns]); + + // 🆕 Search Panel: 다음 검색 결과로 이동 + const goToNextSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + + const highlightArray = Array.from(searchHighlights).sort((a, b) => { + const [aRow, aCol] = a.split("-").map(Number); + const [bRow, bCol] = b.split("-").map(Number); + if (aRow !== bRow) return aRow - bRow; + return aCol - bCol; + }); + + if (!focusedCell) { + const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + return; + } + + const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; + const currentIndex = highlightArray.indexOf(currentKey); + const nextIndex = (currentIndex + 1) % highlightArray.length; + const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + }, [searchHighlights, focusedCell]); + + // 🆕 Search Panel: 이전 검색 결과로 이동 + const goToPrevSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + + const highlightArray = Array.from(searchHighlights).sort((a, b) => { + const [aRow, aCol] = a.split("-").map(Number); + const [bRow, bCol] = b.split("-").map(Number); + if (aRow !== bRow) return aRow - bRow; + return aCol - bCol; + }); + + if (!focusedCell) { + const lastIdx = highlightArray.length - 1; + const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + return; + } + + const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; + const currentIndex = highlightArray.indexOf(currentKey); + const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1; + const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + }, [searchHighlights, focusedCell]); + + // 🆕 Search Panel: 검색 초기화 + const clearGlobalSearch = useCallback(() => { + setGlobalSearchTerm(""); + setSearchHighlights(new Set()); + setIsSearchPanelOpen(false); + }, []); + + // 🆕 Filter Builder: 조건 추가 + const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: [ + ...group.conditions, + { + id: `cond-${Date.now()}`, + column: defaultColumn || "", + operator: "contains" as const, + value: "", + }, + ], + } + : group + ) + ); + }, []); + + // 🆕 Filter Builder: 조건 삭제 + const removeFilterCondition = useCallback((groupId: string, conditionId: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: group.conditions.filter((c) => c.id !== conditionId), + } + : group + ) + ); + }, []); + + // 🆕 Filter Builder: 조건 업데이트 + const updateFilterCondition = useCallback( + (groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: group.conditions.map((c) => + c.id === conditionId ? { ...c, [field]: value } : c + ), + } + : group + ) + ); + }, + [] + ); + + // 🆕 Filter Builder: 그룹 추가 + const addFilterGroup = useCallback((defaultColumn?: string) => { + setFilterGroups((prev) => [ + ...prev, + { + id: `group-${Date.now()}`, + logic: "AND" as const, + conditions: [ + { + id: `cond-${Date.now()}`, + column: defaultColumn || "", + operator: "contains" as const, + value: "", + }, + ], + }, + ]); + }, []); + + // 🆕 Filter Builder: 그룹 삭제 + const removeFilterGroup = useCallback((groupId: string) => { + setFilterGroups((prev) => prev.filter((g) => g.id !== groupId)); + }, []); + + // 🆕 Filter Builder: 그룹 로직 변경 + const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { + setFilterGroups((prev) => + prev.map((group) => (group.id === groupId ? { ...group, logic } : group)) + ); + }, []); + + // 🆕 Filter Builder: 필터 적용 + const applyFilterBuilder = useCallback(() => { + // 유효한 조건 개수 계산 + let validConditions = 0; + filterGroups.forEach((group) => { + group.conditions.forEach((cond) => { + if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) { + validConditions++; + } + }); + }); + setActiveFilterCount(validConditions); + setIsFilterBuilderOpen(false); + toast.success(`${validConditions}개 필터 조건 적용됨`); + }, [filterGroups]); + + // 🆕 Filter Builder: 필터 초기화 + const clearFilterBuilder = useCallback(() => { + setFilterGroups([]); + setActiveFilterCount(0); + toast.info("필터 초기화됨"); + }, []); + + // 🆕 Filter Builder: 조건 평가 함수 + const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => { + const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; + const condValue = condition.value.toLowerCase(); + + switch (condition.operator) { + case "equals": + return strValue === condValue; + case "notEquals": + return strValue !== condValue; + case "contains": + return strValue.includes(condValue); + case "notContains": + return !strValue.includes(condValue); + case "startsWith": + return strValue.startsWith(condValue); + case "endsWith": + return strValue.endsWith(condValue); + case "greaterThan": + return parseFloat(strValue) > parseFloat(condValue); + case "lessThan": + return parseFloat(strValue) < parseFloat(condValue); + case "greaterOrEqual": + return parseFloat(strValue) >= parseFloat(condValue); + case "lessOrEqual": + return parseFloat(strValue) <= parseFloat(condValue); + case "isEmpty": + return strValue === "" || value === null || value === undefined; + case "isNotEmpty": + return strValue !== "" && value !== null && value !== undefined; + default: + return true; + } + }, []); + + // 🆕 Filter Builder: 행이 필터 조건을 만족하는지 확인 + const rowPassesFilterBuilder = useCallback( + (row: any): boolean => { + if (filterGroups.length === 0) return true; + + // 모든 그룹이 AND로 연결됨 (그룹 간) + return filterGroups.every((group) => { + const validConditions = group.conditions.filter( + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + ); + if (validConditions.length === 0) return true; + + if (group.logic === "AND") { + return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); + } else { + return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); + } + }); + }, + [filterGroups, evaluateCondition] + ); + + // 🆕 컬럼 드래그 시작 + const handleColumnDragStart = useCallback((e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled) return; + + setDraggedColumnIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `col-${index}`); + }, [isColumnDragEnabled]); + + // 🆕 컬럼 드래그 오버 + const handleColumnDragOver = useCallback((e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled || draggedColumnIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedColumnIndex) { + setDropTargetColumnIndex(index); + } + }, [isColumnDragEnabled, draggedColumnIndex]); + + // 🆕 컬럼 드래그 종료 + const handleColumnDragEnd = useCallback(() => { + setDraggedColumnIndex(null); + setDropTargetColumnIndex(null); + }, []); + + // 🆕 컬럼 드롭 + const handleColumnDrop = useCallback((e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { + handleColumnDragEnd(); + return; + } + + // 컬럼 순서 변경 + const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; + const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); + newOrder.splice(targetIndex, 0, movedColumn); + + setColumnOrder(newOrder); + toast.info("컬럼 순서가 변경되었습니다."); + console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); + + handleColumnDragEnd(); + }, [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd]); + + // 🆕 행 드래그 시작 + const handleRowDragStart = useCallback((e: React.DragEvent, index: number) => { + if (!isDragEnabled) return; + + setDraggedRowIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + + // 드래그 이미지 설정 (반투명) + const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; + dragImage.style.opacity = "0.5"; + dragImage.style.position = "absolute"; + dragImage.style.top = "-1000px"; + document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, 0, 0); + setTimeout(() => document.body.removeChild(dragImage), 0); + }, [isDragEnabled]); + + // 🆕 행 드래그 오버 + const handleRowDragOver = useCallback((e: React.DragEvent, index: number) => { + if (!isDragEnabled || draggedRowIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedRowIndex) { + setDropTargetIndex(index); + } + }, [isDragEnabled, draggedRowIndex]); + + // 🆕 행 드래그 종료 + const handleRowDragEnd = useCallback(() => { + setDraggedRowIndex(null); + setDropTargetIndex(null); + }, []); + + // 🆕 행 드롭 + const handleRowDrop = useCallback(async (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { + handleRowDragEnd(); + return; + } + + try { + // 로컬 데이터 재정렬 + const newData = [...filteredData]; + const [movedRow] = newData.splice(draggedRowIndex, 1); + newData.splice(targetIndex, 0, movedRow); + + // 서버에 순서 저장 (order_index 필드가 있는 경우) + const orderField = (tableConfig as any).orderField || "order_index"; + const hasOrderField = newData[0] && orderField in newData[0]; + + if (hasOrderField && tableConfig.selectedTable) { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // 영향받는 행들의 순서 업데이트 + const updates = newData.map((row, idx) => ({ + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: row[primaryKeyField], + updateField: orderField, + updateValue: idx + 1, + })); + + // 배치 업데이트 + await Promise.all( + updates.map((update) => + apiClient.put(`/dynamic-form/update-field`, update) + ) + ); + + toast.success("순서가 변경되었습니다."); + setRefreshTrigger((prev) => prev + 1); + } else { + // 로컬에서만 순서 변경 (저장 안함) + toast.info("순서가 변경되었습니다. (로컬만)"); + } + + console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); + } catch (error) { + console.error("❌ 행 순서 변경 실패:", error); + toast.error("순서 변경 중 오류가 발생했습니다."); + } + + handleRowDragEnd(); + }, [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd]); + + // 🆕 PDF 내보내기 (인쇄용 HTML 생성) + const exportToPdf = useCallback((exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } + + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 인쇄용 HTML 생성 + const printContent = ` + + + + + ${tableLabel || tableConfig.selectedTable || "데이터"} + + + +

${tableLabel || tableConfig.selectedTable || "데이터 목록"}

+
+ 출력일: ${new Date().toLocaleDateString("ko-KR")} | + 총 ${exportData.length}건 +
+ + + + ${exportColumns.map((col) => ``).join("")} + + + + ${exportData.map((row) => ` + + ${exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + let value = row[mappedColumnName]; + + // 카테고리 매핑 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) value = mapping.label; + } + + const meta = columnMeta[col.columnName]; + const inputType = meta?.inputType || (col as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ``; + }).join("")} + + `).join("")} + +
${columnLabels[col.columnName] || col.columnName}
${value ?? ""}
+ + + `; + + // 새 창에서 인쇄 + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(printContent); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + toast.success("인쇄 창이 열렸습니다."); + } else { + toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); + } + } catch (error) { + console.error("❌ PDF 내보내기 실패:", error); + toast.error("PDF 내보내기 중 오류가 발생했습니다."); + } + }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, columnMeta, tableLabel, tableConfig.selectedTable, getRowKey]); + + // 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리) + const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + saveEditing(); + break; + case "Escape": + e.preventDefault(); + cancelEditing(); + break; + case "Tab": + e.preventDefault(); + saveEditing(); + // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 + break; + } + }, [saveEditing, cancelEditing]); + + // 🆕 편집 입력 필드가 나타나면 자동 포커스 + useEffect(() => { + if (editingCell && editInputRef.current) { + editInputRef.current.focus(); + // select()는 input 요소에서만 사용 가능 (select 요소에서는 사용 불가) + if (typeof editInputRef.current.select === "function") { + editInputRef.current.select(); + } + } + }, [editingCell]); + + // 🆕 포커스된 셀로 스크롤 + useEffect(() => { + if (focusedCell && tableContainerRef.current) { + const focusedCellElement = tableContainerRef.current.querySelector( + `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]` + ) as HTMLElement; + + if (focusedCellElement) { + focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + } + }, [focusedCell]); + // 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능) const handleClick = (e: React.MouseEvent) => { @@ -1791,62 +3562,9 @@ export const TableListComponent: React.FC = ({ }; // ======================================== - // 컬럼 관련 + // 컬럼 관련 (visibleColumns는 상단에서 정의됨) // ======================================== - const visibleColumns = useMemo(() => { - let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - - // columnVisibility가 있으면 가시성 적용 - if (columnVisibility.length > 0) { - cols = cols.filter((col) => { - const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName); - return visibilityConfig ? visibilityConfig.visible : true; - }); - } - - // 체크박스 컬럼 (나중에 위치 결정) - // 기본값: enabled가 undefined면 true로 처리 - let checkboxCol: ColumnConfig | null = null; - if (tableConfig.checkbox?.enabled ?? true) { - checkboxCol = { - columnName: "__checkbox__", - displayName: "", - visible: true, - sortable: false, - searchable: false, - width: 50, - align: "center", - order: -1, - }; - } - - // columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외) - if (columnOrder.length > 0) { - const orderedCols = columnOrder - .map((colName) => cols.find((c) => c.columnName === colName)) - .filter(Boolean) as ColumnConfig[]; - - // columnOrder에 없는 새로운 컬럼들 추가 - const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName)); - - cols = [...orderedCols, ...remainingCols]; - } else { - cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0)); - } - - // 체크박스를 맨 앞 또는 맨 뒤에 추가 - if (checkboxCol) { - if (tableConfig.checkbox?.position === "right") { - cols = [...cols, checkboxCol]; - } else { - cols = [checkboxCol, ...cols]; - } - } - - return cols; - }, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]); - // 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달 const lastColumnOrderRef = useRef(""); @@ -1917,6 +3635,241 @@ export const TableListComponent: React.FC = ({ ); }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 + // 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치) + const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => { + // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) + if (editingCell) return; + + if (!focusedCell || data.length === 0) return; + + const { rowIndex, colIndex } = focusedCell; + const maxRowIndex = data.length - 1; + const maxColIndex = visibleColumns.length - 1; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); + } + break; + case "ArrowDown": + e.preventDefault(); + if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); + } + break; + case "ArrowLeft": + e.preventDefault(); + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } + break; + case "ArrowRight": + e.preventDefault(); + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } + break; + case "Enter": + e.preventDefault(); + // 현재 행 선택/해제 + const enterRow = data[rowIndex]; + if (enterRow) { + const rowKey = getRowKey(enterRow, rowIndex); + const isCurrentlySelected = selectedRows.has(rowKey); + handleRowSelection(rowKey, !isCurrentlySelected); + } + break; + case " ": // Space + e.preventDefault(); + // 체크박스 토글 + const spaceRow = data[rowIndex]; + if (spaceRow) { + const currentRowKey = getRowKey(spaceRow, rowIndex); + const isChecked = selectedRows.has(currentRowKey); + handleRowSelection(currentRowKey, !isChecked); + } + break; + case "F2": + // 🆕 F2: 편집 모드 진입 + e.preventDefault(); + { + const col = visibleColumns[colIndex]; + if (col && col.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (col.editable === false) { + toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } + const row = data[rowIndex]; + const mappedCol = joinColumnMapping[col.columnName] || col.columnName; + const val = row?.[mappedCol]; + setEditingCell({ + rowIndex, + colIndex, + columnName: col.columnName, + originalValue: val + }); + setEditingValue(val !== null && val !== undefined ? String(val) : ""); + } + } + break; + case "b": + case "B": + // 🆕 Ctrl+B: 배치 편집 모드 토글 + if (e.ctrlKey) { + e.preventDefault(); + setEditMode((prev) => { + const newMode = prev === "immediate" ? "batch" : "immediate"; + if (newMode === "immediate" && pendingChanges.size > 0) { + // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 + const confirmDiscard = window.confirm( + `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?` + ); + if (confirmDiscard) { + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("배치 편집 모드 종료"); + return "immediate"; + } + return "batch"; + } + toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); + return newMode; + }); + } + break; + case "s": + case "S": + // 🆕 Ctrl+S: 배치 저장 + if (e.ctrlKey && editMode === "batch") { + e.preventDefault(); + saveBatchChanges(); + } + break; + case "c": + case "C": + // 🆕 Ctrl+C: 선택된 행/셀 복사 + if (e.ctrlKey) { + e.preventDefault(); + handleCopy(); + } + break; + case "v": + case "V": + // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) + if (e.ctrlKey && editingCell) { + // 기본 동작 허용 (input에서 처리) + } + break; + case "a": + case "A": + // 🆕 Ctrl+A: 전체 선택 + if (e.ctrlKey) { + e.preventDefault(); + handleSelectAllRows(); + } + break; + case "f": + case "F": + // 🆕 Ctrl+F: 통합 검색 패널 열기 + if (e.ctrlKey) { + e.preventDefault(); + setIsSearchPanelOpen(true); + } + break; + case "F3": + // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+Home: 첫 번째 셀로 + setFocusedCell({ rowIndex: 0, colIndex: 0 }); + } else { + // Home: 현재 행의 첫 번째 셀로 + setFocusedCell({ rowIndex, colIndex: 0 }); + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+End: 마지막 셀로 + setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); + } else { + // End: 현재 행의 마지막 셀로 + setFocusedCell({ rowIndex, colIndex: maxColIndex }); + } + break; + case "PageUp": + e.preventDefault(); + // 10행 위로 + setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); + break; + case "PageDown": + e.preventDefault(); + // 10행 아래로 + setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); + break; + case "Escape": + e.preventDefault(); + // 포커스 해제 + setFocusedCell(null); + break; + case "Tab": + e.preventDefault(); + if (e.shiftKey) { + // Shift+Tab: 이전 셀 + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } else if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); + } + } else { + // Tab: 다음 셀 + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } else if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); + } + } + break; + default: + // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) + if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { + const column = visibleColumns[colIndex]; + if (column && column.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (column.editable === false) { + toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } + e.preventDefault(); + // 편집 시작 (현재 키를 초기값으로) + const row = data[rowIndex]; + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const value = row?.[mappedColumnName]; + + setEditingCell({ + rowIndex, + colIndex, + columnName: column.columnName, + originalValue: value + }); + setEditingValue(e.key); // 입력한 키로 시작 + } + } + break; + } + }, [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection]); + const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; if (column.width) return column.width; @@ -2411,14 +4364,42 @@ export const TableListComponent: React.FC = ({ groupValues[col] = items[0]?.[col]; }); + // 🆕 그룹별 소계 계산 + const groupSummary: Record = {}; + + // 숫자형 컬럼에 대해 소계 계산 + (tableConfig.columns || []).forEach((col: { columnName: string }) => { + if (col.columnName === "__checkbox__") return; + + const colMeta = columnMeta?.[col.columnName]; + const inputType = colMeta?.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + if (isNumeric) { + const values = items + .map((item) => parseFloat(item[col.columnName])) + .filter((v) => !isNaN(v)); + + if (values.length > 0) { + const sum = values.reduce((a, b) => a + b, 0); + groupSummary[col.columnName] = { + sum, + avg: sum / values.length, + count: values.length, + }; + } + } + }); + return { groupKey, groupValues, items, count: items.length, + summary: groupSummary, // 🆕 그룹별 소계 }; }); - }, [data, groupByColumns, columnLabels, columnMeta]); + }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); // 저장된 그룹 설정 불러오기 useEffect(() => { @@ -2632,19 +4613,81 @@ export const TableListComponent: React.FC = ({ - {/* 우측 새로고침 버튼 */} - + {/* 우측 버튼 그룹 */} +
+ {/* 🆕 내보내기 버튼 (Excel/PDF) */} + + + + + +
+
Excel
+ + +
+
PDF/인쇄
+ + +
+ + + + {/* 새로고침 버튼 */} + +
); - }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]); + }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); // ======================================== // 렌더링 @@ -2744,6 +4787,236 @@ export const TableListComponent: React.FC = ({
{/* 필터 헤더는 TableSearchWidget으로 이동 */} + {/* 🆕 DevExpress 스타일 기능 툴바 */} +
+ {/* 편집 모드 토글 */} +
+ +
+ + {/* 내보내기 버튼들 */} +
+ + +
+ + {/* 복사 버튼 */} +
+ +
+ + {/* 선택 정보 */} + {selectedRows.size > 0 && ( +
+ + {selectedRows.size}개 선택됨 + + +
+ )} + + {/* 🆕 통합 검색 패널 */} +
+ {isSearchPanelOpen ? ( +
+ setGlobalSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeGlobalSearch(globalSearchTerm); + } else if (e.key === "Escape") { + clearGlobalSearch(); + } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + } + }} + placeholder="검색어 입력... (Enter)" + className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" + autoFocus + /> + {searchHighlights.size > 0 && ( + + {searchHighlights.size}개 + + )} + + + +
+ ) : ( + + )} +
+ + {/* 🆕 Filter Builder (고급 필터) 버튼 */} +
+ + {activeFilterCount > 0 && ( + + )} +
+ + {/* 새로고침 */} +
+ +
+
+ + {/* 🆕 배치 편집 툴바 */} + {(editMode === "batch" || pendingChanges.size > 0) && ( +
+
+ + 배치 편집 모드 + + {pendingChanges.size > 0 && ( + + {pendingChanges.size}개 변경사항 + + )} +
+
+ + +
+
+ )} + {/* 그룹 표시 배지 */} {groupByColumns.length > 0 && (
@@ -2770,17 +5043,23 @@ export const TableListComponent: React.FC = ({
)} - {/* 테이블 컨테이너 */} + {/* 테이블 컨테이너 - 키보드 네비게이션 지원 */}
{/* 스크롤 영역 */}
= ({ height: "100%", overflow: "auto", }} + onScroll={handleVirtualScroll} > {/* 테이블 */} = ({ backgroundColor: "hsl(var(--background))", }} > + {/* 🆕 Multi-Level Headers (Column Bands) */} + {columnBandsInfo?.hasBands && ( + + {visibleColumns.map((column, colIdx) => { + // 이 컬럼이 속한 band 찾기 + const band = columnBandsInfo.bands.find( + (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx + ); + + // band의 첫 번째 컬럼인 경우에만 렌더링 + if (band) { + return ( + + ); + } + + // band에 속하지 않은 컬럼 (개별 표시) + const isInAnyBand = columnBandsInfo.bands.some( + (b) => b.columns.includes(column.columnName) + ); + if (!isInAnyBand) { + return ( + + ); + } + + // band의 중간 컬럼은 렌더링하지 않음 + return null; + })} + + )} = ({ } } + // 🆕 Column Reordering 상태 + const isColumnDragging = draggedColumnIndex === columnIndex; + const isColumnDropTarget = dropTargetColumnIndex === columnIndex; + return ( ))} + {/* 🆕 그룹별 소계 행 */} + {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( + + {visibleColumns.map((column, colIndex) => { + const summary = group.summary?.[column.columnName]; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || (column as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + if (colIndex === 0 && column.columnName === "__checkbox__") { + return ( + + ); + } + + if (colIndex === 0 && column.columnName !== "__checkbox__") { + return ( + + ); + } + + if (summary) { + return ( + + ); + } + + return + )} ); }) ) : ( - // 일반 렌더링 (그룹 없음) - filteredData.map((row, index) => ( - handleRowClick(row, index, e)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const cellValue = row[mappedColumnName]; + // 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원 + <> + {/* 🆕 Virtual Scrolling: Top Spacer */} + {isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && ( + + + )} + {/* 데이터 행 렌더링 */} + {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : filteredData).map((row, idx) => { + // Virtual Scrolling에서는 실제 인덱스 계산 + const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; + const rowKey = getRowKey(row, index); + const isRowSelected = selectedRows.has(rowKey); + const isRowFocused = focusedCell?.rowIndex === index; + + // 🆕 Drag & Drop 상태 + const isDragging = draggedRowIndex === index; + const isDropTarget = dropTargetIndex === index; + + return ( + handleRowClick(row, index, e)} + role="row" + aria-selected={isRowSelected} + // 🆕 Drag & Drop 이벤트 + draggable={isDragEnabled} + onDragStart={(e) => handleRowDragStart(e, index)} + onDragOver={(e) => handleRowDragOver(e, index)} + onDragEnd={handleRowDragEnd} + onDrop={(e) => handleRowDrop(e, index)} + > + {visibleColumns.map((column, colIndex) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 + const cellValue = editMode === "batch" + ? getDisplayValue(row, index, mappedColumnName) + : row[mappedColumnName]; - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || column.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // 셀 포커스 상태 + const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; + + // 🆕 배치 편집: 수정된 셀 여부 + const isModified = isCellModified(index, mappedColumnName); + + // 🆕 유효성 검사 에러 + const cellValidationError = getCellValidationError(index, mappedColumnName); + + // 🆕 검색 하이라이트 여부 + const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; + // 틀고정된 컬럼의 left 위치 계산 + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } } - } - return ( - - ); - })} - - )) + return ( + + ); + })} + + ); + })} + {/* 🆕 Virtual Scrolling: Bottom Spacer */} + {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( + + + )} + )} + + {/* 🆕 데이터 요약 (Total Summaries) */} + {summaryData && Object.keys(summaryData).length > 0 && ( + + + {visibleColumns.map((column, colIndex) => { + const summary = summaryData[column.columnName]; + const columnWidth = columnWidths[column.columnName]; + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // 틀고정된 컬럼의 left 위치 계산 + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } + } + + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || (column as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ( + + ); + })} + + + )}
+ {band.caption} + + {columnLabels[column.columnName] || column.columnName} +
= ({ column.columnName !== "__checkbox__" && "hover:bg-muted/70 cursor-pointer transition-colors", isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", + // 🆕 Column Reordering 스타일 + isColumnDragEnabled && column.columnName !== "__checkbox__" && "cursor-grab active:cursor-grabbing", + isColumnDragging && "opacity-50 bg-primary/20", + isColumnDropTarget && "border-l-4 border-l-primary", )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", @@ -2855,6 +5189,12 @@ export const TableListComponent: React.FC = ({ backgroundColor: "hsl(var(--muted))", ...(isFrozen && { left: `${leftPosition}px` }), }} + // 🆕 Column Reordering 이벤트 + draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"} + onDragStart={(e) => handleColumnDragStart(e, columnIndex)} + onDragOver={(e) => handleColumnDragOver(e, columnIndex)} + onDragEnd={handleColumnDragEnd} + onDrop={(e) => handleColumnDrop(e, columnIndex)} onClick={() => { if (isResizing.current) return; if (column.sortable !== false && column.columnName !== "__checkbox__") { @@ -2865,11 +5205,87 @@ export const TableListComponent: React.FC = ({ {column.columnName === "__checkbox__" ? ( renderCheckboxHeader() ) : ( -
+
+ {/* 🆕 편집 불가 컬럼 표시 */} + {column.editable === false && ( + + + + )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} )} + {/* 🆕 헤더 필터 버튼 */} + {tableConfig.headerFilter !== false && columnUniqueValues[column.columnName]?.length > 0 && ( + setOpenFilterColumn(open ? column.columnName : null)} + > + + + + e.stopPropagation()} + > +
+
+ 필터: {columnLabels[column.columnName] || column.displayName} + {headerFilters[column.columnName]?.size > 0 && ( + + )} +
+
+ {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { + const isSelected = headerFilters[column.columnName]?.has(val); + return ( +
toggleHeaderFilter(column.columnName, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( +
+ ...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개 +
+ )} +
+
+
+
+ )}
)} {/* 리사이즈 핸들 (체크박스 제외) */} @@ -3073,71 +5489,319 @@ export const TableListComponent: React.FC = ({ })}
+ 소계 + + 소계 ({group.count}건) + + {summary.sum.toLocaleString()} + ; + })} +
+
- {column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} -
handleCellClick(index, colIndex, e)} + onDoubleClick={() => handleCellDoubleClick(index, colIndex, column.columnName, cellValue)} + onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} + role="gridcell" + tabIndex={isCellFocused ? 0 : -1} + > + {/* 🆕 인라인 편집 모드 */} + {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex ? ( + // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 + (() => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[column.columnName]; + const options = cascadingConfig ? getCascadingOptions(column.columnName, row) : []; + + // 부모 값이 변경되면 옵션 로딩 + if (cascadingConfig && options.length === 0) { + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue !== undefined && parentValue !== null) { + loadCascadingOptions(column.columnName, cascadingConfig.parentColumn, parentValue); + } + } + + // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 + const colMeta = columnMeta[column.columnName]; + const isCategoryType = colMeta?.inputType === "category" || colMeta?.inputType === "code"; + const hasCategoryOptions = categoryMappings[column.columnName] && Object.keys(categoryMappings[column.columnName]).length > 0; + + if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { + const selectOptions = cascadingConfig + ? options + : Object.entries(categoryMappings[column.columnName] || {}).map(([value, info]) => ({ + value, + label: info.label, + })); + + return ( + + ); + } + + // 일반 입력 필드 + return ( + setEditingValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={saveEditing} + className="w-full h-full px-2 py-1 sm:px-4 sm:py-1.5 text-xs sm:text-sm border-2 border-primary bg-background focus:outline-none" + style={{ + textAlign: isNumeric ? "right" : column.align || "left", + }} + /> + ); + })() + ) : column.columnName === "__checkbox__" ? ( + renderCheckboxCell(row, index) + ) : ( + formatCellValue(cellValue, column, row) + )} +
+
+ {summary ? ( +
+ {summary.label} + + {typeof summary.value === "number" + ? summary.value.toLocaleString("ko-KR", { + maximumFractionDigits: 2, + }) + : summary.value} + +
+ ) : colIndex === 0 ? ( + 요약 + ) : null} +
@@ -3226,6 +5890,298 @@ export const TableListComponent: React.FC = ({ + {/* 🆕 Context Menu (우클릭 메뉴) */} + {contextMenu && ( +
e.stopPropagation()} + > +
+ {/* 셀 복사 */} + + + {/* 행 복사 */} + + +
+ + {/* 셀 편집 */} + {(() => { + const col = visibleColumns[contextMenu.colIndex]; + const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__"; + return ( + + ); + })()} + + {/* 행 선택/해제 */} + + +
+ + {/* 행 삭제 */} + +
+
+ )} + + {/* 🆕 Filter Builder 모달 */} + + + + 고급 필터 + + 여러 조건을 조합하여 데이터를 필터링합니다. + + + +
+ {filterGroups.length === 0 ? ( +
+ 필터 조건이 없습니다. 아래 버튼을 클릭하여 조건을 추가하세요. +
+ ) : ( + filterGroups.map((group, groupIndex) => ( +
+
+
+ 조건 그룹 {groupIndex + 1} + +
+ +
+ +
+ {group.conditions.map((condition) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */} + {condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && ( + updateFilterCondition(group.id, condition.id, "value", e.target.value)} + placeholder="값 입력" + className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs" + /> + )} + + {/* 조건 삭제 */} + +
+ ))} +
+ + {/* 조건 추가 버튼 */} + +
+ )) + )} + + {/* 그룹 추가 버튼 */} + +
+ + + + + + +
+
+ {/* 테이블 옵션 모달 */} = ({ )}
- {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */} + {/* 편집 가능 여부 + 필터 체크박스 */}
+ {/* 🆕 편집 가능 여부 토글 */} + + + {/* 필터 체크박스 */} f.columnName === column.columnName) || false} onCheckedChange={(checked) => { @@ -1174,6 +1194,7 @@ export const TableListConfigPanel: React.FC = ({ } }} className="h-3 w-3" + title="필터에 추가" />
diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index 2475f58f..a619baa0 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -77,6 +77,7 @@ export interface ColumnConfig { // 새로운 기능들 hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김) autoGeneration?: AutoGenerationConfig; // 자동생성 설정 + editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가) // 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들) additionalJoinInfo?: {