From c64c94c07b5a2b6a9a3dbb00a72a20b5fc7a4b3c Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 13:48:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=B5=9C=EA=B7=BC=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=ED=95=9C=20=EB=82=B4=EC=97=AD=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 222 +++++++++++++++++- 1 file changed, 219 insertions(+), 3 deletions(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 48545281..c1553b38 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -103,6 +103,13 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [routeLoading, setRouteLoading] = useState(false); const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 + // 공차/운행 정보 상태 + const [tripInfo, setTripInfo] = useState>({}); + const [tripInfoLoading, setTripInfoLoading] = useState(null); + + // Popup 열림 상태 (자동 새로고침 일시 중지용) + const [isPopupOpen, setIsPopupOpen] = useState(false); + // 지역 필터 상태 const [selectedRegion, setSelectedRegion] = useState("all"); @@ -187,6 +194,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setRoutePoints([]); }, []); + // 공차/운행 정보 로드 함수 + const loadTripInfo = useCallback(async (identifier: string) => { + if (!identifier || tripInfo[identifier]) { + return; // 이미 로드됨 + } + + setTripInfoLoading(identifier); + + try { + // user_id 또는 vehicle_number로 조회 + const query = `SELECT + id, vehicle_number, user_id, + last_trip_start, last_trip_end, last_trip_distance, last_trip_time, + last_empty_start, last_empty_end, last_empty_distance, last_empty_time, + departure, arrival, status + FROM vehicles + WHERE user_id = '${identifier}' + OR vehicle_number = '${identifier}' + LIMIT 1`; + + 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) { + setTripInfo((prev) => ({ + ...prev, + [identifier]: result.data.rows[0], + })); + } + } + } catch (err) { + console.error("공차/운행 정보 로드 실패:", err); + } + + setTripInfoLoading(null); + }, [tripInfo]); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { @@ -1135,14 +1187,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } const intervalId = setInterval(() => { - loadMultipleDataSources(); + // Popup이 열려있으면 자동 새로고침 건너뛰기 + if (!isPopupOpen) { + loadMultipleDataSources(); + } }, refreshInterval * 1000); return () => { clearInterval(intervalId); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataSources, element?.chartConfig?.refreshInterval]); + }, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]); // 타일맵 URL (VWorld 한국 지도) const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; @@ -1390,6 +1445,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { fillOpacity: 0.3, weight: 2, }} + eventHandlers={{ + popupopen: () => setIsPopupOpen(true), + popupclose: () => setIsPopupOpen(false), + }} >
@@ -1621,7 +1680,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } return ( - + setIsPopupOpen(true), + popupclose: () => setIsPopupOpen(false), + }} + >
{/* 데이터 소스명만 표시 */} @@ -1732,6 +1799,155 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } })()} + {/* 공차/운행 정보 (동적 로딩) */} + {(() => { + try { + const parsed = JSON.parse(marker.description || "{}"); + + // 식별자 찾기 (user_id 또는 vehicle_number) + const identifier = parsed.user_id || parsed.userId || parsed.vehicle_number || + parsed.vehicleNumber || parsed.plate_no || parsed.plateNo || + parsed.car_number || parsed.carNumber || marker.name; + + if (!identifier) return null; + + // 동적으로 로드된 정보 또는 marker.description에서 가져온 정보 사용 + const info = tripInfo[identifier] || parsed; + + // 공차 정보가 있는지 확인 + const hasEmptyTripInfo = info.last_empty_start || info.last_empty_end || + info.last_empty_distance || info.last_empty_time; + // 운행 정보가 있는지 확인 + const hasTripInfo = info.last_trip_start || info.last_trip_end || + info.last_trip_distance || info.last_trip_time; + + // 날짜/시간 포맷팅 함수 + const formatDateTime = (dateStr: string) => { + if (!dateStr) return "-"; + try { + const date = new Date(dateStr); + return date.toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateStr; + } + }; + + // 거리 포맷팅 (km) + const formatDistance = (dist: number | string) => { + if (dist === null || dist === undefined) return "-"; + const num = typeof dist === "string" ? parseFloat(dist) : dist; + if (isNaN(num)) return "-"; + return `${num.toFixed(1)} km`; + }; + + // 시간 포맷팅 (분) + const formatTime = (minutes: number | string) => { + if (minutes === null || minutes === undefined) return "-"; + const num = typeof minutes === "string" ? parseInt(minutes) : minutes; + if (isNaN(num)) return "-"; + if (num < 60) return `${num}분`; + const hours = Math.floor(num / 60); + const mins = num % 60; + return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`; + }; + + // 데이터가 없고 아직 로드 안 했으면 로드 버튼 표시 + if (!hasEmptyTripInfo && !hasTripInfo && !tripInfo[identifier]) { + return ( +
+ +
+ ); + } + + // 데이터가 없으면 표시 안 함 + if (!hasEmptyTripInfo && !hasTripInfo) return null; + + return ( +
+ {/* 운행 정보 */} + {hasTripInfo && ( +
+
🚛 최근 운행
+
+ {(info.last_trip_start || info.last_trip_end) && ( +
+ 시간:{" "} + {formatDateTime(info.last_trip_start)} ~ {formatDateTime(info.last_trip_end)} +
+ )} +
+ {info.last_trip_distance !== undefined && info.last_trip_distance !== null && ( + + 거리:{" "} + {formatDistance(info.last_trip_distance)} + + )} + {info.last_trip_time !== undefined && info.last_trip_time !== null && ( + + 소요:{" "} + {formatTime(info.last_trip_time)} + + )} +
+ {/* 출발지/도착지 */} + {(info.departure || info.arrival) && ( +
+ {info.departure && 출발: {info.departure}} + {info.departure && info.arrival && " → "} + {info.arrival && 도착: {info.arrival}} +
+ )} +
+
+ )} + + {/* 공차 정보 */} + {hasEmptyTripInfo && ( +
+
📦 최근 공차
+
+ {(info.last_empty_start || info.last_empty_end) && ( +
+ 시간:{" "} + {formatDateTime(info.last_empty_start)} ~ {formatDateTime(info.last_empty_end)} +
+ )} +
+ {info.last_empty_distance !== undefined && info.last_empty_distance !== null && ( + + 거리:{" "} + {formatDistance(info.last_empty_distance)} + + )} + {info.last_empty_time !== undefined && info.last_empty_time !== null && ( + + 소요:{" "} + {formatTime(info.last_empty_time)} + + )} +
+
+
+ )} +
+ ); + } catch { + return null; + } + })()} + {/* 좌표 */}
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)} From f75c3e43ed8717bcc78f6dd8ddd1e9aa44325971 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 15:15:06 +0900 Subject: [PATCH 2/3] =?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 }), + )} + + )} +
+ )} + + + + + +
); } From d1c9aeca18b7c3bf438b0a0d025fb184281bb095 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 15:29:23 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=A1=B0=EA=B8=88=20=EB=8D=94=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=EB=90=9C=20=EB=B2=84=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/widgets/ListWidget.tsx | 6 +++++- frontend/components/dashboard/widgets/ListTestWidget.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index befb1286..2e69f72d 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -584,7 +584,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { {config.rowDetailPopup?.title || "상세 정보"} - {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + {detailPopupLoading + ? "추가 정보를 로딩 중입니다..." + : detailPopupData + ? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}` + : "선택된 항목의 상세 정보입니다."} diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index 802c9ef2..24b9e320 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -728,7 +728,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { {config.rowDetailPopup?.title || "상세 정보"} - {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + {detailPopupLoading + ? "추가 정보를 로딩 중입니다..." + : detailPopupData + ? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}` + : "선택된 항목의 상세 정보입니다."}