lhj #269
|
|
@ -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";
|
||||
}
|
||||
|
||||
// 리스트 컬럼
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,49 @@ 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
|
||||
? "추가 정보를 로딩 중입니다..."
|
||||
: detailPopupData
|
||||
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
|
||||
: "선택된 항목의 상세 정보입니다."}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,49 @@ 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
|
||||
? "추가 정보를 로딩 중입니다..."
|
||||
: detailPopupData
|
||||
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
|
||||
: "선택된 항목의 상세 정보입니다."}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,13 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
||||
|
||||
// 공차/운행 정보 상태
|
||||
const [tripInfo, setTripInfo] = useState<Record<string, any>>({});
|
||||
const [tripInfoLoading, setTripInfoLoading] = useState<string | null>(null);
|
||||
|
||||
// Popup 열림 상태 (자동 새로고침 일시 중지용)
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
|
||||
// 지역 필터 상태
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>("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),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px]">
|
||||
|
|
@ -1621,7 +1680,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Marker key={marker.id} position={[marker.lat, marker.lng]} icon={markerIcon}>
|
||||
<Marker
|
||||
key={marker.id}
|
||||
position={[marker.lat, marker.lng]}
|
||||
icon={markerIcon}
|
||||
eventHandlers={{
|
||||
popupopen: () => setIsPopupOpen(true),
|
||||
popupclose: () => setIsPopupOpen(false),
|
||||
}}
|
||||
>
|
||||
<Popup maxWidth={350}>
|
||||
<div className="max-w-[350px] min-w-[250px]" dir="ltr">
|
||||
{/* 데이터 소스명만 표시 */}
|
||||
|
|
@ -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 (
|
||||
<div className="border-t pt-2 mt-2">
|
||||
<button
|
||||
onClick={() => loadTripInfo(identifier)}
|
||||
disabled={tripInfoLoading === identifier}
|
||||
className="w-full rounded bg-gray-100 px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
{tripInfoLoading === identifier ? "로딩 중..." : "📊 운행/공차 정보 보기"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터가 없으면 표시 안 함
|
||||
if (!hasEmptyTripInfo && !hasTripInfo) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t pt-2 mt-2">
|
||||
{/* 운행 정보 */}
|
||||
{hasTripInfo && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-semibold text-blue-600 mb-1">🚛 최근 운행</div>
|
||||
<div className="bg-blue-50 rounded p-2 space-y-1">
|
||||
{(info.last_trip_start || info.last_trip_end) && (
|
||||
<div className="text-[10px] text-gray-600">
|
||||
<span className="font-medium">시간:</span>{" "}
|
||||
{formatDateTime(info.last_trip_start)} ~ {formatDateTime(info.last_trip_end)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 text-[10px]">
|
||||
{info.last_trip_distance !== undefined && info.last_trip_distance !== null && (
|
||||
<span>
|
||||
<span className="font-medium text-gray-600">거리:</span>{" "}
|
||||
<span className="text-blue-700 font-semibold">{formatDistance(info.last_trip_distance)}</span>
|
||||
</span>
|
||||
)}
|
||||
{info.last_trip_time !== undefined && info.last_trip_time !== null && (
|
||||
<span>
|
||||
<span className="font-medium text-gray-600">소요:</span>{" "}
|
||||
<span className="text-blue-700 font-semibold">{formatTime(info.last_trip_time)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 출발지/도착지 */}
|
||||
{(info.departure || info.arrival) && (
|
||||
<div className="text-[10px] text-gray-600 pt-1 border-t border-blue-100">
|
||||
{info.departure && <span>출발: {info.departure}</span>}
|
||||
{info.departure && info.arrival && " → "}
|
||||
{info.arrival && <span>도착: {info.arrival}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공차 정보 */}
|
||||
{hasEmptyTripInfo && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-orange-600 mb-1">📦 최근 공차</div>
|
||||
<div className="bg-orange-50 rounded p-2 space-y-1">
|
||||
{(info.last_empty_start || info.last_empty_end) && (
|
||||
<div className="text-[10px] text-gray-600">
|
||||
<span className="font-medium">시간:</span>{" "}
|
||||
{formatDateTime(info.last_empty_start)} ~ {formatDateTime(info.last_empty_end)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 text-[10px]">
|
||||
{info.last_empty_distance !== undefined && info.last_empty_distance !== null && (
|
||||
<span>
|
||||
<span className="font-medium text-gray-600">거리:</span>{" "}
|
||||
<span className="text-orange-700 font-semibold">{formatDistance(info.last_empty_distance)}</span>
|
||||
</span>
|
||||
)}
|
||||
{info.last_empty_time !== undefined && info.last_empty_time !== null && (
|
||||
<span>
|
||||
<span className="font-medium text-gray-600">소요:</span>{" "}
|
||||
<span className="text-orange-700 font-semibold">{formatTime(info.last_empty_time)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* 좌표 */}
|
||||
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue