리스트 위젯 업그레이드

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

@ -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";
}
// 리스트 컬럼

View File

@ -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<Record<string, boolean>>({});
// 팝업 설정 초기화
const popupConfig = config.rowDetailPopup || {
enabled: false,
title: "상세 정보",
additionalQuery: { enabled: false, tableName: "", matchColumn: "" },
fieldGroups: [],
};
// 팝업 설정 업데이트 헬퍼
const updatePopupConfig = (updates: Partial<typeof popupConfig>) => {
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<FieldGroup>) => {
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<FieldConfig>) => {
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 (
<div className="space-y-3">
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
<ListTableOptions config={config} onConfigChange={onConfigChange} />
</div>
)}
{/* 행 클릭 팝업 설정 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Switch
checked={popupConfig.enabled}
onCheckedChange={(enabled) => updatePopupConfig({ enabled })}
aria-label="행 클릭 팝업 활성화"
/>
</div>
{popupConfig.enabled && (
<div className="space-y-3">
{/* 팝업 제목 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={popupConfig.title || ""}
onChange={(e) => updatePopupConfig({ title: e.target.value })}
placeholder="상세 정보"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 추가 데이터 조회 설정 */}
<div className="space-y-2 rounded border p-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={popupConfig.additionalQuery?.enabled || false}
onCheckedChange={(enabled) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
})
}
aria-label="추가 데이터 조회 활성화"
/>
</div>
{popupConfig.additionalQuery?.enabled && (
<div className="space-y-2">
<div>
<Label className="text-xs"></Label>
<Input
value={popupConfig.additionalQuery?.tableName || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
})
}
placeholder="vehicles"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.matchColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
<div>
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
<span className="truncate">
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
: "전체 표시 (클릭하여 선택)"}
</span>
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* 쿼리 결과 컬럼 목록 */}
{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 (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
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 },
});
}}
>
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span>
</div>
);
})}
{(!queryResult?.columns || queryResult.columns.length === 0) && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
)}
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
{/* 선택된 컬럼 라벨 편집 */}
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<div className="space-y-1.5">
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return (
<div key={column} className="flex items-center gap-2">
<span className="text-muted-foreground w-24 truncate text-xs" title={column}>
{column}
</span>
<Input
value={label}
onChange={(e) => {
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"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => {
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
(c) => (typeof c === 'object' ? c.column : c) !== column
);
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* 필드 그룹 설정 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> ()</Label>
<Button variant="outline" size="sm" onClick={addFieldGroup} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-xs"> .</p>
{/* 필드 그룹 목록 */}
{(popupConfig.fieldGroups || []).map((group) => (
<div key={group.id} className="rounded border p-2">
{/* 그룹 헤더 */}
<div className="flex items-center justify-between">
<button
onClick={() => toggleGroupExpand(group.id)}
className="flex flex-1 items-center gap-2 text-left"
>
{expandedGroups[group.id] ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<span className="text-xs font-medium">{group.title || "새 그룹"}</span>
<span className="text-muted-foreground text-xs">({group.fields.length} )</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => removeFieldGroup(group.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 그룹 상세 (확장 시) */}
{expandedGroups[group.id] && (
<div className="mt-2 space-y-2 border-t pt-2">
{/* 그룹 제목 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
value={group.title}
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={group.color || "gray"}
onValueChange={(value) =>
updateFieldGroup(group.id, {
color: value as "blue" | "orange" | "green" | "red" | "purple" | "gray",
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gray"></SelectItem>
<SelectItem value="blue"></SelectItem>
<SelectItem value="orange"></SelectItem>
<SelectItem value="green"></SelectItem>
<SelectItem value="red"></SelectItem>
<SelectItem value="purple"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 아이콘 */}
<div>
<Label className="text-xs"></Label>
<Select
value={group.icon || "info"}
onValueChange={(value) => updateFieldGroup(group.id, { icon: value })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="info"></SelectItem>
<SelectItem value="truck"></SelectItem>
<SelectItem value="clock"></SelectItem>
<SelectItem value="map"></SelectItem>
<SelectItem value="package"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 필드 목록 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Button
variant="outline"
size="sm"
onClick={() => addField(group.id)}
className="h-6 gap-1 text-xs"
>
<Plus className="h-3 w-3" />
</Button>
</div>
{group.fields.map((field, fieldIndex) => (
<div key={fieldIndex} className="flex items-center gap-1 rounded bg-muted/50 p-1">
<Input
value={field.column}
onChange={(e) => updateField(group.id, fieldIndex, { column: e.target.value })}
placeholder="컬럼명"
className="h-6 flex-1 text-xs"
/>
<Input
value={field.label}
onChange={(e) => updateField(group.id, fieldIndex, { label: e.target.value })}
placeholder="라벨"
className="h-6 flex-1 text-xs"
/>
<Select
value={field.format || "text"}
onValueChange={(value) =>
updateField(group.id, fieldIndex, {
format: value as FieldConfig["format"],
})
}
>
<SelectTrigger className="h-6 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="datetime"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="distance"></SelectItem>
<SelectItem value="duration"></SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={() => removeField(group.id, fieldIndex)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

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

View File

@ -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<Date | null>(null);
// 행 상세 팝업 상태
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);
// // console.log("🧪 ListTestWidget 렌더링!", element);
const dataSources = useMemo(() => {
@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
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 (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 <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, groupData: 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(groupData[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가 있으면 해당 컬럼만, 없으면 전체
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) {
)}
<TableBody>
{paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
<TableRow
key={idx}
className={`${config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-accent" : ""}`}
onClick={() => handleRowClick(row)}
>
{displayColumns.map((field) => (
<TableCell key={field} className="whitespace-nowrap">
{String(row[field] ?? "")}
@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
return (
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
{paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4">
<Card
key={idx}
className={`p-4 ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-shadow hover:shadow-md" : ""}`}
onClick={() => handleRowClick(row)}
>
{displayColumns.map((field) => (
<div key={field} className="mb-2">
<span className="font-semibold">{getLabel(field)}: </span>
@ -489,6 +721,45 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
</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>
);
}