리스트 위젯 업그레이드

This commit is contained in:
leeheejin
2025-12-10 15:15:06 +09:00
parent c64c94c07b
commit f75c3e43ed
4 changed files with 1049 additions and 10 deletions

View File

@@ -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>
);
}