From f75c3e43ed8717bcc78f6dd8ddd1e9aa44325971 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 15:15:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/types.ts | 41 ++ .../widget-sections/ListWidgetSection.tsx | 460 +++++++++++++++++- .../admin/dashboard/widgets/ListWidget.tsx | 279 ++++++++++- .../dashboard/widgets/ListTestWidget.tsx | 279 ++++++++++- 4 files changed, 1049 insertions(+), 10 deletions(-) diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index bc52ecb8..51f3bf7b 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -379,6 +379,47 @@ export interface ListWidgetConfig { stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용) compactMode: boolean; // 압축 모드 (기본: false) cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3) + // 행 클릭 팝업 설정 + rowDetailPopup?: RowDetailPopupConfig; +} + +// 행 상세 팝업 설정 +export interface RowDetailPopupConfig { + enabled: boolean; // 팝업 활성화 여부 + title?: string; // 팝업 제목 (기본: "상세 정보") + // 추가 데이터 조회 설정 + additionalQuery?: { + enabled: boolean; + tableName: string; // 조회할 테이블명 (예: vehicles) + matchColumn: string; // 매칭할 컬럼 (예: id) + sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일) + // 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시) + displayColumns?: DisplayColumnConfig[]; + }; +} + +// 표시 컬럼 설정 +export interface DisplayColumnConfig { + column: string; // DB 컬럼명 + label: string; // 표시 라벨 (사용자 정의) + // 필드 그룹 설정 + fieldGroups?: FieldGroup[]; +} + +// 필드 그룹 (팝업 내 섹션) +export interface FieldGroup { + id: string; + title: string; // 그룹 제목 (예: "운행 정보") + icon?: string; // 아이콘 (예: "truck", "clock") + color?: "blue" | "orange" | "green" | "red" | "purple" | "gray"; + fields: FieldConfig[]; +} + +// 필드 설정 +export interface FieldConfig { + column: string; // DB 컬럼명 + label: string; // 표시 라벨 + format?: "text" | "number" | "date" | "datetime" | "currency" | "boolean" | "distance" | "duration"; } // 리스트 컬럼 diff --git a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx index 2e84f123..b10057cf 100644 --- a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx @@ -1,10 +1,17 @@ "use client"; -import React from "react"; -import { ListWidgetConfig, QueryResult } from "../types"; +import React, { useState } from "react"; +import { ListWidgetConfig, QueryResult, FieldGroup, FieldConfig, DisplayColumnConfig } from "../types"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor"; import { ListTableOptions } from "../widgets/list-widget/ListTableOptions"; +import { Plus, Trash2, ChevronDown, ChevronUp, X, Check } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; interface ListWidgetSectionProps { queryResult: QueryResult | null; @@ -16,8 +23,91 @@ interface ListWidgetSectionProps { * 리스트 위젯 설정 섹션 * - 컬럼 설정 * - 테이블 옵션 + * - 행 클릭 팝업 설정 */ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) { + const [expandedGroups, setExpandedGroups] = useState>({}); + + // 팝업 설정 초기화 + const popupConfig = config.rowDetailPopup || { + enabled: false, + title: "상세 정보", + additionalQuery: { enabled: false, tableName: "", matchColumn: "" }, + fieldGroups: [], + }; + + // 팝업 설정 업데이트 헬퍼 + const updatePopupConfig = (updates: Partial) => { + onConfigChange({ + rowDetailPopup: { ...popupConfig, ...updates }, + }); + }; + + // 필드 그룹 추가 + const addFieldGroup = () => { + const newGroup: FieldGroup = { + id: `group-${Date.now()}`, + title: "새 그룹", + icon: "info", + color: "gray", + fields: [], + }; + updatePopupConfig({ + fieldGroups: [...(popupConfig.fieldGroups || []), newGroup], + }); + }; + + // 필드 그룹 삭제 + const removeFieldGroup = (groupId: string) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).filter((g) => g.id !== groupId), + }); + }; + + // 필드 그룹 업데이트 + const updateFieldGroup = (groupId: string, updates: Partial) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => (g.id === groupId ? { ...g, ...updates } : g)), + }); + }; + + // 필드 추가 + const addField = (groupId: string) => { + const newField: FieldConfig = { + column: "", + label: "", + format: "text", + }; + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => + g.id === groupId ? { ...g, fields: [...g.fields, newField] } : g, + ), + }); + }; + + // 필드 삭제 + const removeField = (groupId: string, fieldIndex: number) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => + g.id === groupId ? { ...g, fields: g.fields.filter((_, i) => i !== fieldIndex) } : g, + ), + }); + }; + + // 필드 업데이트 + const updateField = (groupId: string, fieldIndex: number, updates: Partial) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => + g.id === groupId ? { ...g, fields: g.fields.map((f, i) => (i === fieldIndex ? { ...f, ...updates } : f)) } : g, + ), + }); + }; + + // 그룹 확장/축소 토글 + const toggleGroupExpand = (groupId: string) => { + setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] })); + }; + return (
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */} @@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
)} + + {/* 행 클릭 팝업 설정 */} +
+
+ + updatePopupConfig({ enabled })} + aria-label="행 클릭 팝업 활성화" + /> +
+ + {popupConfig.enabled && ( +
+ {/* 팝업 제목 */} +
+ + updatePopupConfig({ title: e.target.value })} + placeholder="상세 정보" + className="mt-1 h-8 text-xs" + /> +
+ + {/* 추가 데이터 조회 설정 */} +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" }, + }) + } + aria-label="추가 데이터 조회 활성화" + /> +
+ + {popupConfig.additionalQuery?.enabled && ( +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value }, + }) + } + placeholder="vehicles" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value }, + }) + } + placeholder="id" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, + }) + } + placeholder="비워두면 매칭 컬럼과 동일" + className="mt-1 h-8 text-xs" + /> +
+ + {/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */} +
+ + + + + + +
+ 컬럼 선택 + +
+
+ {/* 쿼리 결과 컬럼 목록 */} + {queryResult?.columns.map((col) => { + const currentColumns = popupConfig.additionalQuery?.displayColumns || []; + const existingConfig = currentColumns.find((c) => + typeof c === 'object' ? c.column === col : c === col + ); + const isSelected = !!existingConfig; + return ( +
{ + const newColumns = isSelected + ? currentColumns.filter((c) => + typeof c === 'object' ? c.column !== col : c !== col + ) + : [...currentColumns, { column: col, label: col } as DisplayColumnConfig]; + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns }, + }); + }} + > + + {col} +
+ ); + })} + {(!queryResult?.columns || queryResult.columns.length === 0) && ( +

+ 쿼리를 먼저 실행해주세요 +

+ )} +
+
+
+

