Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
commit
48300146e6
|
|
@ -379,6 +379,47 @@ export interface ListWidgetConfig {
|
||||||
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
||||||
compactMode: boolean; // 압축 모드 (기본: false)
|
compactMode: boolean; // 압축 모드 (기본: false)
|
||||||
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
|
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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { ListWidgetConfig, QueryResult } from "../types";
|
import { ListWidgetConfig, QueryResult, FieldGroup, FieldConfig, DisplayColumnConfig } from "../types";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
|
||||||
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
|
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 {
|
interface ListWidgetSectionProps {
|
||||||
queryResult: QueryResult | null;
|
queryResult: QueryResult | null;
|
||||||
|
|
@ -16,8 +23,91 @@ interface ListWidgetSectionProps {
|
||||||
* 리스트 위젯 설정 섹션
|
* 리스트 위젯 설정 섹션
|
||||||
* - 컬럼 설정
|
* - 컬럼 설정
|
||||||
* - 테이블 옵션
|
* - 테이블 옵션
|
||||||
|
* - 행 클릭 팝업 설정
|
||||||
*/
|
*/
|
||||||
export function ListWidgetSection({ queryResult, config, onConfigChange }: 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
||||||
|
|
@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
import { Truck, Clock, MapPin, Package, Info } from "lucide-react";
|
||||||
|
|
||||||
interface ListWidgetProps {
|
interface ListWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
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 || {
|
const config = element.listConfig || {
|
||||||
columnMode: "auto",
|
columnMode: "auto",
|
||||||
viewMode: "table",
|
viewMode: "table",
|
||||||
|
|
@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
cardColumns: 3,
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
|
@ -260,7 +484,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paginatedRows.map((row, idx) => (
|
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
|
{displayColumns
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
|
|
@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{paginatedRows.map((row, idx) => (
|
{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">
|
<div className="space-y-2">
|
||||||
{displayColumns
|
{displayColumns
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
|
|
@ -345,6 +577,49 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
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 { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
|
||||||
|
|
@ -34,6 +42,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
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);
|
// // console.log("🧪 ListTestWidget 렌더링!", element);
|
||||||
|
|
||||||
const dataSources = useMemo(() => {
|
const dataSources = useMemo(() => {
|
||||||
|
|
@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
cardColumns: 3,
|
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)
|
// visible 컬럼 설정 객체 배열 (field + label)
|
||||||
const visibleColumnConfigs = useMemo(() => {
|
const visibleColumnConfigs = useMemo(() => {
|
||||||
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
|
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
|
||||||
|
|
@ -368,7 +592,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
)}
|
)}
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedRows.map((row, idx) => (
|
{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) => (
|
{displayColumns.map((field) => (
|
||||||
<TableCell key={field} className="whitespace-nowrap">
|
<TableCell key={field} className="whitespace-nowrap">
|
||||||
{String(row[field] ?? "")}
|
{String(row[field] ?? "")}
|
||||||
|
|
@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
||||||
{paginatedRows.map((row, idx) => (
|
{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) => (
|
{displayColumns.map((field) => (
|
||||||
<div key={field} className="mb-2">
|
<div key={field} className="mb-2">
|
||||||
<span className="font-semibold">{getLabel(field)}: </span>
|
<span className="font-semibold">{getLabel(field)}: </span>
|
||||||
|
|
@ -489,6 +721,49 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,13 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [routeLoading, setRouteLoading] = useState(false);
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
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");
|
const [selectedRegion, setSelectedRegion] = useState<string>("all");
|
||||||
|
|
||||||
|
|
@ -187,6 +194,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
setRoutePoints([]);
|
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 () => {
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
if (!dataSources || dataSources.length === 0) {
|
if (!dataSources || dataSources.length === 0) {
|
||||||
|
|
@ -1135,14 +1187,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
loadMultipleDataSources();
|
// Popup이 열려있으면 자동 새로고침 건너뛰기
|
||||||
|
if (!isPopupOpen) {
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}
|
||||||
}, refreshInterval * 1000);
|
}, refreshInterval * 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dataSources, element?.chartConfig?.refreshInterval]);
|
}, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]);
|
||||||
|
|
||||||
// 타일맵 URL (VWorld 한국 지도)
|
// 타일맵 URL (VWorld 한국 지도)
|
||||||
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
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,
|
fillOpacity: 0.3,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
}}
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
popupopen: () => setIsPopupOpen(true),
|
||||||
|
popupclose: () => setIsPopupOpen(false),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="min-w-[200px]">
|
<div className="min-w-[200px]">
|
||||||
|
|
@ -1621,7 +1680,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}>
|
<Popup maxWidth={350}>
|
||||||
<div className="max-w-[350px] min-w-[250px]" dir="ltr">
|
<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]">
|
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue