리스트 위젯 업그레이드
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 행 상세 팝업 상태
|
||||
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
|
||||
const [detailPopupData, setDetailPopupData] = useState<Record<string, any> | null>(null);
|
||||
const [detailPopupLoading, setDetailPopupLoading] = useState(false);
|
||||
const [additionalDetailData, setAdditionalDetailData] = useState<Record<string, any> | 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<string, any>) => {
|
||||
// 팝업이 비활성화되어 있으면 무시
|
||||
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 <Truck className={`h-4 w-4 ${colorClass}`} />;
|
||||
case "clock":
|
||||
return <Clock className={`h-4 w-4 ${colorClass}`} />;
|
||||
case "map":
|
||||
return <MapPin className={`h-4 w-4 ${colorClass}`} />;
|
||||
case "package":
|
||||
return <Package className={`h-4 w-4 ${colorClass}`} />;
|
||||
default:
|
||||
return <Info className={`h-4 w-4 ${colorClass}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 그룹 렌더링
|
||||
const renderFieldGroup = (group: FieldGroup, data: Record<string, any>) => {
|
||||
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 (
|
||||
<div key={group.id} className="rounded-lg border p-4">
|
||||
<div className={`mb-3 flex items-center gap-2 text-sm font-semibold ${colorClass}`}>
|
||||
{renderIcon(group.icon, group.color)}
|
||||
{group.title}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
|
||||
{group.fields.map((field) => (
|
||||
<div key={field.column} className="flex flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="font-medium break-words">{formatValue(data[field.column], field.format)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 기본 필드 그룹 생성 (설정이 없을 경우)
|
||||
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | 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) {
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
||||
<TableRow
|
||||
key={idx}
|
||||
className={`${config.stripedRows ? "" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-muted/50" : ""}`}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{displayColumns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
@@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||
}}
|
||||
>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
|
||||
<Card
|
||||
key={idx}
|
||||
className={`p-4 transition-shadow hover:shadow-md ${config.rowDetailPopup?.enabled ? "cursor-pointer" : ""}`}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{displayColumns
|
||||
.filter((col) => col.visible)
|
||||
@@ -345,6 +577,45 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 행 상세 팝업 */}
|
||||
<Dialog open={detailPopupOpen} onOpenChange={setDetailPopupOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[600px] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{config.rowDetailPopup?.title || "상세 정보"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{detailPopupLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{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 }),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setDetailPopupOpen(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user