비워두면 모든 컬럼이 표시됩니다

+ + {/* 선택된 컬럼 라벨 편집 */} + {(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && ( +
+ +
+ {popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return ( +
+ + {column} + + { + const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])]; + newColumns[index] = { column, label: e.target.value }; + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns }, + }); + }} + placeholder="표시 라벨" + className="h-7 flex-1 text-xs" + /> + +
+ ); + })} +
+
+ )} +
+
+ )} +
+ + {/* 필드 그룹 설정 */} +
+
+ + +
+

설정하지 않으면 모든 필드가 자동으로 표시됩니다.

+ + {/* 필드 그룹 목록 */} + {(popupConfig.fieldGroups || []).map((group) => ( +
+ {/* 그룹 헤더 */} +
+ + +
+ + {/* 그룹 상세 (확장 시) */} + {expandedGroups[group.id] && ( +
+ {/* 그룹 제목 */} +
+
+ + updateFieldGroup(group.id, { title: e.target.value })} + className="mt-1 h-7 text-xs" + /> +
+
+ + +
+
+ + {/* 아이콘 */} +
+ + +
+ + {/* 필드 목록 */} +
+
+ + +
+ + {group.fields.map((field, fieldIndex) => ( +
+ updateField(group.id, fieldIndex, { column: e.target.value })} + placeholder="컬럼명" + className="h-6 flex-1 text-xs" + /> + updateField(group.id, fieldIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 flex-1 text-xs" + /> + + +
+ ))} +
+
+ )} +
+ ))} +
+
+ )} +
); } diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 8193aea4..befb1286 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -1,11 +1,20 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { DashboardElement, QueryResult, ListWidgetConfig } from "../types"; +import React, { useState, useEffect, useCallback } from "react"; +import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { getApiUrl } from "@/lib/utils/apiUrl"; +import { Truck, Clock, MapPin, Package, Info } from "lucide-react"; interface ListWidgetProps { element: DashboardElement; @@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); + // 행 상세 팝업 상태 + const [detailPopupOpen, setDetailPopupOpen] = useState(false); + const [detailPopupData, setDetailPopupData] = useState | null>(null); + const [detailPopupLoading, setDetailPopupLoading] = useState(false); + const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + const config = element.listConfig || { columnMode: "auto", viewMode: "table", @@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { cardColumns: 3, }; + // 행 클릭 핸들러 - 팝업 열기 + const handleRowClick = useCallback( + async (row: Record) => { + // 팝업이 비활성화되어 있으면 무시 + if (!config.rowDetailPopup?.enabled) return; + + setDetailPopupData(row); + setDetailPopupOpen(true); + setAdditionalDetailData(null); + setDetailPopupLoading(false); + + // 추가 데이터 조회 설정이 있으면 실행 + const additionalQuery = config.rowDetailPopup?.additionalQuery; + if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { + const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; + const matchValue = row[sourceColumn]; + + if (matchValue !== undefined && matchValue !== null) { + setDetailPopupLoading(true); + try { + const query = ` + SELECT * + FROM ${additionalQuery.tableName} + WHERE ${additionalQuery.matchColumn} = '${matchValue}' + LIMIT 1; + `; + + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(query); + + if (result.success && result.rows.length > 0) { + setAdditionalDetailData(result.rows[0]); + } else { + setAdditionalDetailData({}); + } + } catch (error) { + console.error("추가 데이터 로드 실패:", error); + setAdditionalDetailData({}); + } finally { + setDetailPopupLoading(false); + } + } + } + }, + [config.rowDetailPopup], + ); + + // 값 포맷팅 함수 + const formatValue = (value: any, format?: string): string => { + if (value === null || value === undefined) return "-"; + + switch (format) { + case "date": + return new Date(value).toLocaleDateString("ko-KR"); + case "datetime": + return new Date(value).toLocaleString("ko-KR"); + case "number": + return Number(value).toLocaleString("ko-KR"); + case "currency": + return `${Number(value).toLocaleString("ko-KR")}원`; + case "boolean": + return value ? "예" : "아니오"; + case "distance": + return typeof value === "number" ? `${value.toFixed(1)} km` : String(value); + case "duration": + return typeof value === "number" ? `${value}분` : String(value); + default: + return String(value); + } + }; + + // 아이콘 렌더링 + const renderIcon = (icon?: string, color?: string) => { + const colorClass = + color === "blue" + ? "text-blue-600" + : color === "orange" + ? "text-orange-600" + : color === "green" + ? "text-green-600" + : color === "red" + ? "text-red-600" + : color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + switch (icon) { + case "truck": + return ; + case "clock": + return ; + case "map": + return ; + case "package": + return ; + default: + return ; + } + }; + + // 필드 그룹 렌더링 + const renderFieldGroup = (group: FieldGroup, data: Record) => { + const colorClass = + group.color === "blue" + ? "text-blue-600" + : group.color === "orange" + ? "text-orange-600" + : group.color === "green" + ? "text-green-600" + : group.color === "red" + ? "text-red-600" + : group.color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + return ( +
+
+ {renderIcon(group.icon, group.color)} + {group.title} +
+
+ {group.fields.map((field) => ( +
+ + {field.label} + + {formatValue(data[field.column], field.format)} +
+ ))} +
+
+ ); + }; + + // 기본 필드 그룹 생성 (설정이 없을 경우) + const getDefaultFieldGroups = (row: Record, additional: Record | null): FieldGroup[] => { + const groups: FieldGroup[] = []; + const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; + + // 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체 + let basicFields: { column: string; label: string }[] = []; + + if (displayColumns && displayColumns.length > 0) { + // DisplayColumnConfig 형식 지원 + basicFields = displayColumns + .map((colConfig) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return { column, label }; + }) + .filter((item) => item.column in row); + } else { + // 전체 컬럼 + basicFields = Object.keys(row).map((key) => ({ column: key, label: key })); + } + + groups.push({ + id: "basic", + title: "기본 정보", + icon: "info", + color: "gray", + fields: basicFields.map((item) => ({ + column: item.column, + label: item.label, + format: "text", + })), + }); + + // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 + if (additional && Object.keys(additional).length > 0) { + // 운행 정보 + if (additional.last_trip_start || additional.last_trip_end) { + groups.push({ + id: "trip", + title: "운행 정보", + icon: "truck", + color: "blue", + fields: [ + { column: "last_trip_start", label: "시작", format: "datetime" }, + { column: "last_trip_end", label: "종료", format: "datetime" }, + { column: "last_trip_distance", label: "거리", format: "distance" }, + { column: "last_trip_time", label: "시간", format: "duration" }, + { column: "departure", label: "출발지", format: "text" }, + { column: "arrival", label: "도착지", format: "text" }, + ], + }); + } + + // 공차 정보 + if (additional.last_empty_start) { + groups.push({ + id: "empty", + title: "공차 정보", + icon: "package", + color: "orange", + fields: [ + { column: "last_empty_start", label: "시작", format: "datetime" }, + { column: "last_empty_end", label: "종료", format: "datetime" }, + { column: "last_empty_distance", label: "거리", format: "distance" }, + { column: "last_empty_time", label: "시간", format: "duration" }, + ], + }); + } + } + + return groups; + }; + // 데이터 로드 useEffect(() => { const loadData = async () => { @@ -260,7 +484,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { ) : ( paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns .filter((col) => col.visible) .map((col) => ( @@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { }} > {paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + >
{displayColumns .filter((col) => col.visible) @@ -345,6 +577,45 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
)} + + {/* 행 상세 팝업 */} + + + + {config.rowDetailPopup?.title || "상세 정보"} + + {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + + + + {detailPopupLoading ? ( +
+
+
+ ) : ( +
+ {detailPopupData && ( + <> + {/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */} + {config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0 + ? // 설정된 필드 그룹 렌더링 + config.rowDetailPopup.fieldGroups.map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + ) + : // 기본 필드 그룹 렌더링 + getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + )} + + )} +
+ )} + + + + + +
); } diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index c46244b1..802c9ef2 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -1,11 +1,19 @@ "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; +import { DashboardElement, ChartDataSource, FieldGroup } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; -import { Loader2, RefreshCw } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -34,6 +42,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [currentPage, setCurrentPage] = useState(1); const [lastRefreshTime, setLastRefreshTime] = useState(null); + // 행 상세 팝업 상태 + const [detailPopupOpen, setDetailPopupOpen] = useState(false); + const [detailPopupData, setDetailPopupData] = useState | null>(null); + const [detailPopupLoading, setDetailPopupLoading] = useState(false); + const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + // // console.log("🧪 ListTestWidget 렌더링!", element); const dataSources = useMemo(() => { @@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { cardColumns: 3, }; + // 행 클릭 핸들러 - 팝업 열기 + const handleRowClick = useCallback( + async (row: Record) => { + // 팝업이 비활성화되어 있으면 무시 + if (!config.rowDetailPopup?.enabled) return; + + setDetailPopupData(row); + setDetailPopupOpen(true); + setAdditionalDetailData(null); + setDetailPopupLoading(false); + + // 추가 데이터 조회 설정이 있으면 실행 + const additionalQuery = config.rowDetailPopup?.additionalQuery; + if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { + const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; + const matchValue = row[sourceColumn]; + + if (matchValue !== undefined && matchValue !== null) { + setDetailPopupLoading(true); + try { + const query = ` + SELECT * + FROM ${additionalQuery.tableName} + WHERE ${additionalQuery.matchColumn} = '${matchValue}' + LIMIT 1; + `; + + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(query); + + if (result.success && result.rows.length > 0) { + setAdditionalDetailData(result.rows[0]); + } else { + setAdditionalDetailData({}); + } + } catch (err) { + console.error("추가 데이터 로드 실패:", err); + setAdditionalDetailData({}); + } finally { + setDetailPopupLoading(false); + } + } + } + }, + [config.rowDetailPopup], + ); + + // 값 포맷팅 함수 + const formatValue = (value: any, format?: string): string => { + if (value === null || value === undefined) return "-"; + + switch (format) { + case "date": + return new Date(value).toLocaleDateString("ko-KR"); + case "datetime": + return new Date(value).toLocaleString("ko-KR"); + case "number": + return Number(value).toLocaleString("ko-KR"); + case "currency": + return `${Number(value).toLocaleString("ko-KR")}원`; + case "boolean": + return value ? "예" : "아니오"; + case "distance": + return typeof value === "number" ? `${value.toFixed(1)} km` : String(value); + case "duration": + return typeof value === "number" ? `${value}분` : String(value); + default: + return String(value); + } + }; + + // 아이콘 렌더링 + const renderIcon = (icon?: string, color?: string) => { + const colorClass = + color === "blue" + ? "text-blue-600" + : color === "orange" + ? "text-orange-600" + : color === "green" + ? "text-green-600" + : color === "red" + ? "text-red-600" + : color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + switch (icon) { + case "truck": + return ; + case "clock": + return ; + case "map": + return ; + case "package": + return ; + default: + return ; + } + }; + + // 필드 그룹 렌더링 + const renderFieldGroup = (group: FieldGroup, groupData: Record) => { + const colorClass = + group.color === "blue" + ? "text-blue-600" + : group.color === "orange" + ? "text-orange-600" + : group.color === "green" + ? "text-green-600" + : group.color === "red" + ? "text-red-600" + : group.color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + return ( +
+
+ {renderIcon(group.icon, group.color)} + {group.title} +
+
+ {group.fields.map((field) => ( +
+ + {field.label} + + {formatValue(groupData[field.column], field.format)} +
+ ))} +
+
+ ); + }; + + // 기본 필드 그룹 생성 (설정이 없을 경우) + const getDefaultFieldGroups = (row: Record, additional: Record | null): FieldGroup[] => { + const groups: FieldGroup[] = []; + const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; + + // 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체 + const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외 + let basicFields: { column: string; label: string }[] = []; + + if (displayColumns && displayColumns.length > 0) { + // DisplayColumnConfig 형식 지원 + basicFields = displayColumns + .map((colConfig) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return { column, label }; + }) + .filter((item) => allKeys.includes(item.column)); + } else { + // 전체 컬럼 + basicFields = allKeys.map((key) => ({ column: key, label: key })); + } + + groups.push({ + id: "basic", + title: "기본 정보", + icon: "info", + color: "gray", + fields: basicFields.map((item) => ({ + column: item.column, + label: item.label, + format: "text" as const, + })), + }); + + // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 + if (additional && Object.keys(additional).length > 0) { + // 운행 정보 + if (additional.last_trip_start || additional.last_trip_end) { + groups.push({ + id: "trip", + title: "운행 정보", + icon: "truck", + color: "blue", + fields: [ + { column: "last_trip_start", label: "시작", format: "datetime" as const }, + { column: "last_trip_end", label: "종료", format: "datetime" as const }, + { column: "last_trip_distance", label: "거리", format: "distance" as const }, + { column: "last_trip_time", label: "시간", format: "duration" as const }, + { column: "departure", label: "출발지", format: "text" as const }, + { column: "arrival", label: "도착지", format: "text" as const }, + ], + }); + } + + // 공차 정보 + if (additional.last_empty_start) { + groups.push({ + id: "empty", + title: "공차 정보", + icon: "package", + color: "orange", + fields: [ + { column: "last_empty_start", label: "시작", format: "datetime" as const }, + { column: "last_empty_end", label: "종료", format: "datetime" as const }, + { column: "last_empty_distance", label: "거리", format: "distance" as const }, + { column: "last_empty_time", label: "시간", format: "duration" as const }, + ], + }); + } + } + + return groups; + }; + // visible 컬럼 설정 객체 배열 (field + label) const visibleColumnConfigs = useMemo(() => { if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") { @@ -368,7 +592,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { )} {paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns.map((field) => ( {String(row[field] ?? "")} @@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { return (
{paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns.map((field) => (
{getLabel(field)}: @@ -489,6 +721,45 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
)} + + {/* 행 상세 팝업 */} + + + + {config.rowDetailPopup?.title || "상세 정보"} + + {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + + + + {detailPopupLoading ? ( +
+
+
+ ) : ( +
+ {detailPopupData && ( + <> + {/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */} + {config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0 + ? // 설정된 필드 그룹 렌더링 + config.rowDetailPopup.fieldGroups.map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + ) + : // 기본 필드 그룹 렌더링 + getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + )} + + )} +
+ )} + + + + + +
); }