ERP-node/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx

669 lines
32 KiB
TypeScript

"use client";
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;
config: ListWidgetConfig;
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
}
/**
* 리스트 위젯 설정 섹션
* - 컬럼 설정
* - 테이블 옵션
* - 행 클릭 팝업 설정
*/
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">
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
{queryResult && queryResult.columns.length > 0 && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<UnifiedColumnEditor queryResult={queryResult} config={config} onConfigChange={onConfigChange} />
</div>
)}
{/* 테이블 옵션 */}
{config.columns.length > 0 && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<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, queryMode: "table", tableName: "", matchColumn: "" },
})
}
aria-label="추가 데이터 조회 활성화"
/>
</div>
{popupConfig.additionalQuery?.enabled && (
<div className="space-y-2">
{/* 조회 모드 선택 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={popupConfig.additionalQuery?.queryMode || "table"}
onValueChange={(value: "table" | "custom") =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
})
}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="table"> </SelectItem>
<SelectItem value="custom"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 테이블 조회 모드 */}
{(popupConfig.additionalQuery?.queryMode || "table") === "table" && (
<>
<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>
</>
)}
{/* 커스텀 쿼리 모드 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (
<>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label className="text-xs"> </Label>
<textarea
value={popupConfig.additionalQuery?.customQuery || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, customQuery: e.target.value },
})
}
placeholder={`SELECT
v.vehicle_number AS "차량번호",
ROUND(SUM(ts.loaded_distance_km)::NUMERIC, 2) AS "운행거리"
FROM vehicles v
LEFT JOIN transport_statistics ts ON v.id = ts.vehicle_id
WHERE v.id = {id}
GROUP BY v.id;`}
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-muted-foreground mt-1 text-xs">
{"{id}"}, {"{vehicle_number}"}
</p>
</div>
</>
)}
{/* 표시할 컬럼 선택 - 테이블 모드와 커스텀 쿼리 모드 분기 */}
<div>
<Label className="text-xs"> </Label>
{/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (
<>
<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?.queryMode === "custom" && (
<>
<p className="text-muted-foreground mt-1 text-xs">
.
AS "라벨명" alias를 .
</p>
<div className="mt-2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || []), { column: "", label: "" }];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Plus className="h-3 w-3" />
()
</Button>
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
)}
</div>
</>
)}
{/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (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>
)}
{/* 커스텀 쿼리 모드: 직접 입력 컬럼 편집 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<p className="text-muted-foreground text-xs"> </p>
<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={index} className="flex items-center gap-2">
<Input
value={column}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column: e.target.value, label: label || e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="컬럼명 (쿼리 결과)"
className="h-7 flex-1 text-xs"
/>
<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(
(_, i) => i !== index
);
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>
);
}