442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* FieldChooser 컴포넌트
|
|
* 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달
|
|
*/
|
|
|
|
import React, { useState, useMemo } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Search,
|
|
Filter,
|
|
Columns,
|
|
Rows,
|
|
BarChart3,
|
|
GripVertical,
|
|
Plus,
|
|
Minus,
|
|
Type,
|
|
Hash,
|
|
Calendar,
|
|
ToggleLeft,
|
|
} from "lucide-react";
|
|
|
|
// ==================== 타입 ====================
|
|
|
|
interface AvailableField {
|
|
field: string;
|
|
caption: string;
|
|
dataType: "string" | "number" | "date" | "boolean";
|
|
isSelected: boolean;
|
|
currentArea?: PivotAreaType;
|
|
}
|
|
|
|
interface FieldChooserProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
availableFields: AvailableField[];
|
|
selectedFields: PivotFieldConfig[];
|
|
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
|
}
|
|
|
|
// ==================== 영역 설정 ====================
|
|
|
|
const AREA_OPTIONS: {
|
|
value: PivotAreaType | "none";
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
}[] = [
|
|
{ value: "none", label: "사용 안함", icon: <Minus className="h-3.5 w-3.5" /> },
|
|
{ value: "filter", label: "필터", icon: <Filter className="h-3.5 w-3.5" /> },
|
|
{ value: "row", label: "행", icon: <Rows className="h-3.5 w-3.5" /> },
|
|
{ value: "column", label: "열", icon: <Columns className="h-3.5 w-3.5" /> },
|
|
{ value: "data", label: "데이터", icon: <BarChart3 className="h-3.5 w-3.5" /> },
|
|
];
|
|
|
|
const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [
|
|
{ value: "sum", label: "합계" },
|
|
{ value: "count", label: "개수" },
|
|
{ value: "avg", label: "평균" },
|
|
{ value: "min", label: "최소" },
|
|
{ value: "max", label: "최대" },
|
|
{ value: "countDistinct", label: "고유 개수" },
|
|
];
|
|
|
|
const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
|
{ value: "absoluteValue", label: "절대값" },
|
|
{ value: "percentOfRowTotal", label: "행 총계 %" },
|
|
{ value: "percentOfColumnTotal", label: "열 총계 %" },
|
|
{ value: "percentOfGrandTotal", label: "전체 총계 %" },
|
|
{ value: "runningTotalByRow", label: "행 누계" },
|
|
{ value: "runningTotalByColumn", label: "열 누계" },
|
|
{ value: "differenceFromPrevious", label: "이전 대비 차이" },
|
|
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
|
];
|
|
|
|
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
|
string: <Type className="h-3.5 w-3.5" />,
|
|
number: <Hash className="h-3.5 w-3.5" />,
|
|
date: <Calendar className="h-3.5 w-3.5" />,
|
|
boolean: <ToggleLeft className="h-3.5 w-3.5" />,
|
|
};
|
|
|
|
// ==================== 필드 아이템 ====================
|
|
|
|
interface FieldItemProps {
|
|
field: AvailableField;
|
|
config?: PivotFieldConfig;
|
|
onAreaChange: (area: PivotAreaType | "none") => void;
|
|
onSummaryChange?: (summary: AggregationType) => void;
|
|
onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void;
|
|
}
|
|
|
|
const FieldItem: React.FC<FieldItemProps> = ({
|
|
field,
|
|
config,
|
|
onAreaChange,
|
|
onSummaryChange,
|
|
onDisplayModeChange,
|
|
}) => {
|
|
const currentArea = config?.area || "none";
|
|
const isSelected = currentArea !== "none";
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-3 p-2 rounded-md border",
|
|
"transition-colors",
|
|
isSelected
|
|
? "bg-primary/5 border-primary/30"
|
|
: "bg-background border-border hover:bg-muted/50"
|
|
)}
|
|
>
|
|
{/* 데이터 타입 아이콘 */}
|
|
<div className="text-muted-foreground">
|
|
{DATA_TYPE_ICONS[field.dataType] || <Type className="h-3.5 w-3.5" />}
|
|
</div>
|
|
|
|
{/* 필드명 */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{field.caption}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{field.field}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 영역 선택 */}
|
|
<Select
|
|
value={currentArea}
|
|
onValueChange={(value) => onAreaChange(value as PivotAreaType | "none")}
|
|
>
|
|
<SelectTrigger className="w-28 h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AREA_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
<div className="flex items-center gap-2">
|
|
{option.icon}
|
|
<span>{option.label}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 집계 함수 선택 (데이터 영역인 경우) */}
|
|
{currentArea === "data" && onSummaryChange && (
|
|
<Select
|
|
value={config?.summaryType || "sum"}
|
|
onValueChange={(value) => onSummaryChange(value as AggregationType)}
|
|
>
|
|
<SelectTrigger className="w-24 h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SUMMARY_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{/* 표시 모드 선택 (데이터 영역인 경우) */}
|
|
{currentArea === "data" && onDisplayModeChange && (
|
|
<Select
|
|
value={config?.summaryDisplayMode || "absoluteValue"}
|
|
onValueChange={(value) => onDisplayModeChange(value as SummaryDisplayMode)}
|
|
>
|
|
<SelectTrigger className="w-28 h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DISPLAY_MODE_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ==================== 메인 컴포넌트 ====================
|
|
|
|
export const FieldChooser: React.FC<FieldChooserProps> = ({
|
|
open,
|
|
onOpenChange,
|
|
availableFields,
|
|
selectedFields,
|
|
onFieldsChange,
|
|
}) => {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">(
|
|
"all"
|
|
);
|
|
|
|
// 필터링된 필드 목록
|
|
const filteredFields = useMemo(() => {
|
|
let result = availableFields;
|
|
|
|
// 검색어 필터
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(
|
|
(f) =>
|
|
f.caption.toLowerCase().includes(query) ||
|
|
f.field.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// 선택 상태 필터
|
|
if (filterType === "selected") {
|
|
result = result.filter((f) =>
|
|
selectedFields.some((sf) => sf.field === f.field && sf.visible !== false)
|
|
);
|
|
} else if (filterType === "unselected") {
|
|
result = result.filter(
|
|
(f) =>
|
|
!selectedFields.some(
|
|
(sf) => sf.field === f.field && sf.visible !== false
|
|
)
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}, [availableFields, selectedFields, searchQuery, filterType]);
|
|
|
|
// 필드 영역 변경
|
|
const handleAreaChange = (
|
|
field: AvailableField,
|
|
area: PivotAreaType | "none"
|
|
) => {
|
|
const existingConfig = selectedFields.find((f) => f.field === field.field);
|
|
|
|
if (area === "none") {
|
|
// 필드 제거 또는 숨기기
|
|
if (existingConfig) {
|
|
const newFields = selectedFields.map((f) =>
|
|
f.field === field.field ? { ...f, visible: false } : f
|
|
);
|
|
onFieldsChange(newFields);
|
|
}
|
|
} else {
|
|
// 필드 추가 또는 영역 변경
|
|
if (existingConfig) {
|
|
const newFields = selectedFields.map((f) =>
|
|
f.field === field.field
|
|
? { ...f, area, visible: true }
|
|
: f
|
|
);
|
|
onFieldsChange(newFields);
|
|
} else {
|
|
// 새 필드 추가
|
|
const newField: PivotFieldConfig = {
|
|
field: field.field,
|
|
caption: field.caption,
|
|
area,
|
|
dataType: field.dataType,
|
|
visible: true,
|
|
summaryType: area === "data" ? "sum" : undefined,
|
|
areaIndex: selectedFields.filter((f) => f.area === area).length,
|
|
};
|
|
onFieldsChange([...selectedFields, newField]);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 집계 함수 변경
|
|
const handleSummaryChange = (
|
|
field: AvailableField,
|
|
summaryType: AggregationType
|
|
) => {
|
|
const newFields = selectedFields.map((f) =>
|
|
f.field === field.field ? { ...f, summaryType } : f
|
|
);
|
|
onFieldsChange(newFields);
|
|
};
|
|
|
|
// 표시 모드 변경
|
|
const handleDisplayModeChange = (
|
|
field: AvailableField,
|
|
displayMode: SummaryDisplayMode
|
|
) => {
|
|
const newFields = selectedFields.map((f) =>
|
|
f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f
|
|
);
|
|
onFieldsChange(newFields);
|
|
};
|
|
|
|
// 모든 필드 선택 해제
|
|
const handleClearAll = () => {
|
|
const newFields = selectedFields.map((f) => ({ ...f, visible: false }));
|
|
onFieldsChange(newFields);
|
|
};
|
|
|
|
// 통계
|
|
const stats = useMemo(() => {
|
|
const visible = selectedFields.filter((f) => f.visible !== false);
|
|
return {
|
|
total: availableFields.length,
|
|
selected: visible.length,
|
|
filter: visible.filter((f) => f.area === "filter").length,
|
|
row: visible.filter((f) => f.area === "row").length,
|
|
column: visible.filter((f) => f.area === "column").length,
|
|
data: visible.filter((f) => f.area === "data").length,
|
|
};
|
|
}, [availableFields, selectedFields]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>필드 선택기</DialogTitle>
|
|
<DialogDescription>
|
|
피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* 통계 */}
|
|
<div className="flex items-center gap-4 py-2 px-1 text-xs text-muted-foreground border-b border-border">
|
|
<span>전체: {stats.total}</span>
|
|
<span className="text-primary font-medium">
|
|
선택됨: {stats.selected}
|
|
</span>
|
|
<span>필터: {stats.filter}</span>
|
|
<span>행: {stats.row}</span>
|
|
<span>열: {stats.column}</span>
|
|
<span>데이터: {stats.data}</span>
|
|
</div>
|
|
|
|
{/* 검색 및 필터 */}
|
|
<div className="flex items-center gap-2 py-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="필드 검색..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
|
|
<Select
|
|
value={filterType}
|
|
onValueChange={(v) =>
|
|
setFilterType(v as "all" | "selected" | "unselected")
|
|
}
|
|
>
|
|
<SelectTrigger className="w-32 h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
<SelectItem value="selected">선택됨</SelectItem>
|
|
<SelectItem value="unselected">미선택</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleClearAll}
|
|
className="h-9"
|
|
>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 필드 목록 */}
|
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
|
<div className="space-y-2 py-2">
|
|
{filteredFields.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
검색 결과가 없습니다.
|
|
</div>
|
|
) : (
|
|
filteredFields.map((field) => {
|
|
const config = selectedFields.find(
|
|
(f) => f.field === field.field && f.visible !== false
|
|
);
|
|
return (
|
|
<FieldItem
|
|
key={field.field}
|
|
field={field}
|
|
config={config}
|
|
onAreaChange={(area) => handleAreaChange(field, area)}
|
|
onSummaryChange={
|
|
config?.area === "data"
|
|
? (summary) => handleSummaryChange(field, summary)
|
|
: undefined
|
|
}
|
|
onDisplayModeChange={
|
|
config?.area === "data"
|
|
? (mode) => handleDisplayModeChange(field, mode)
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* 푸터 */}
|
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
닫기
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default FieldChooser;
|
|
|