Merge pull request 'lhj' (#352) from lhj into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/352
This commit is contained in:
hjlee 2026-01-12 17:26:48 +09:00
commit 3fa57ad2ae
10 changed files with 2300 additions and 1222 deletions

View File

@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Trash2, Plus } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management"; import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { UnifiedColumnInfo } from "@/types/table-management"; import { UnifiedColumnInfo } from "@/types/table-management";
import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { getCategoryValues } from "@/lib/api/tableCategoryValue";
@ -19,6 +20,67 @@ interface DataFilterConfigPanelProps {
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요) menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
} }
/**
*
*/
interface FilterItemCollapsibleProps {
filter: ColumnFilter;
index: number;
filterSummary: string;
onRemove: () => void;
children: React.ReactNode;
}
const FilterItemCollapsible: React.FC<FilterItemCollapsibleProps> = ({
filter,
index,
filterSummary,
onRemove,
children,
}) => {
const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="rounded-lg border p-2">
<CollapsibleTrigger asChild>
<div className="hover:bg-muted/50 cursor-pointer rounded p-1">
{/* 상단: 필터 번호 + 삭제 버튼 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{isOpen ? (
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
) : (
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0" />
)}
<span className="text-muted-foreground text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 shrink-0 p-0"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 하단: 필터 요약 (전체 너비 사용) */}
<div className="mt-1 pl-4">
<span className="text-xs font-medium text-blue-600" title={filterSummary}>
{filterSummary}
</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">{children}</CollapsibleContent>
</div>
</Collapsible>
);
};
/** /**
* *
* , , * , ,
@ -42,7 +104,7 @@ export function DataFilterConfigPanel({
enabled: false, enabled: false,
filters: [], filters: [],
matchType: "all", matchType: "all",
} },
); );
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록) // 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
@ -69,7 +131,7 @@ export function DataFilterConfigPanel({
return; // 이미 로드되었거나 로딩 중이면 스킵 return; // 이미 로드되었거나 로딩 중이면 스킵
} }
setLoadingCategories(prev => ({ ...prev, [columnName]: true })); setLoadingCategories((prev) => ({ ...prev, [columnName]: true }));
try { try {
console.log("🔍 카테고리 값 로드 시작:", { console.log("🔍 카테고리 값 로드 시작:", {
@ -82,7 +144,7 @@ export function DataFilterConfigPanel({
tableName, tableName,
columnName, columnName,
false, // includeInactive false, // includeInactive
menuObjid // 🆕 메뉴 OBJID 전달 menuObjid, // 🆕 메뉴 OBJID 전달
); );
console.log("📦 카테고리 값 로드 응답:", response); console.log("📦 카테고리 값 로드 응답:", response);
@ -94,14 +156,14 @@ export function DataFilterConfigPanel({
})); }));
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length }); console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
setCategoryValues(prev => ({ ...prev, [columnName]: values })); setCategoryValues((prev) => ({ ...prev, [columnName]: values }));
} else { } else {
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response); console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
} }
} catch (error) { } catch (error) {
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error); console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
} finally { } finally {
setLoadingCategories(prev => ({ ...prev, [columnName]: false })); setLoadingCategories((prev) => ({ ...prev, [columnName]: false }));
} }
}; };
@ -145,9 +207,7 @@ export function DataFilterConfigPanel({
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => { const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
const newConfig = { const newConfig = {
...localConfig, ...localConfig,
filters: localConfig.filters.map((filter) => filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)),
filter.id === filterId ? { ...filter, [field]: value } : filter
),
}; };
setLocalConfig(newConfig); setLocalConfig(newConfig);
onConfigChange(newConfig); onConfigChange(newConfig);
@ -178,7 +238,7 @@ export function DataFilterConfigPanel({
<> <>
{/* 테이블명 표시 */} {/* 테이블명 표시 */}
{tableName && ( {tableName && (
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
: <span className="font-medium">{tableName}</span> : <span className="font-medium">{tableName}</span>
</div> </div>
)} )}
@ -200,23 +260,48 @@ export function DataFilterConfigPanel({
)} )}
{/* 필터 목록 */} {/* 필터 목록 */}
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2"> <div className="max-h-[600px] space-y-2 overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => ( {localConfig.filters.map((filter, index) => {
<div key={filter.id} className="rounded-lg border p-3 space-y-2"> // 연산자 표시 텍스트
<div className="flex items-center justify-between mb-2"> const operatorLabels: Record<string, string> = {
<span className="text-xs font-medium text-muted-foreground"> equals: "=",
{index + 1} not_equals: "!=",
</span> greater_than: ">",
<Button less_than: "<",
variant="ghost" greater_than_or_equal: ">=",
size="sm" less_than_or_equal: "<=",
className="h-6 w-6 p-0" between: "BETWEEN",
onClick={() => handleRemoveFilter(filter.id)} in: "IN",
> not_in: "NOT IN",
<Trash2 className="h-3 w-3" /> contains: "LIKE",
</Button> starts_with: "시작",
</div> ends_with: "끝",
is_null: "IS NULL",
is_not_null: "IS NOT NULL",
date_range_contains: "기간 내",
};
// 컬럼 라벨 찾기
const columnLabel =
columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName;
// 필터 요약 텍스트 생성
const filterSummary = filter.columnName
? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${
filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value
? ` ${filter.value}`
: ""
}`
: "설정 필요";
return (
<FilterItemCollapsible
key={filter.id}
filter={filter}
index={index}
filterSummary={filterSummary}
onRemove={() => handleRemoveFilter(filter.id)}
>
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */} {/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
{filter.operator !== "date_range_contains" && ( {filter.operator !== "date_range_contains" && (
<div> <div>
@ -246,9 +331,7 @@ export function DataFilterConfigPanel({
const newConfig = { const newConfig = {
...localConfig, ...localConfig,
filters: localConfig.filters.map((f) => filters: localConfig.filters.map((f) =>
f.id === filter.id f.id === filter.id ? { ...f, columnName: value, valueType, value: "" } : f,
? { ...f, columnName: value, valueType, value: "" }
: f
), ),
}; };
@ -271,9 +354,7 @@ export function DataFilterConfigPanel({
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && ( {(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground"> <span className="text-muted-foreground ml-2 text-xs">({col.input_type})</span>
({col.input_type})
</span>
)} )}
</SelectItem> </SelectItem>
))} ))}
@ -293,9 +374,7 @@ export function DataFilterConfigPanel({
const newConfig = { const newConfig = {
...localConfig, ...localConfig,
filters: localConfig.filters.map((f) => filters: localConfig.filters.map((f) =>
f.id === filter.id f.id === filter.id ? { ...f, operator: value, valueType: "dynamic", value: "TODAY" } : f,
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
: f
), ),
}; };
setLocalConfig(newConfig); setLocalConfig(newConfig);
@ -332,7 +411,7 @@ export function DataFilterConfigPanel({
{filter.operator === "date_range_contains" && ( {filter.operator === "date_range_contains" && (
<> <>
<div className="col-span-2"> <div className="col-span-2">
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded"> <p className="text-muted-foreground bg-muted/50 rounded p-2 text-xs">
💡 : 💡 :
<br /> NULL <br /> NULL
<br /> NULL <br /> NULL
@ -356,10 +435,13 @@ export function DataFilterConfigPanel({
<SelectValue placeholder="시작일 컬럼 선택" /> <SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{columns.filter(col => {columns
col.dataType?.toLowerCase().includes('date') || .filter(
col.dataType?.toLowerCase().includes('time') (col) =>
).map((col) => ( col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}
</SelectItem> </SelectItem>
@ -384,10 +466,13 @@ export function DataFilterConfigPanel({
<SelectValue placeholder="종료일 컬럼 선택" /> <SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{columns.filter(col => {columns
col.dataType?.toLowerCase().includes('date') || .filter(
col.dataType?.toLowerCase().includes('time') (col) =>
).map((col) => ( col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}
</SelectItem> </SelectItem>
@ -410,9 +495,7 @@ export function DataFilterConfigPanel({
const newConfig = { const newConfig = {
...localConfig, ...localConfig,
filters: localConfig.filters.map((f) => filters: localConfig.filters.map((f) =>
f.id === filter.id f.id === filter.id ? { ...f, valueType: value, value: "TODAY" } : f,
? { ...f, valueType: value, value: "TODAY" }
: f
), ),
}; };
setLocalConfig(newConfig); setLocalConfig(newConfig);
@ -422,9 +505,7 @@ export function DataFilterConfigPanel({
const newConfig = { const newConfig = {
...localConfig, ...localConfig,
filters: localConfig.filters.map((f) => filters: localConfig.filters.map((f) =>
f.id === filter.id f.id === filter.id ? { ...f, valueType: value, value: "" } : f,
? { ...f, valueType: value, value: "" }
: f
), ),
}; };
setLocalConfig(newConfig); setLocalConfig(newConfig);
@ -464,9 +545,9 @@ export function DataFilterConfigPanel({
onValueChange={(value) => handleFilterChange(filter.id, "value", value)} onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={ <SelectValue
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택" placeholder={loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"}
} /> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{categoryValues[filter.columnName].map((option) => ( {categoryValues[filter.columnName].map((option) => (
@ -491,7 +572,11 @@ export function DataFilterConfigPanel({
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value} value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onChange={(e) => { onChange={(e) => {
const values = e.target.value.split("~").map((v) => v.trim()); const values = e.target.value.split("~").map((v) => v.trim());
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]); handleFilterChange(
filter.id,
"value",
values.length === 2 ? values : [values[0] || "", ""],
);
}} }}
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)" placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-8 text-xs sm:h-10 sm:text-sm"
@ -501,11 +586,13 @@ export function DataFilterConfigPanel({
type={filter.operator === "date_range_contains" ? "date" : "text"} type={filter.operator === "date_range_contains" ? "date" : "text"}
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value} value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)} onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"} placeholder={
filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"
}
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-8 text-xs sm:h-10 sm:text-sm"
/> />
)} )}
<p className="text-[10px] text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-[10px]">
{filter.valueType === "category" && categoryValues[filter.columnName] {filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요" ? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in" : filter.operator === "in" || filter.operator === "not_in"
@ -522,20 +609,19 @@ export function DataFilterConfigPanel({
{/* date_range_contains의 dynamic 타입 안내 */} {/* date_range_contains의 dynamic 타입 안내 */}
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( {filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
<div className="rounded-md bg-blue-50 p-2"> <div className="rounded-md bg-blue-50 p-2">
<p className="text-[10px] text-blue-700"> <p className="text-[10px] text-blue-700"> .</p>
.
</p>
</div> </div>
)} )}
</div> </FilterItemCollapsible>
))} );
})}
</div> </div>
{/* 필터 추가 버튼 */} {/* 필터 추가 버튼 */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full h-8 text-xs sm:h-10 sm:text-sm" className="h-8 w-full text-xs sm:h-10 sm:text-sm"
onClick={handleAddFilter} onClick={handleAddFilter}
disabled={columns.length === 0} disabled={columns.length === 0}
> >
@ -544,13 +630,10 @@ export function DataFilterConfigPanel({
</Button> </Button>
{columns.length === 0 && ( {columns.length === 0 && (
<p className="text-xs text-muted-foreground text-center"> <p className="text-muted-foreground text-center text-xs"> </p>
</p>
)} )}
</> </>
)} )}
</div> </div>
); );
} }

View File

@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
// 🆕 연관 데이터 버튼 컴포넌트 // 🆕 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
// 🆕 피벗 그리드 컴포넌트
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
/** /**
* *
*/ */

View File

@ -0,0 +1,213 @@
"use client";
/**
* PivotGrid
* , , /
*/
import React from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
ArrowUpAZ,
ArrowDownAZ,
Filter,
ChevronDown,
ChevronRight,
Copy,
Eye,
EyeOff,
BarChart3,
} from "lucide-react";
import { PivotFieldConfig, AggregationType } from "../types";
interface PivotContextMenuProps {
children: React.ReactNode;
// 현재 컨텍스트 정보
cellType: "header" | "data" | "rowHeader" | "columnHeader";
field?: PivotFieldConfig;
rowPath?: string[];
columnPath?: string[];
value?: any;
// 콜백
onSort?: (field: string, direction: "asc" | "desc") => void;
onFilter?: (field: string) => void;
onExpand?: (path: string[]) => void;
onCollapse?: (path: string[]) => void;
onExpandAll?: () => void;
onCollapseAll?: () => void;
onCopy?: (value: any) => void;
onHideField?: (field: string) => void;
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
}
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
children,
cellType,
field,
rowPath,
columnPath,
value,
onSort,
onFilter,
onExpand,
onCollapse,
onExpandAll,
onCollapseAll,
onCopy,
onHideField,
onChangeSummary,
onDrillDown,
}) => {
const handleCopy = () => {
if (value !== undefined && value !== null) {
navigator.clipboard.writeText(String(value));
onCopy?.(value);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 정렬 옵션 (헤더에서만) */}
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
<ArrowDownAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 확장/축소 옵션 */}
{(cellType === "rowHeader" || cellType === "columnHeader") && (
<>
{rowPath && rowPath.length > 0 && (
<>
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={onExpandAll}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={onCollapseAll}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필터 옵션 */}
{field && onFilter && (
<>
<ContextMenuItem onClick={() => onFilter(field.field)}>
<Filter className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 집계 함수 변경 (데이터 필드에서만) */}
{cellType === "data" && field && onChangeSummary && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<BarChart3 className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "sum")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "count")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "avg")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "min")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "max")}
>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 드릴다운 (데이터 셀에서만) */}
{cellType === "data" && rowPath && columnPath && onDrillDown && (
<>
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
<Eye className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필드 숨기기 */}
{field && onHideField && (
<ContextMenuItem onClick={() => onHideField(field.field)}>
<EyeOff className="mr-2 h-4 w-4" />
</ContextMenuItem>
)}
{/* 복사 */}
<ContextMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export default PivotContextMenu;

View File

@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" }, { value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
]; ];
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
{ value: "none", label: "그룹 없음" },
{ value: "year", label: "년" },
{ value: "quarter", label: "분기" },
{ value: "month", label: "월" },
{ value: "week", label: "주" },
{ value: "day", label: "일" },
];
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = { const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
string: <Type className="h-3.5 w-3.5" />, string: <Type className="h-3.5 w-3.5" />,
number: <Hash className="h-3.5 w-3.5" />, number: <Hash className="h-3.5 w-3.5" />,

View File

@ -2,7 +2,7 @@
/** /**
* FieldPanel * FieldPanel
* (, , , ) * (, , )
* *
*/ */
@ -247,7 +247,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
return ( return (
<div <div
className={cn( className={cn(
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2", "flex-1 min-h-[44px] rounded border border-dashed p-1.5",
"transition-colors duration-200", "transition-colors duration-200",
config.color, config.color,
isOver && "border-primary bg-primary/5" isOver && "border-primary bg-primary/5"
@ -255,7 +255,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
data-area={area} data-area={area}
> >
{/* 영역 헤더 */} {/* 영역 헤더 */}
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground"> <div className="flex items-center gap-1 mb-1 text-[11px] font-medium text-muted-foreground">
{icon} {icon}
<span>{title}</span> <span>{title}</span>
{areaFields.length > 0 && ( {areaFields.length > 0 && (
@ -267,9 +267,9 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
{/* 필드 목록 */} {/* 필드 목록 */}
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}> <SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
<div className="flex flex-wrap gap-1.5 min-h-[28px]"> <div className="flex flex-wrap gap-1 min-h-[22px]">
{areaFields.length === 0 ? ( {areaFields.length === 0 ? (
<span className="text-xs text-muted-foreground/50 italic"> <span className="text-[10px] text-muted-foreground/50 italic">
</span> </span>
) : ( ) : (
@ -443,16 +443,42 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
? fields.find((f) => `${f.area}-${f.field}` === activeId) ? fields.find((f) => `${f.area}-${f.field}` === activeId)
: null; : null;
// 각 영역의 필드 수 계산
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
if (collapsed) { if (collapsed) {
return ( return (
<div className="border-b border-border px-3 py-2"> <div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{filterCount > 0 && (
<span className="flex items-center gap-1">
<Filter className="h-3 w-3" />
{filterCount}
</span>
)}
<span className="flex items-center gap-1">
<Columns className="h-3 w-3" />
{columnCount}
</span>
<span className="flex items-center gap-1">
<Rows className="h-3 w-3" />
{rowCount}
</span>
<span className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
{dataCount}
</span>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-xs" className="text-xs h-6 px-2"
> >
</Button> </Button>
</div> </div>
); );
@ -466,9 +492,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="border-b border-border bg-muted/20 p-3"> <div className="border-b border-border bg-muted/20 p-2">
{/* 2x2 그리드로 영역 배치 */} {/* 4개 영역 배치: 2x2 그리드 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-1.5">
{/* 필터 영역 */} {/* 필터 영역 */}
<DroppableArea <DroppableArea
area="filter" area="filter"
@ -516,12 +542,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
{/* 접기 버튼 */} {/* 접기 버튼 */}
{onToggleCollapse && ( {onToggleCollapse && (
<div className="flex justify-center mt-2"> <div className="flex justify-center mt-1.5">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-xs h-6" className="text-xs h-5 px-2"
> >
</Button> </Button>

View File

@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
export { DrillDownModal } from "./DrillDownModal"; export { DrillDownModal } from "./DrillDownModal";
export { FilterPopup } from "./FilterPopup"; export { FilterPopup } from "./FilterPopup";
export { PivotChart } from "./PivotChart"; export { PivotChart } from "./PivotChart";
export { PivotContextMenu } from "./ContextMenu";

View File

@ -90,6 +90,10 @@ export interface PivotFieldConfig {
// 계층 관련 // 계층 관련
displayFolder?: string; // 필드 선택기에서 폴더 구조 displayFolder?: string; // 필드 선택기에서 폴더 구조
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능) isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
// 계산 필드
isCalculated?: boolean; // 계산 필드 여부
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
} }
// ==================== 데이터 소스 설정 ==================== // ==================== 데이터 소스 설정 ====================
@ -140,11 +144,13 @@ export interface PivotTotalsConfig {
showRowGrandTotals?: boolean; // 행 총합계 표시 showRowGrandTotals?: boolean; // 행 총합계 표시
showRowTotals?: boolean; // 행 소계 표시 showRowTotals?: boolean; // 행 소계 표시
rowTotalsPosition?: "first" | "last"; // 소계 위치 rowTotalsPosition?: "first" | "last"; // 소계 위치
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
// 열 총합계 // 열 총합계
showColumnGrandTotals?: boolean; // 열 총합계 표시 showColumnGrandTotals?: boolean; // 열 총합계 표시
showColumnTotals?: boolean; // 열 소계 표시 showColumnTotals?: boolean; // 열 소계 표시
columnTotalsPosition?: "first" | "last"; // 소계 위치 columnTotalsPosition?: "first" | "last"; // 소계 위치
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
} }
// 필드 선택기 설정 // 필드 선택기 설정
@ -214,6 +220,7 @@ export interface PivotStyleConfig {
alternateRowColors?: boolean; alternateRowColors?: boolean;
highlightTotals?: boolean; // 총합계 강조 highlightTotals?: boolean; // 총합계 강조
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙 conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
mergeCells?: boolean; // 같은 값 셀 병합
} }
// ==================== 내보내기 설정 ==================== // ==================== 내보내기 설정 ====================

View File

@ -1026,10 +1026,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 추가 dataFilter 적용 // 추가 dataFilter 적용
let filteredData = result.data || []; let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter; const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) { // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
if (dataFilter?.enabled && filterConditions.length > 0) {
filteredData = filteredData.filter((item: any) => { filteredData = filteredData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => { return filterConditions.every((cond: any) => {
const value = item[cond.column]; // columnName 또는 column 지원
const columnName = cond.columnName || cond.column;
const value = item[columnName];
const condValue = cond.value; const condValue = cond.value;
switch (cond.operator) { switch (cond.operator) {
case "equals": case "equals":
@ -1038,6 +1042,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return value !== condValue; return value !== condValue;
case "contains": case "contains":
return String(value).includes(String(condValue)); return String(value).includes(String(condValue));
case "is_null":
case "NULL":
return value === null || value === undefined || value === "";
case "is_not_null":
case "NOT NULL":
return value !== null && value !== undefined && value !== "";
default: default:
return true; return true;
} }
@ -1193,10 +1203,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 데이터 필터 적용 // 데이터 필터 적용
const dataFilter = tabConfig.dataFilter; const dataFilter = tabConfig.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) { // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
if (dataFilter?.enabled && filterConditions.length > 0) {
resultData = resultData.filter((item: any) => { resultData = resultData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => { return filterConditions.every((cond: any) => {
const value = item[cond.column]; // columnName 또는 column 지원
const columnName = cond.columnName || cond.column;
const value = item[columnName];
const condValue = cond.value; const condValue = cond.value;
switch (cond.operator) { switch (cond.operator) {
case "equals": case "equals":
@ -1205,6 +1219,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return value !== condValue; return value !== condValue;
case "contains": case "contains":
return String(value).includes(String(condValue)); return String(value).includes(String(condValue));
case "is_null":
case "NULL":
return value === null || value === undefined || value === "";
case "is_not_null":
case "NOT NULL":
return value !== null && value !== undefined && value !== "";
default: default:
return true; return true;
} }