diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index 46b4d799..f3e65199 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { UnifiedColumnInfo } from "@/types/table-management"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; @@ -19,6 +20,67 @@ interface DataFilterConfigPanelProps { menuObjid?: number; // ๐Ÿ†• ๋ฉ”๋‰ด OBJID (์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ์กฐํšŒ ์‹œ ํ•„์š”) } +/** + * ์ ‘์„ ์ˆ˜ ์žˆ๋Š” ํ•„ํ„ฐ ํ•ญ๋ชฉ ์ปดํฌ๋„ŒํŠธ + */ +interface FilterItemCollapsibleProps { + filter: ColumnFilter; + index: number; + filterSummary: string; + onRemove: () => void; + children: React.ReactNode; +} + +const FilterItemCollapsible: React.FC = ({ + filter, + index, + filterSummary, + onRemove, + children, +}) => { + const [isOpen, setIsOpen] = useState(!filter.columnName); // ์„ค์ • ์•ˆ ๋œ ํ•„ํ„ฐ๋Š” ์—ด๋ฆฐ ์ƒํƒœ๋กœ + + return ( + +
+ +
+ {/* ์ƒ๋‹จ: ํ•„ํ„ฐ ๋ฒˆํ˜ธ + ์‚ญ์ œ ๋ฒ„ํŠผ */} +
+
+ {isOpen ? ( + + ) : ( + + )} + ํ•„ํ„ฐ {index + 1} +
+ +
+ {/* ํ•˜๋‹จ: ํ•„ํ„ฐ ์š”์•ฝ (์ „์ฒด ๋„ˆ๋น„ ์‚ฌ์šฉ) */} +
+ + {filterSummary} + +
+
+
+ {children} +
+
+ ); +}; + /** * ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ ์„ค์ • ํŒจ๋„ * ํ…Œ์ด๋ธ” ๋ฆฌ์ŠคํŠธ, ๋ถ„ํ•  ํŒจ๋„, ํ”Œ๋กœ์šฐ ์œ„์ ฏ ๋“ฑ์—์„œ ์‚ฌ์šฉ @@ -36,13 +98,13 @@ export function DataFilterConfigPanel({ menuObjid, sampleColumns: columns.slice(0, 3), }); - + const [localConfig, setLocalConfig] = useState( config || { enabled: false, filters: [], matchType: "all", - } + }, ); // ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ์บ์‹œ (์ปฌ๋Ÿผ๋ช… -> ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ๋ชฉ๋ก) @@ -52,7 +114,7 @@ export function DataFilterConfigPanel({ useEffect(() => { if (config) { setLocalConfig(config); - + // ๐Ÿ†• ๊ธฐ์กด ํ•„ํ„ฐ ์ค‘ ์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž…์ธ ๊ฒƒ๋“ค์˜ ๊ฐ’์„ ๋กœ๋“œ config.filters?.forEach((filter) => { if (filter.valueType === "category" && filter.columnName) { @@ -69,7 +131,7 @@ export function DataFilterConfigPanel({ return; // ์ด๋ฏธ ๋กœ๋“œ๋˜์—ˆ๊ฑฐ๋‚˜ ๋กœ๋”ฉ ์ค‘์ด๋ฉด ์Šคํ‚ต } - setLoadingCategories(prev => ({ ...prev, [columnName]: true })); + setLoadingCategories((prev) => ({ ...prev, [columnName]: true })); try { console.log("๐Ÿ” ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ๋กœ๋“œ ์‹œ์ž‘:", { @@ -82,7 +144,7 @@ export function DataFilterConfigPanel({ tableName, columnName, false, // includeInactive - menuObjid // ๐Ÿ†• ๋ฉ”๋‰ด OBJID ์ „๋‹ฌ + menuObjid, // ๐Ÿ†• ๋ฉ”๋‰ด OBJID ์ „๋‹ฌ ); console.log("๐Ÿ“ฆ ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ๋กœ๋“œ ์‘๋‹ต:", response); @@ -92,16 +154,16 @@ export function DataFilterConfigPanel({ value: item.valueCode, label: item.valueLabel, })); - + console.log("โœ… ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ์„ค์ •:", { columnName, valuesCount: values.length }); - setCategoryValues(prev => ({ ...prev, [columnName]: values })); + setCategoryValues((prev) => ({ ...prev, [columnName]: values })); } else { console.warn("โš ๏ธ ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ๋กœ๋“œ ์‹คํŒจ ๋˜๋Š” ๋ฐ์ดํ„ฐ ์—†์Œ:", response); } } catch (error) { console.error(`โŒ ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’ ๋กœ๋“œ ์‹คํŒจ (${columnName}):`, error); } 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 newConfig = { ...localConfig, - filters: localConfig.filters.map((filter) => - filter.id === filterId ? { ...filter, [field]: value } : filter - ), + filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)), }; setLocalConfig(newConfig); onConfigChange(newConfig); @@ -178,7 +238,7 @@ export function DataFilterConfigPanel({ <> {/* ํ…Œ์ด๋ธ”๋ช… ํ‘œ์‹œ */} {tableName && ( -
+
ํ…Œ์ด๋ธ”: {tableName}
)} @@ -200,235 +260,127 @@ export function DataFilterConfigPanel({ )} {/* ํ•„ํ„ฐ ๋ชฉ๋ก */} -
- {localConfig.filters.map((filter, index) => ( -
-
- - ํ•„ํ„ฐ {index + 1} - - -
+
+ {localConfig.filters.map((filter, index) => { + // ์—ฐ์‚ฐ์ž ํ‘œ์‹œ ํ…์ŠคํŠธ + const operatorLabels: Record = { + equals: "=", + not_equals: "!=", + greater_than: ">", + less_than: "<", + greater_than_or_equal: ">=", + less_than_or_equal: "<=", + between: "BETWEEN", + in: "IN", + not_in: "NOT IN", + contains: "LIKE", + starts_with: "์‹œ์ž‘", + ends_with: "๋", + is_null: "IS NULL", + is_not_null: "IS NOT NULL", + date_range_contains: "๊ธฐ๊ฐ„ ๋‚ด", + }; - {/* ์ปฌ๋Ÿผ ์„ ํƒ (๋‚ ์งœ ๋ฒ”์œ„ ํฌํ•จ์ด ์•„๋‹ ๋•Œ๋งŒ ํ‘œ์‹œ) */} - {filter.operator !== "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}` + : "" + }` + : "์„ค์ • ํ•„์š”"; - {/* ๋‚ ์งœ ๋ฒ”์œ„ ํฌํ•จ - ์‹œ์ž‘์ผ/์ข…๋ฃŒ์ผ ์ปฌ๋Ÿผ ์„ ํƒ */} - {filter.operator === "date_range_contains" && ( - <> -
-

- ๐Ÿ’ก ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ๋ง ๊ทœ์น™: -
โ€ข ์‹œ์ž‘์ผ๋งŒ ์žˆ๊ณ  ์ข…๋ฃŒ์ผ์ด NULL โ†’ ์‹œ์ž‘์ผ ์ดํ›„ ๋ชจ๋“  ๋ฐ์ดํ„ฐ -
โ€ข ์ข…๋ฃŒ์ผ๋งŒ ์žˆ๊ณ  ์‹œ์ž‘์ผ์ด NULL โ†’ ์ข…๋ฃŒ์ผ ์ด์ „ ๋ชจ๋“  ๋ฐ์ดํ„ฐ -
โ€ข ๋‘˜ ๋‹ค ์žˆ์œผ๋ฉด โ†’ ๊ธฐ๊ฐ„ ๋‚ด ๋ฐ์ดํ„ฐ๋งŒ -

-
+ return ( + handleRemoveFilter(filter.id)} + > + {/* ์ปฌ๋Ÿผ ์„ ํƒ (๋‚ ์งœ ๋ฒ”์œ„ ํฌํ•จ์ด ์•„๋‹ ๋•Œ๋งŒ ํ‘œ์‹œ) */} + {filter.operator !== "date_range_contains" && (
- + -
-
- - -
- - )} + const column = columns.find((col) => col.columnName === value); - {/* ๊ฐ’ ํƒ€์ž… ์„ ํƒ (์นดํ…Œ๊ณ ๋ฆฌ/์ฝ”๋“œ ์ปฌ๋Ÿผ ๋˜๋Š” date_range_contains) */} - {(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && ( -
- - +
+ )} + + {/* ์—ฐ์‚ฐ์ž ์„ ํƒ */} +
+ +
- )} - {/* ๊ฐ’ ์ž…๋ ฅ (NULL ์ฒดํฌ ๋ฐ date_range_contains์˜ dynamic ์ œ์™ธ) */} - {filter.operator !== "is_null" && - filter.operator !== "is_not_null" && - !(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && ( -
- - {/* ์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž…์ด๊ณ  ๊ฐ’ ํƒ€์ž…์ด category์ธ ๊ฒฝ์šฐ ์…€๋ ‰ํŠธ๋ฐ•์Šค */} - {filter.valueType === "category" && categoryValues[filter.columnName] ? ( + {/* ๋‚ ์งœ ๋ฒ”์œ„ ํฌํ•จ - ์‹œ์ž‘์ผ/์ข…๋ฃŒ์ผ ์ปฌ๋Ÿผ ์„ ํƒ */} + {filter.operator === "date_range_contains" && ( + <> +
+

+ ๐Ÿ’ก ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ๋ง ๊ทœ์น™: +
โ€ข ์‹œ์ž‘์ผ๋งŒ ์žˆ๊ณ  ์ข…๋ฃŒ์ผ์ด NULL โ†’ ์‹œ์ž‘์ผ ์ดํ›„ ๋ชจ๋“  ๋ฐ์ดํ„ฐ +
โ€ข ์ข…๋ฃŒ์ผ๋งŒ ์žˆ๊ณ  ์‹œ์ž‘์ผ์ด NULL โ†’ ์ข…๋ฃŒ์ผ ์ด์ „ ๋ชจ๋“  ๋ฐ์ดํ„ฐ +
โ€ข ๋‘˜ ๋‹ค ์žˆ์œผ๋ฉด โ†’ ๊ธฐ๊ฐ„ ๋‚ด ๋ฐ์ดํ„ฐ๋งŒ +

+
+
+ + +
+
+ + +
+ + )} + + {/* ๊ฐ’ ํƒ€์ž… ์„ ํƒ (์นดํ…Œ๊ณ ๋ฆฌ/์ฝ”๋“œ ์ปฌ๋Ÿผ ๋˜๋Š” date_range_contains) */} + {(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && ( +
+ - ) : filter.operator === "in" || filter.operator === "not_in" ? ( - { - const values = e.target.value.split(",").map((v) => v.trim()); - handleFilterChange(filter.id, "value", values); - }} - placeholder="์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„ (์˜ˆ: ๊ฐ’1, ๊ฐ’2, ๊ฐ’3)" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> - ) : filter.operator === "between" ? ( - { - const values = e.target.value.split("~").map((v) => v.trim()); - handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]); - }} - placeholder="์‹œ์ž‘ ~ ์ข…๋ฃŒ (์˜ˆ: 2025-01-01 ~ 2025-12-31)" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> - ) : ( - handleFilterChange(filter.id, "value", e.target.value)} - placeholder={filter.operator === "date_range_contains" ? "๋น„๊ตํ•  ๋‚ ์งœ ์„ ํƒ" : "ํ•„ํ„ฐ ๊ฐ’ ์ž…๋ ฅ"} - className="h-8 text-xs sm:h-10 sm:text-sm" - /> +
+ )} + + {/* ๊ฐ’ ์ž…๋ ฅ (NULL ์ฒดํฌ ๋ฐ date_range_contains์˜ dynamic ์ œ์™ธ) */} + {filter.operator !== "is_null" && + filter.operator !== "is_not_null" && + !(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && ( +
+ + {/* ์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž…์ด๊ณ  ๊ฐ’ ํƒ€์ž…์ด category์ธ ๊ฒฝ์šฐ ์…€๋ ‰ํŠธ๋ฐ•์Šค */} + {filter.valueType === "category" && categoryValues[filter.columnName] ? ( + + ) : filter.operator === "in" || filter.operator === "not_in" ? ( + { + const values = e.target.value.split(",").map((v) => v.trim()); + handleFilterChange(filter.id, "value", values); + }} + placeholder="์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„ (์˜ˆ: ๊ฐ’1, ๊ฐ’2, ๊ฐ’3)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + ) : filter.operator === "between" ? ( + { + const values = e.target.value.split("~").map((v) => v.trim()); + handleFilterChange( + filter.id, + "value", + values.length === 2 ? values : [values[0] || "", ""], + ); + }} + placeholder="์‹œ์ž‘ ~ ์ข…๋ฃŒ (์˜ˆ: 2025-01-01 ~ 2025-12-31)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + ) : ( + handleFilterChange(filter.id, "value", e.target.value)} + placeholder={ + filter.operator === "date_range_contains" ? "๋น„๊ตํ•  ๋‚ ์งœ ์„ ํƒ" : "ํ•„ํ„ฐ ๊ฐ’ ์ž…๋ ฅ" + } + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + )} +

+ {filter.valueType === "category" && categoryValues[filter.columnName] + ? "์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’์„ ์„ ํƒํ•˜์„ธ์š”" + : filter.operator === "in" || filter.operator === "not_in" + ? "์—ฌ๋Ÿฌ ๊ฐ’์€ ์‰ผํ‘œ(,)๋กœ ๊ตฌ๋ถ„ํ•˜์„ธ์š”" + : filter.operator === "between" + ? "์‹œ์ž‘๊ณผ ์ข…๋ฃŒ ๊ฐ’์„ ~๋กœ ๊ตฌ๋ถ„ํ•˜์„ธ์š”" + : filter.operator === "date_range_contains" + ? "๊ธฐ๊ฐ„ ๋‚ด์— ํฌํ•จ๋˜๋Š”์ง€ ํ™•์ธํ•  ๋‚ ์งœ๋ฅผ ์„ ํƒํ•˜์„ธ์š”" + : "ํ•„ํ„ฐ๋งํ•  ๊ฐ’์„ ์ž…๋ ฅํ•˜์„ธ์š”"} +

+
)} -

- {filter.valueType === "category" && categoryValues[filter.columnName] - ? "์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ’์„ ์„ ํƒํ•˜์„ธ์š”" - : filter.operator === "in" || filter.operator === "not_in" - ? "์—ฌ๋Ÿฌ ๊ฐ’์€ ์‰ผํ‘œ(,)๋กœ ๊ตฌ๋ถ„ํ•˜์„ธ์š”" - : filter.operator === "between" - ? "์‹œ์ž‘๊ณผ ์ข…๋ฃŒ ๊ฐ’์„ ~๋กœ ๊ตฌ๋ถ„ํ•˜์„ธ์š”" - : filter.operator === "date_range_contains" - ? "๊ธฐ๊ฐ„ ๋‚ด์— ํฌํ•จ๋˜๋Š”์ง€ ํ™•์ธํ•  ๋‚ ์งœ๋ฅผ ์„ ํƒํ•˜์„ธ์š”" - : "ํ•„ํ„ฐ๋งํ•  ๊ฐ’์„ ์ž…๋ ฅํ•˜์„ธ์š”"} -

-
- )} - - {/* date_range_contains์˜ dynamic ํƒ€์ž… ์•ˆ๋‚ด */} - {filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( -
-

- โ„น๏ธ ์˜ค๋Š˜ ๋‚ ์งœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ๊ฐ„ ๋‚ด ๋ฐ์ดํ„ฐ๋ฅผ ํ•„ํ„ฐ๋งํ•ฉ๋‹ˆ๋‹ค. -

-
- )} -
- ))} + + {/* date_range_contains์˜ dynamic ํƒ€์ž… ์•ˆ๋‚ด */} + {filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( +
+

์˜ค๋Š˜ ๋‚ ์งœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ๊ฐ„ ๋‚ด ๋ฐ์ดํ„ฐ๋ฅผ ํ•„ํ„ฐ๋งํ•ฉ๋‹ˆ๋‹ค.

+
+ )} + + ); + })}
{/* ํ•„ํ„ฐ ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} {columns.length === 0 && ( -

- ํ…Œ์ด๋ธ”์„ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š” -

+

ํ…Œ์ด๋ธ”์„ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”

)} )}
); } - diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index e28e1755..b72b5154 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // ๋‚ด๋ถ€ ์ธ // ๐Ÿ†• ์—ฐ๊ด€ ๋ฐ์ดํ„ฐ ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ import "./related-data-buttons/RelatedDataButtonsRenderer"; // ์ขŒ์ธก ์„ ํƒ ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ์—ฐ๊ด€ ํ…Œ์ด๋ธ” ๋ฒ„ํŠผ ํ‘œ์‹œ +// ๐Ÿ†• ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ปดํฌ๋„ŒํŠธ +import "./pivot-grid/PivotGridRenderer"; // ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” (ํ–‰/์—ด ๊ทธ๋ฃนํ™”, ์ง‘๊ณ„, ๋“œ๋ฆด๋‹ค์šด) + /** * ์ปดํฌ๋„ŒํŠธ ์ดˆ๊ธฐํ™” ํ•จ์ˆ˜ */ diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index e7904a95..4f4595ff 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -5,7 +5,7 @@ * ๋‹ค์ฐจ์› ๋ฐ์ดํ„ฐ ๋ถ„์„์„ ์œ„ํ•œ ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” */ -import React, { useState, useMemo, useCallback, useEffect } from "react"; +import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, @@ -15,7 +15,6 @@ import { PivotFlatRow, PivotCellValue, PivotGridState, - PivotAreaType, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; import { exportPivotToExcel } from "./utils/exportExcel"; @@ -24,6 +23,8 @@ import { FieldPanel } from "./components/FieldPanel"; import { FieldChooser } from "./components/FieldChooser"; import { DrillDownModal } from "./components/DrillDownModal"; import { PivotChart } from "./components/PivotChart"; +import { FilterPopup } from "./components/FilterPopup"; +import { useVirtualScroll } from "./hooks/useVirtualScroll"; import { ChevronRight, ChevronDown, @@ -35,9 +36,66 @@ import { LayoutGrid, FileSpreadsheet, BarChart3, + Filter, + ArrowUp, + ArrowDown, + ArrowUpDown, + Printer, + Save, + RotateCcw, + FileText, + Loader2, + Eye, + EyeOff, } from "lucide-react"; import { Button } from "@/components/ui/button"; +// ==================== ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ ==================== + +// ์…€ ๋ณ‘ํ•ฉ ์ •๋ณด ๊ณ„์‚ฐ +interface MergeCellInfo { + rowSpan: number; + skip: boolean; // ๋ณ‘ํ•ฉ๋œ ์…€์—์„œ ๊ฑด๋„ˆ๋›ธ์ง€ ์—ฌ๋ถ€ +} + +const calculateMergeCells = ( + rows: PivotFlatRow[], + mergeCells: boolean +): Map => { + const mergeInfo = new Map(); + + if (!mergeCells || rows.length === 0) { + rows.forEach((_, idx) => mergeInfo.set(idx, { rowSpan: 1, skip: false })); + return mergeInfo; + } + + let i = 0; + while (i < rows.length) { + const currentPath = rows[i].path.join("|||"); + let spanCount = 1; + + // ๊ฐ™์€ path๋ฅผ ๊ฐ€์ง„ ์—ฐ์† ํ–‰ ์ฐพ๊ธฐ + while ( + i + spanCount < rows.length && + rows[i + spanCount].path.join("|||") === currentPath + ) { + spanCount++; + } + + // ์ฒซ ๋ฒˆ์งธ ํ–‰์€ rowSpan ์„ค์ • + mergeInfo.set(i, { rowSpan: spanCount, skip: false }); + + // ๋‚˜๋จธ์ง€ ํ–‰์€ skip + for (let j = 1; j < spanCount; j++) { + mergeInfo.set(i + j, { rowSpan: 1, skip: true }); + } + + i += spanCount; + } + + return mergeInfo; +}; + // ==================== ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ ==================== // ํ–‰ ํ—ค๋” ์…€ @@ -45,12 +103,14 @@ interface RowHeaderCellProps { row: PivotFlatRow; rowFields: PivotFieldConfig[]; onToggleExpand: (path: string[]) => void; + rowSpan?: number; } const RowHeaderCell: React.FC = ({ row, rowFields, onToggleExpand, + rowSpan = 1, }) => { const indentSize = row.level * 20; @@ -63,6 +123,7 @@ const RowHeaderCell: React.FC = ({ row.isExpanded && "bg-muted/70" )} style={{ paddingLeft: `${8 + indentSize}px` }} + rowSpan={rowSpan > 1 ? rowSpan : undefined} >
{row.hasChildren && ( @@ -88,7 +149,8 @@ const RowHeaderCell: React.FC = ({ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; - onClick?: () => void; + isSelected?: boolean; + onClick?: (e?: React.MouseEvent) => void; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; } @@ -96,6 +158,7 @@ interface DataCellProps { const DataCell: React.FC = ({ values, isTotal = false, + isSelected = false, onClick, onDoubleClick, conditionalStyle, @@ -104,6 +167,9 @@ const DataCell: React.FC = ({ const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; const icon = conditionalStyle?.icon; + + // ์„ ํƒ ์ƒํƒœ ์Šคํƒ€์ผ + const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10"; if (!values || values.length === 0) { return ( @@ -111,7 +177,8 @@ const DataCell: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1.5 text-right text-sm", - isTotal && "bg-primary/5 font-medium" + isTotal && "bg-primary/5 font-medium", + selectedClass )} style={cellStyle} onClick={onClick} @@ -122,19 +189,26 @@ const DataCell: React.FC = ({ ); } + // ํˆดํŒ ๋‚ด์šฉ ์ƒ์„ฑ + const tooltipContent = values.map((v) => + `${v.field || "๊ฐ’"}: ${v.formattedValue || v.value}` + ).join("\n"); + // ๋‹จ์ผ ๋ฐ์ดํ„ฐ ํ•„๋“œ์ธ ๊ฒฝ์šฐ if (values.length === 1) { return ( {/* Data Bar */} {hasDataBar && ( @@ -164,11 +238,13 @@ const DataCell: React.FC = ({ "border-r border-b border-border relative", "px-2 py-1.5 text-right text-sm tabular-nums", isTotal && "bg-primary/5 font-medium", - (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50" + (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50", + selectedClass )} style={cellStyle} onClick={onClick} onDoubleClick={onDoubleClick} + title={`${val.field || "๊ฐ’"}: ${val.formattedValue || val.value}`} > {hasDataBar && (
= ({ filterConfig: {}, }); const [isFullscreen, setIsFullscreen] = useState(false); - const [showFieldPanel, setShowFieldPanel] = useState(true); + const [showFieldPanel, setShowFieldPanel] = useState(false); // ๊ธฐ๋ณธ์ ์œผ๋กœ ์ ‘ํžŒ ์ƒํƒœ const [showFieldChooser, setShowFieldChooser] = useState(false); const [drillDownData, setDrillDownData] = useState<{ open: boolean; cellData: PivotCellData | null; }>({ open: false, cellData: null }); const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + const [containerHeight, setContainerHeight] = useState(400); + const tableContainerRef = useRef(null); + + // ์…€ ์„ ํƒ ์ƒํƒœ (๋ฒ”์œ„ ์„ ํƒ ์ง€์›) + const [selectedCell, setSelectedCell] = useState<{ + rowIndex: number; + colIndex: number; + } | null>(null); + const [selectionRange, setSelectionRange] = useState<{ + startRow: number; + startCol: number; + endRow: number; + endCol: number; + } | null>(null); + const tableRef = useRef(null); + + // ์ •๋ ฌ ์ƒํƒœ + const [sortConfig, setSortConfig] = useState<{ + field: string; + direction: "asc" | "desc"; + } | null>(null); + + // ์—ด ๋„ˆ๋น„ ์ƒํƒœ + const [columnWidths, setColumnWidths] = useState>({}); + const [resizingColumn, setResizingColumn] = useState(null); + const [resizeStartX, setResizeStartX] = useState(0); + const [resizeStartWidth, setResizeStartWidth] = useState(0); // ์™ธ๋ถ€ fields ๋ณ€๊ฒฝ ์‹œ ๋™๊ธฐํ™” useEffect(() => { @@ -252,6 +355,38 @@ export const PivotGridComponent: React.FC = ({ } }, [initialFields]); + // ์ƒํƒœ ์ €์žฅ ํ‚ค + const stateStorageKey = `pivot-state-${title || "default"}`; + + // ์ƒํƒœ ์ €์žฅ (localStorage) + const saveStateToStorage = useCallback(() => { + if (typeof window === "undefined") return; + const stateToSave = { + fields, + pivotState, + sortConfig, + columnWidths, + }; + localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); + }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]); + + // ์ƒํƒœ ๋ณต์› (localStorage) + useEffect(() => { + if (typeof window === "undefined") return; + const savedState = localStorage.getItem(stateStorageKey); + if (savedState) { + try { + const parsed = JSON.parse(savedState); + if (parsed.fields) setFields(parsed.fields); + if (parsed.pivotState) setPivotState(parsed.pivotState); + if (parsed.sortConfig) setSortConfig(parsed.sortConfig); + if (parsed.columnWidths) setColumnWidths(parsed.columnWidths); + } catch (e) { + console.warn("ํ”ผ๋ฒ— ์ƒํƒœ ๋ณต์› ์‹คํŒจ:", e); + } + } + }, [stateStorageKey]); + // ๋ฐ์ดํ„ฐ const data = externalData || []; @@ -281,6 +416,7 @@ export const PivotGridComponent: React.FC = ({ [fields] ); + // ํ•„ํ„ฐ ์˜์—ญ ํ•„๋“œ const filterFields = useMemo( () => fields @@ -318,25 +454,53 @@ export const PivotGridComponent: React.FC = ({ }); }, [data, fields]); + // ==================== ํ•„ํ„ฐ ์ ์šฉ ==================== + + const filteredData = useMemo(() => { + if (!data || data.length === 0) return data; + + // ํ•„ํ„ฐ ์˜์—ญ์˜ ํ•„๋“œ๋“ค๋กœ ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ๋ง + const activeFilters = fields.filter( + (f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 + ); + + if (activeFilters.length === 0) return data; + + return data.filter((row) => { + return activeFilters.every((filter) => { + const value = row[filter.field]; + const filterValues = filter.filterValues || []; + const filterType = filter.filterType || "include"; + + if (filterType === "include") { + return filterValues.includes(value); + } else { + return !filterValues.includes(value); + } + }); + }); + }, [data, fields]); + // ==================== ํ”ผ๋ฒ— ์ฒ˜๋ฆฌ ==================== const pivotResult = useMemo(() => { - if (!data || data.length === 0 || fields.length === 0) { + if (!filteredData || filteredData.length === 0 || fields.length === 0) { return null; } const visibleFields = fields.filter((f) => f.visible !== false); - if (visibleFields.filter((f) => f.area !== "filter").length === 0) { + // ํ–‰, ์—ด, ๋ฐ์ดํ„ฐ ์˜์—ญ์— ํ•„๋“œ๊ฐ€ ํ•˜๋‚˜๋„ ์—†์œผ๋ฉด null ๋ฐ˜ํ™˜ (ํ•„ํ„ฐ๋Š” ์ œ์™ธ) + if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { return null; } return processPivotData( - data, + filteredData, visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); - }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); // ์กฐ๊ฑด๋ถ€ ์„œ์‹์šฉ ์ „์ฒด ๊ฐ’ ์ˆ˜์ง‘ const allCellValues = useMemo(() => { @@ -380,6 +544,102 @@ export const PivotGridComponent: React.FC = ({ return valuesByField; }, [pivotResult]); + // ==================== ๊ฐ€์ƒ ์Šคํฌ๋กค ==================== + + const ROW_HEIGHT = 32; // ํ–‰ ๋†’์ด (px) + const VIRTUAL_SCROLL_THRESHOLD = 50; // ์ด ํ–‰ ์ˆ˜ ์ด์ƒ์ด๋ฉด ๊ฐ€์ƒ ์Šคํฌ๋กค ํ™œ์„ฑํ™” + + // ์ปจํ…Œ์ด๋„ˆ ๋†’์ด ์ธก์ • + useEffect(() => { + if (!tableContainerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(tableContainerRef.current); + return () => observer.disconnect(); + }, []); + + // ์—ด ํฌ๊ธฐ ์กฐ์ ˆ ์ค‘ + useEffect(() => { + if (resizingColumn === null) return; + + const handleMouseMove = (e: MouseEvent) => { + const diff = e.clientX - resizeStartX; + const newWidth = Math.max(50, resizeStartWidth + diff); // ์ตœ์†Œ 50px + setColumnWidths((prev) => ({ + ...prev, + [resizingColumn]: newWidth, + })); + }; + + const handleMouseUp = () => { + setResizingColumn(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [resizingColumn, resizeStartX, resizeStartWidth]); + + // ๊ฐ€์ƒ ์Šคํฌ๋กค ํ›… ์‚ฌ์šฉ + const flatRows = pivotResult?.flatRows || []; + + // ์ •๋ ฌ๋œ ํ–‰ ๋ฐ์ดํ„ฐ + const sortedFlatRows = useMemo(() => { + if (!sortConfig || !pivotResult) return flatRows; + + const { field, direction } = sortConfig; + const { dataMatrix, flatColumns } = pivotResult; + + // ๊ฐ ํ–‰์˜ ์ •๋ ฌ ๊ธฐ์ค€ ๊ฐ’ ๊ณ„์‚ฐ + const rowsWithSortValue = flatRows.map((row) => { + let sortValue = 0; + // ๋ชจ๋“  ์—ด์— ๋Œ€ํ•ด ํ•ด๋‹น ํ•„๋“œ์˜ ํ•ฉ๊ณ„ ๊ณ„์‚ฐ + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + const targetValue = values.find((v) => v.field === field); + if (targetValue?.value != null) { + sortValue += targetValue.value; + } + }); + return { row, sortValue }; + }); + + // ์ •๋ ฌ + rowsWithSortValue.sort((a, b) => { + if (direction === "asc") { + return a.sortValue - b.sortValue; + } + return b.sortValue - a.sortValue; + }); + + return rowsWithSortValue.map((item) => item.row); + }, [flatRows, sortConfig, pivotResult]); + + const enableVirtualScroll = sortedFlatRows.length > VIRTUAL_SCROLL_THRESHOLD; + + const virtualScroll = useVirtualScroll({ + itemCount: sortedFlatRows.length, + itemHeight: ROW_HEIGHT, + containerHeight: containerHeight, + overscan: 10, + }); + + // ๊ฐ€์ƒ ์Šคํฌ๋กค ์ ์šฉ๋œ ํ–‰ ๋ฐ์ดํ„ฐ + const visibleFlatRows = useMemo(() => { + if (!enableVirtualScroll) return sortedFlatRows; + return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์Šคํƒ€์ผ ๊ณ„์‚ฐ ํ—ฌํผ const getCellConditionalStyle = useCallback( (value: number | undefined, field: string): CellFormatStyle => { @@ -567,6 +827,154 @@ export const PivotGridComponent: React.FC = ({ console.error("Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:", error); } }, [pivotResult, fields, totals, title]); + + // ์ธ์‡„ ๊ธฐ๋Šฅ (PDF ๋‚ด๋ณด๋‚ด๊ธฐ๋ณด๋‹ค ๋จผ์ € ์ •์˜ํ•ด์•ผ ํ•จ) + const handlePrint = useCallback(() => { + const printContent = tableRef.current; + if (!printContent) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) return; + + printWindow.document.write(` + + + + ${title || "ํ”ผ๋ฒ— ํ…Œ์ด๋ธ”"} + + + +

${title || "ํ”ผ๋ฒ— ํ…Œ์ด๋ธ”"}

+ ${printContent.outerHTML} + + + `); + + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => { + printWindow.print(); + printWindow.close(); + }, 250); + }, [title]); + + // PDF ๋‚ด๋ณด๋‚ด๊ธฐ + const handleExportPDF = useCallback(async () => { + if (!pivotResult || !tableRef.current) return; + + try { + // ๋™์  import๋กœ jspdf์™€ html2canvas ๋กœ๋“œ + const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ + import("jspdf"), + import("html2canvas"), + ]); + + const canvas = await html2canvas(tableRef.current, { + scale: 2, + useCORS: true, + logging: false, + }); + + const imgData = canvas.toDataURL("image/png"); + const pdf = new jsPDF({ + orientation: canvas.width > canvas.height ? "landscape" : "portrait", + unit: "px", + format: [canvas.width, canvas.height], + }); + + pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height); + pdf.save(`${title || "pivot"}_export.pdf`); + } catch (error) { + console.error("PDF ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:", error); + // jspdf๊ฐ€ ์—†์œผ๋ฉด ์ธ์‡„ ๋Œ€ํ™”์ƒ์ž๋กœ ๋Œ€์ฒด + handlePrint(); + } + }, [pivotResult, title, handlePrint]); + + // ๋ฐ์ดํ„ฐ ์ƒˆ๋กœ๊ณ ์นจ + const [isRefreshing, setIsRefreshing] = useState(false); + const handleRefreshData = useCallback(async () => { + setIsRefreshing(true); + // ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์†Œ์Šค๊ฐ€ ์žˆ์œผ๋ฉด ์ƒˆ๋กœ๊ณ ์นจ + // ์—ฌ๊ธฐ์„œ๋Š” ์ƒํƒœ๋งŒ ์ดˆ๊ธฐํ™” + setPivotState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + setSortConfig(null); + setSelectedCell(null); + setSelectionRange(null); + setTimeout(() => setIsRefreshing(false), 500); + }, []); + + // ์ƒํƒœ ์ €์žฅ ๋ฒ„ํŠผ ํ•ธ๋“ค๋Ÿฌ + const handleSaveState = useCallback(() => { + saveStateToStorage(); + console.log("ํ”ผ๋ฒ— ์ƒํƒœ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + }, [saveStateToStorage]); + + // ์ƒํƒœ ์ดˆ๊ธฐํ™” + const handleResetState = useCallback(() => { + localStorage.removeItem(stateStorageKey); + setFields(initialFields); + setPivotState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + setSortConfig(null); + setColumnWidths({}); + setSelectedCell(null); + setSelectionRange(null); + }, [stateStorageKey, initialFields]); + + // ํ•„๋“œ ์ˆจ๊ธฐ๊ธฐ/ํ‘œ์‹œ ์ƒํƒœ + const [hiddenFields, setHiddenFields] = useState>(new Set()); + + const toggleFieldVisibility = useCallback((fieldName: string) => { + setHiddenFields((prev) => { + const newSet = new Set(prev); + if (newSet.has(fieldName)) { + newSet.delete(fieldName); + } else { + newSet.add(fieldName); + } + return newSet; + }); + }, []); + + // ์ˆจ๊ฒจ์ง„ ํ•„๋“œ ์ œ์™ธํ•œ ํ™œ์„ฑ ํ•„๋“œ๋“ค + const visibleFields = useMemo(() => { + return fields.filter((f) => !hiddenFields.has(f.field)); + }, [fields, hiddenFields]); + + // ์ˆจ๊ฒจ์ง„ ํ•„๋“œ ๋ชฉ๋ก + const hiddenFieldsList = useMemo(() => { + return fields.filter((f) => hiddenFields.has(f.field)); + }, [fields, hiddenFields]); + + // ๋ชจ๋“  ํ•„๋“œ ํ‘œ์‹œ + const showAllFields = useCallback(() => { + setHiddenFields(new Set()); + }, []); // ==================== ๋ Œ๋”๋ง ==================== @@ -587,9 +995,9 @@ export const PivotGridComponent: React.FC = ({ ); } - // ํ•„๋“œ ๋ฏธ์„ค์ • + // ํ•„๋“œ ๋ฏธ์„ค์ • (ํ–‰, ์—ด, ๋ฐ์ดํ„ฐ ์˜์—ญ์— ํ•„๋“œ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ) const hasActiveFields = fields.some( - (f) => f.visible !== false && f.area !== "filter" + (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) ); if (!hasActiveFields) { return ( @@ -646,7 +1054,221 @@ export const PivotGridComponent: React.FC = ({ ); } - const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + const { flatColumns, dataMatrix, grandTotals } = pivotResult; + + // ==================== ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ==================== + + // ํ‚ค๋ณด๋“œ ํ•ธ๋“ค๋Ÿฌ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!selectedCell) return; + + const { rowIndex, colIndex } = selectedCell; + const maxRowIndex = visibleFlatRows.length - 1; + const maxColIndex = flatColumns.length - 1; + + let newRowIndex = rowIndex; + let newColIndex = colIndex; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 1); + break; + case "ArrowDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 1); + break; + case "ArrowLeft": + e.preventDefault(); + newColIndex = Math.max(0, colIndex - 1); + break; + case "ArrowRight": + e.preventDefault(); + newColIndex = Math.min(maxColIndex, colIndex + 1); + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = 0; + newColIndex = 0; + } else { + newColIndex = 0; + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = maxRowIndex; + newColIndex = maxColIndex; + } else { + newColIndex = maxColIndex; + } + break; + case "PageUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 10); + break; + case "PageDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 10); + break; + case "Enter": + e.preventDefault(); + // ์…€ ๋”๋ธ”ํด๋ฆญ๊ณผ ๋™์ผํ•œ ๋™์ž‘ (๋“œ๋ฆด๋‹ค์šด) + if (visibleFlatRows[rowIndex] && flatColumns[colIndex]) { + const row = visibleFlatRows[rowIndex]; + const col = flatColumns[colIndex]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + // ๋“œ๋ฆด๋‹ค์šด ๋ชจ๋‹ฌ ์—ด๊ธฐ + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath: row.path, + columnPath: col.path, + field: values[0]?.field, + }; + setDrillDownData({ open: true, cellData }); + } + break; + case "Escape": + e.preventDefault(); + setSelectedCell(null); + setSelectionRange(null); + break; + case "c": + // Ctrl+C: ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + copySelectionToClipboard(); + } + return; + case "a": + // Ctrl+A: ์ „์ฒด ์„ ํƒ + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + setSelectionRange({ + startRow: 0, + startCol: 0, + endRow: visibleFlatRows.length - 1, + endCol: flatColumns.length - 1, + }); + } + return; + default: + return; + } + + if (newRowIndex !== rowIndex || newColIndex !== colIndex) { + setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex }); + } + }; + + // ์…€ ํด๋ฆญ์œผ๋กœ ์„ ํƒ (Shift+ํด๋ฆญ์œผ๋กœ ๋ฒ”์œ„ ์„ ํƒ) + const handleCellSelect = (rowIndex: number, colIndex: number, shiftKey: boolean = false) => { + if (shiftKey && selectedCell) { + // Shift+ํด๋ฆญ: ๋ฒ”์œ„ ์„ ํƒ + setSelectionRange({ + startRow: Math.min(selectedCell.rowIndex, rowIndex), + startCol: Math.min(selectedCell.colIndex, colIndex), + endRow: Math.max(selectedCell.rowIndex, rowIndex), + endCol: Math.max(selectedCell.colIndex, colIndex), + }); + } else { + // ์ผ๋ฐ˜ ํด๋ฆญ: ๋‹จ์ผ ์„ ํƒ + setSelectedCell({ rowIndex, colIndex }); + setSelectionRange(null); + } + }; + + // ์…€์ด ์„ ํƒ ๋ฒ”์œ„ ๋‚ด์— ์žˆ๋Š”์ง€ ํ™•์ธ + const isCellInRange = (rowIndex: number, colIndex: number): boolean => { + if (selectionRange) { + return ( + rowIndex >= selectionRange.startRow && + rowIndex <= selectionRange.endRow && + colIndex >= selectionRange.startCol && + colIndex <= selectionRange.endCol + ); + } + if (selectedCell) { + return selectedCell.rowIndex === rowIndex && selectedCell.colIndex === colIndex; + } + return false; + }; + + // ์—ด ํฌ๊ธฐ ์กฐ์ ˆ ์‹œ์ž‘ + const handleResizeStart = (colIdx: number, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setResizingColumn(colIdx); + setResizeStartX(e.clientX); + setResizeStartWidth(columnWidths[colIdx] || 100); + }; + + // ํด๋ฆฝ๋ณด๋“œ์— ์„ ํƒ ์˜์—ญ ๋ณต์‚ฌ + const copySelectionToClipboard = () => { + const range = selectionRange || (selectedCell ? { + startRow: selectedCell.rowIndex, + startCol: selectedCell.colIndex, + endRow: selectedCell.rowIndex, + endCol: selectedCell.colIndex, + } : null); + + if (!range) return; + + const lines: string[] = []; + + for (let rowIdx = range.startRow; rowIdx <= range.endRow; rowIdx++) { + const row = visibleFlatRows[rowIdx]; + if (!row) continue; + + const rowValues: string[] = []; + for (let colIdx = range.startCol; colIdx <= range.endCol; colIdx++) { + const col = flatColumns[colIdx]; + if (!col) continue; + + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + const cellValue = values.map((v) => v.formattedValue || v.value || "").join(", "); + rowValues.push(cellValue); + } + lines.push(rowValues.join("\t")); + } + + const text = lines.join("\n"); + navigator.clipboard.writeText(text).then(() => { + // ๋ณต์‚ฌ ์„ฑ๊ณต ํ”ผ๋“œ๋ฐฑ (์„ ํƒ์ ) + console.log("ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ๋จ:", text); + }).catch((err) => { + console.error("ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ ์‹คํŒจ:", err); + }); + }; + + // ์ •๋ ฌ ํ† ๊ธ€ + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (prev?.field === field) { + // ๊ฐ™์€ ํ•„๋“œ ํด๋ฆญ: asc -> desc -> null ์ˆœํ™˜ + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; // ์ •๋ ฌ ํ•ด์ œ + } + // ์ƒˆ๋กœ์šด ํ•„๋“œ: asc๋กœ ์‹œ์ž‘ + return { field, direction: "asc" }; + }); + }; + + // ์ •๋ ฌ ์•„์ด์ฝ˜ ๋ Œ๋”๋ง + const SortIcon = ({ field }: { field: string }) => { + if (sortConfig?.field !== field) { + return ; + } + if (sortConfig.direction === "asc") { + return ; + } + return ; + }; return (
= ({
{title &&

{title}

} - ({data.length}๊ฑด) + ({filteredData.length !== data.length + ? `${filteredData.length} / ${data.length}๊ฑด` + : `${data.length}๊ฑด`})
@@ -761,8 +1385,101 @@ export const PivotGridComponent: React.FC = ({ > + + )} + + + + + + + + {/* ์ˆจ๊ฒจ์ง„ ํ•„๋“œ ํ‘œ์‹œ ๋“œ๋กญ๋‹ค์šด */} + {hiddenFieldsList.length > 0 && ( +
+ +
+
+ ์ˆจ๊ฒจ์ง„ ํ•„๋“œ +
+
+ {hiddenFieldsList.map((field) => ( + + ))} +
+
+ +
+
+
+ )}
+ {/* ํ•„ํ„ฐ ๋ฐ” - ํ•„ํ„ฐ ์˜์—ญ์— ํ•„๋“œ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ํ‘œ์‹œ */} + {filterFields.length > 0 && ( +
+ + ํ•„ํ„ฐ: +
+ {filterFields.map((filterField) => { + const selectedValues = filterField.filterValues || []; + const isFiltered = selectedValues.length > 0; + + return ( + { + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, filterValues: values, filterType: type } + : f + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ); + })} +
+
+ )} + {/* ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” */} -
- +
+
{/* ์—ด ํ—ค๋” */} - {/* ์ขŒ์ƒ๋‹จ ์ฝ”๋„ˆ (ํ–‰ ํ•„๋“œ ๋ผ๋ฒจ) */} + {/* ์ขŒ์ƒ๋‹จ ์ฝ”๋„ˆ (ํ–‰ ํ•„๋“œ ๋ผ๋ฒจ + ํ•„ํ„ฐ) */} {/* ์—ด ํ—ค๋” ์…€ */} @@ -803,15 +1606,71 @@ export const PivotGridComponent: React.FC = ({ ))} + + {/* ์—ด ํ•„๋“œ ํ•„ํ„ฐ (ํ—ค๋” ์™ผ์ชฝ์— ํ‘œ์‹œ) */} + {columnFields.length > 0 && ( + + )} {/* ํ–‰ ์ด๊ณ„ ํ—ค๋” */} {totals?.showRowGrandTotals && ( @@ -839,10 +1698,14 @@ export const PivotGridComponent: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1 text-center text-xs font-normal", - "text-muted-foreground" + "text-muted-foreground cursor-pointer hover:bg-accent/50" )} + onClick={() => handleSort(df.field)} > - {df.caption} +
+ {df.caption} + +
))} @@ -865,62 +1728,124 @@ export const PivotGridComponent: React.FC = ({
- {flatRows.map((row, rowIdx) => ( - - {/* ํ–‰ ํ—ค๋” */} - + {/* ์—ด ์ด๊ณ„ ํ–‰ (์ƒ๋‹จ ์œ„์น˜) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( + + - {/* ๋ฐ์ดํ„ฐ ์…€ */} - {flatColumns.map((col, colIdx) => { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = dataMatrix.get(cellKey) || []; - - // ์กฐ๊ฑด๋ถ€ ์„œ์‹ (์ฒซ ๋ฒˆ์งธ ๊ฐ’ ๊ธฐ์ค€) - const conditionalStyle = - values.length > 0 && values[0].field - ? getCellConditionalStyle(values[0].value, values[0].field) - : undefined; - - return ( - handleCellClick(row.path, col.path, values) - : undefined - } - onDoubleClick={() => - handleCellDoubleClick(row.path, col.path, values) - } - /> - ); - })} - - {/* ํ–‰ ์ด๊ณ„ */} - {totals?.showRowGrandTotals && ( + {flatColumns.map((col, colIdx) => ( + ))} + + {/* ๋Œ€์ดํ•ฉ */} + {totals?.showRowGrandTotals && ( + )} - ))} + )} + + {/* ๊ฐ€์ƒ ์Šคํฌ๋กค ์ƒ๋‹จ ์—ฌ๋ฐฑ */} + {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( + + + )} + + {(() => { + // ์…€ ๋ณ‘ํ•ฉ ์ •๋ณด ๊ณ„์‚ฐ + const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false); + + return visibleFlatRows.map((row, idx) => { + // ์‹ค์ œ ํ–‰ ์ธ๋ฑ์Šค ๊ณ„์‚ฐ + const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; + const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false }; + + return ( + + {/* ํ–‰ ํ—ค๋” (๋ณ‘ํ•ฉ๋˜๋ฉด skip) */} + {!cellMerge.skip && ( + + )} - {/* ์—ด ์ด๊ณ„ ํ–‰ */} - {totals?.showColumnGrandTotals && ( + {/* ๋ฐ์ดํ„ฐ ์…€ */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + // ์กฐ๊ฑด๋ถ€ ์„œ์‹ (์ฒซ ๋ฒˆ์งธ ๊ฐ’ ๊ธฐ์ค€) + const conditionalStyle = + values.length > 0 && values[0].field + ? getCellConditionalStyle(values[0].value ?? undefined, values[0].field) + : undefined; + + // ์„ ํƒ ์ƒํƒœ ํ™•์ธ (๋ฒ”์œ„ ์„ ํƒ ํฌํ•จ) + const isCellSelected = isCellInRange(rowIdx, colIdx); + + return ( + { + handleCellSelect(rowIdx, colIdx, e?.shiftKey || false); + if (onCellClick) { + handleCellClick(row.path, col.path, values); + } + }} + onDoubleClick={() => + handleCellDoubleClick(row.path, col.path, values) + } + /> + ); + })} + + {/* ํ–‰ ์ด๊ณ„ */} + {totals?.showRowGrandTotals && ( + + )} + + ); + }); + })()} + + {/* ๊ฐ€์ƒ ์Šคํฌ๋กค ํ•˜๋‹จ ์—ฌ๋ฐฑ */} + {enableVirtualScroll && ( + + + )} + + {/* ์—ด ์ด๊ณ„ ํ–‰ (ํ•˜๋‹จ ์œ„์น˜ - ๊ธฐ๋ณธ๊ฐ’) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && (
= ({ )} rowSpan={columnFields.length > 0 ? 2 : 1} > - {rowFields.map((f) => f.caption).join(" / ") || "ํ•ญ๋ชฉ"} +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
+ ))} + {rowFields.length === 0 && ํ•ญ๋ชฉ} +
handleSort(dataFields[0].field) : undefined} > - {col.caption || "(์ „์ฒด)"} +
+ {col.caption || "(์ „์ฒด)"} + {dataFields.length === 1 && } +
+ {/* ์—ด ๋ฆฌ์‚ฌ์ด์ฆˆ ํ•ธ๋“ค */} +
handleResizeStart(idx, e)} + />
0 ? 2 : 1} + > +
+ {columnFields.map((f) => ( + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "column" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ))} +
+
+ ์ด๊ณ„ +
+
+
void; } -// ==================== ์œ ํ‹ธ๋ฆฌํ‹ฐ ==================== - -const AREA_LABELS: Record = { - row: { label: "ํ–‰ ์˜์—ญ", icon: }, - column: { label: "์—ด ์˜์—ญ", icon: }, - data: { label: "๋ฐ์ดํ„ฐ ์˜์—ญ", icon: }, - filter: { label: "ํ•„ํ„ฐ ์˜์—ญ", icon: }, -}; - -const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [ - { value: "sum", label: "ํ•ฉ๊ณ„" }, - { value: "count", label: "๊ฐœ์ˆ˜" }, - { value: "avg", label: "ํ‰๊ท " }, - { value: "min", label: "์ตœ์†Œ" }, - { value: "max", label: "์ตœ๋Œ€" }, - { value: "countDistinct", label: "๊ณ ์œ ๊ฐ’ ๊ฐœ์ˆ˜" }, -]; - -const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [ - { value: "year", label: "์—ฐ๋„" }, - { value: "quarter", label: "๋ถ„๊ธฐ" }, - { value: "month", label: "์›”" }, - { value: "week", label: "์ฃผ" }, - { value: "day", label: "์ผ" }, -]; - -const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [ - { value: "string", label: "๋ฌธ์ž์—ด" }, - { value: "number", label: "์ˆซ์ž" }, - { value: "date", label: "๋‚ ์งœ" }, - { value: "boolean", label: "๋ถ€์šธ" }, -]; - // DB ํƒ€์ž…์„ FieldDataType์œผ๋กœ ๋ณ€ํ™˜ function mapDbTypeToFieldType(dbType: string): FieldDataType { const type = dbType.toLowerCase(); - if ( - type.includes("int") || - type.includes("numeric") || - type.includes("decimal") || - type.includes("float") || - type.includes("double") || - type.includes("real") - ) { + if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) { return "number"; } - if ( - type.includes("date") || - type.includes("time") || - type.includes("timestamp") - ) { + if (type.includes("date") || type.includes("time") || type.includes("timestamp")) { return "date"; } if (type.includes("bool")) { @@ -126,332 +78,174 @@ function mapDbTypeToFieldType(dbType: string): FieldDataType { return "string"; } -// ==================== ํ•„๋“œ ์„ค์ • ์ปดํฌ๋„ŒํŠธ ==================== +// ==================== ์ปฌ๋Ÿผ ์นฉ ์ปดํฌ๋„ŒํŠธ ==================== -interface FieldConfigItemProps { - field: PivotFieldConfig; - index: number; - onChange: (field: PivotFieldConfig) => void; - onRemove: () => void; - onMoveUp: () => void; - onMoveDown: () => void; - isFirst: boolean; - isLast: boolean; +interface ColumnChipProps { + column: ColumnInfo; + isUsed: boolean; + onClick: () => void; } -const FieldConfigItem: React.FC = ({ - field, - index, - onChange, - onRemove, - onMoveUp, - onMoveDown, - isFirst, - isLast, -}) => { +const ColumnChip: React.FC = ({ column, isUsed, onClick }) => { + const dataType = mapDbTypeToFieldType(column.data_type); + const typeColor = { + number: "bg-blue-100 text-blue-700 border-blue-200", + string: "bg-green-100 text-green-700 border-green-200", + date: "bg-purple-100 text-purple-700 border-purple-200", + boolean: "bg-orange-100 text-orange-700 border-orange-200", + }[dataType]; + return ( -
- {/* ๋“œ๋ž˜๊ทธ ํ•ธ๋“ค & ์ˆœ์„œ ๋ฒ„ํŠผ */} -
- - - -
- - {/* ํ•„๋“œ ์„ค์ • */} -
- {/* ํ•„๋“œ๋ช… & ๋ผ๋ฒจ */} -
-
- - onChange({ ...field, field: e.target.value })} - placeholder="column_name" - className="h-8 text-xs" - /> -
-
- - onChange({ ...field, caption: e.target.value })} - placeholder="ํ‘œ์‹œ๋ช…" - className="h-8 text-xs" - /> -
-
- - {/* ๋ฐ์ดํ„ฐ ํƒ€์ž… & ์ง‘๊ณ„ ํ•จ์ˆ˜ */} -
-
- - -
- - {field.area === "data" && ( -
- - -
- )} - - {field.dataType === "date" && - (field.area === "row" || field.area === "column") && ( -
- - -
- )} -
-
- - {/* ์‚ญ์ œ ๋ฒ„ํŠผ */} - -
+ ); }; -// ==================== ์˜์—ญ๋ณ„ ํ•„๋“œ ๋ชฉ๋ก ==================== +// ==================== ์˜์—ญ ๋“œ๋กญ์กด ์ปดํฌ๋„ŒํŠธ ==================== -interface AreaFieldListProps { +interface AreaDropZoneProps { area: PivotAreaType; + label: string; + description: string; + icon: React.ReactNode; fields: PivotFieldConfig[]; - allColumns: ColumnInfo[]; - onFieldsChange: (fields: PivotFieldConfig[]) => void; + columns: ColumnInfo[]; + onAddField: (column: ColumnInfo) => void; + onRemoveField: (index: number) => void; + onUpdateField: (index: number, updates: Partial) => void; + color: string; } -const AreaFieldList: React.FC = ({ +const AreaDropZone: React.FC = ({ area, + label, + description, + icon, fields, - allColumns, - onFieldsChange, + columns, + onAddField, + onRemoveField, + onUpdateField, + color, }) => { - const areaFields = fields.filter((f) => f.area === area); - const { label, icon } = AREA_LABELS[area]; - - const handleAddField = () => { - const newField: PivotFieldConfig = { - field: "", - caption: "", - area, - areaIndex: areaFields.length, - dataType: "string", - visible: true, - }; - if (area === "data") { - newField.summaryType = "sum"; - } - onFieldsChange([...fields, newField]); - }; - - const handleAddFromColumn = (column: ColumnInfo) => { - const dataType = mapDbTypeToFieldType(column.data_type); - const newField: PivotFieldConfig = { - field: column.column_name, - caption: column.column_comment || column.column_name, - area, - areaIndex: areaFields.length, - dataType, - visible: true, - }; - if (area === "data") { - newField.summaryType = "sum"; - } - onFieldsChange([...fields, newField]); - }; - - const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => { - const newFields = [...fields]; - const globalIndex = fields.findIndex( - (f) => f.area === area && f.areaIndex === index - ); - if (globalIndex >= 0) { - newFields[globalIndex] = updatedField; - onFieldsChange(newFields); - } - }; - - const handleRemoveField = (index: number) => { - const newFields = fields.filter( - (f) => !(f.area === area && f.areaIndex === index) - ); - // ์ธ๋ฑ์Šค ์žฌ์ •๋ ฌ - let idx = 0; - newFields.forEach((f) => { - if (f.area === area) { - f.areaIndex = idx++; - } - }); - onFieldsChange(newFields); - }; - - const handleMoveField = (fromIndex: number, direction: "up" | "down") => { - const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; - if (toIndex < 0 || toIndex >= areaFields.length) return; - - const newAreaFields = [...areaFields]; - const [moved] = newAreaFields.splice(fromIndex, 1); - newAreaFields.splice(toIndex, 0, moved); - - // ์ธ๋ฑ์Šค ์žฌ์ •๋ ฌ - newAreaFields.forEach((f, idx) => { - f.areaIndex = idx; - }); - - // ์ „์ฒด ํ•„๋“œ ์—…๋ฐ์ดํŠธ - const newFields = fields.filter((f) => f.area !== area); - onFieldsChange([...newFields, ...newAreaFields]); - }; - - // ์ด๋ฏธ ์ถ”๊ฐ€๋œ ์ปฌ๋Ÿผ ์ œ์™ธ - const availableColumns = allColumns.filter( + const [isExpanded, setIsExpanded] = useState(true); + + // ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปฌ๋Ÿผ (์ด๋ฏธ ์ถ”๊ฐ€๋œ ์ปฌ๋Ÿผ ์ œ์™ธ) + const availableColumns = columns.filter( (col) => !fields.some((f) => f.field === col.column_name) ); return ( - - +
+ {/* ํ—ค๋” */} +
setIsExpanded(!isExpanded)} + >
{icon} - {label} - - {areaFields.length} + {label} + + {fields.length}
- - - {/* ํ•„๋“œ ๋ชฉ๋ก */} - {areaFields - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) - .map((field, idx) => ( - handleFieldChange(field.areaIndex || idx, f)} - onRemove={() => handleRemoveField(field.areaIndex || idx)} - onMoveUp={() => handleMoveField(idx, "up")} - onMoveDown={() => handleMoveField(idx, "down")} - isFirst={idx === 0} - isLast={idx === areaFields.length - 1} - /> - ))} + {isExpanded ? : } +
+ + {/* ์„ค๋ช… */} +

{description}

- {/* ํ•„๋“œ ์ถ”๊ฐ€ */} -
- onUpdateField(idx, { summaryType: v as AggregationType })} + > + + + + + ํ•ฉ๊ณ„ + ๊ฐœ์ˆ˜ + ํ‰๊ท  + ์ตœ์†Œ + ์ตœ๋Œ€ + + + )} + + +
+ ))} +
+ ) : ( +
+ ์•„๋ž˜์—์„œ ์ปฌ๋Ÿผ์„ ์„ ํƒํ•˜์„ธ์š” +
+ )} + + {/* ์ปฌ๋Ÿผ ์ถ”๊ฐ€ ๋“œ๋กญ๋‹ค์šด */} + {availableColumns.length > 0 && ( + - - + ))} + + + )} - -
+ )} + ); }; @@ -465,17 +259,19 @@ export const PivotGridConfigPanel: React.FC = ({ const [columns, setColumns] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); // ํ…Œ์ด๋ธ” ๋ชฉ๋ก ๋กœ๋“œ useEffect(() => { const loadTables = async () => { setLoadingTables(true); try { - // apiClient์˜ baseURL์ด ์ด๋ฏธ /api๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ /api ์ œ์™ธ - const response = await apiClient.get("/table-management/tables"); - if (response.data.success) { - setTables(response.data.data || []); - } + const tableList = await tableTypeApi.getTables(); + const mappedTables: TableInfo[] = tableList.map((t: any) => ({ + table_name: t.tableName, + table_comment: t.tableLabel || t.displayName || t.tableName, + })); + setTables(mappedTables); } catch (error) { console.error("ํ…Œ์ด๋ธ” ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:", error); } finally { @@ -495,13 +291,13 @@ export const PivotGridConfigPanel: React.FC = ({ setLoadingColumns(true); try { - // apiClient์˜ baseURL์ด ์ด๋ฏธ /api๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ /api ์ œ์™ธ - const response = await apiClient.get( - `/table-management/tables/${config.dataSource.tableName}/columns` - ); - if (response.data.success) { - setColumns(response.data.data || []); - } + const columnList = await tableTypeApi.getColumns(config.dataSource.tableName); + const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({ + column_name: c.columnName || c.column_name, + data_type: c.dataType || c.data_type || "text", + column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name, + })); + setColumns(mappedColumns); } catch (error) { console.error("์ปฌ๋Ÿผ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:", error); } finally { @@ -519,489 +315,484 @@ export const PivotGridConfigPanel: React.FC = ({ [config, onChange] ); + // ํ•„๋“œ ์ถ”๊ฐ€ + const handleAddField = (area: PivotAreaType, column: ColumnInfo) => { + const currentFields = config.fields || []; + const areaFields = currentFields.filter(f => f.area === area); + + const newField: PivotFieldConfig = { + field: column.column_name, + caption: column.column_comment || column.column_name, + area, + areaIndex: areaFields.length, + dataType: mapDbTypeToFieldType(column.data_type), + visible: true, + }; + + if (area === "data") { + newField.summaryType = "sum"; + } + + updateConfig({ fields: [...currentFields, newField] }); + }; + + // ํ•„๋“œ ์ œ๊ฑฐ + const handleRemoveField = (area: PivotAreaType, index: number) => { + const currentFields = config.fields || []; + const newFields = currentFields.filter( + (f) => !(f.area === area && f.areaIndex === index) + ); + + // ์ธ๋ฑ์Šค ์žฌ์ •๋ ฌ + let idx = 0; + newFields.forEach((f) => { + if (f.area === area) { + f.areaIndex = idx++; + } + }); + + updateConfig({ fields: newFields }); + }; + + // ํ•„๋“œ ์—…๋ฐ์ดํŠธ + const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial) => { + const currentFields = config.fields || []; + const newFields = currentFields.map((f) => { + if (f.area === area && f.areaIndex === index) { + return { ...f, ...updates }; + } + return f; + }); + updateConfig({ fields: newFields }); + }; + + // ์˜์—ญ๋ณ„ ํ•„๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ + const getFieldsByArea = (area: PivotAreaType) => { + return (config.fields || []) + .filter(f => f.area === area) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + }; + return (
- {/* ๋ฐ์ดํ„ฐ ์†Œ์Šค ์„ค์ • */} -
- - -
- - + {/* ์‚ฌ์šฉ ๊ฐ€์ด๋“œ */} +
+
+ +
+

ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” ์„ค์ • ๋ฐฉ๋ฒ•

+
    +
  1. ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ํ…Œ์ด๋ธ”์„ ์„ ํƒํ•˜์„ธ์š”
  2. +
  3. ํ–‰ ๊ทธ๋ฃน์— ๊ทธ๋ฃนํ™”ํ•  ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š” (์˜ˆ: ์ง€์—ญ, ๋ถ€์„œ)
  4. +
  5. ์—ด ๊ทธ๋ฃน์— ๊ฐ€๋กœ๋กœ ํŽผ์น  ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š” (์˜ˆ: ์›”, ๋ถ„๊ธฐ)
  6. +
  7. ๊ฐ’์— ์ง‘๊ณ„ํ•  ์ˆซ์ž ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š” (์˜ˆ: ๋งค์ถœ, ์ˆ˜๋Ÿ‰)
  8. +
+
- + {/* STEP 1: ํ…Œ์ด๋ธ” ์„ ํƒ */} +
+
+ + +
+ + +
- {/* ํ•„๋“œ ์„ค์ • */} + {/* STEP 2: ํ•„๋“œ ๋ฐฐ์น˜ */} {config.dataSource?.tableName && (
-
- - - {columns.length}๊ฐœ ์ปฌ๋Ÿผ - +
+ + + {loadingColumns && (์ปฌ๋Ÿผ ๋กœ๋”ฉ ์ค‘...)}
- {loadingColumns ? ( -
- ์ปฌ๋Ÿผ ๋กœ๋”ฉ ์ค‘... + {/* ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปฌ๋Ÿผ ๋ชฉ๋ก */} + {columns.length > 0 && ( +
+ +
+ {columns.map((col) => { + const isUsed = (config.fields || []).some(f => f.field === col.column_name); + return ( + {/* ํด๋ฆญ ์‹œ ์•„๋ฌด๊ฒƒ๋„ ์•ˆํ•จ - ๋“œ๋กญ์กด์—์„œ ์ถ”๊ฐ€ */}} + /> + ); + })} +
- ) : ( - - {(["row", "column", "data", "filter"] as PivotAreaType[]).map( - (area) => ( - updateConfig({ fields })} - /> - ) - )} - )} + + {/* ์˜์—ญ๋ณ„ ๋“œ๋กญ์กด */} +
+ } + fields={getFieldsByArea("row")} + columns={columns} + onAddField={(col) => handleAddField("row", col)} + onRemoveField={(idx) => handleRemoveField("row", idx)} + onUpdateField={(idx, updates) => handleUpdateField("row", idx, updates)} + color="border-emerald-200 bg-emerald-50/50" + /> + + } + fields={getFieldsByArea("column")} + columns={columns} + onAddField={(col) => handleAddField("column", col)} + onRemoveField={(idx) => handleRemoveField("column", idx)} + onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)} + color="border-blue-200 bg-blue-50/50" + /> + + } + fields={getFieldsByArea("data")} + columns={columns} + onAddField={(col) => handleAddField("data", col)} + onRemoveField={(idx) => handleRemoveField("data", idx)} + onUpdateField={(idx, updates) => handleUpdateField("data", idx, updates)} + color="border-amber-200 bg-amber-50/50" + /> +
)} - - - {/* ํ‘œ์‹œ ์„ค์ • */} -
- - -
-
- - - updateConfig({ - totals: { ...config.totals, showRowGrandTotals: v }, - }) - } - /> + {/* ๊ณ ๊ธ‰ ์„ค์ • ํ† ๊ธ€ */} +
+
- -
- - - updateConfig({ - style: { ...config.style, alternateRowColors: v }, - }) - } - /> -
- -
- - - updateConfig({ - style: { ...config.style, highlightTotals: v }, - }) - } - /> -
+ {showAdvanced ? : } +
- - - {/* ๊ธฐ๋Šฅ ์„ค์ • */} -
- - -
-
- - - updateConfig({ allowExpandAll: v }) - } - /> -
- -
- - - updateConfig({ - exportConfig: { ...config.exportConfig, excel: v }, - }) - } - /> -
-
-
- - - - {/* ์ฐจํŠธ ์„ค์ • */} -
- - -
-
- - - updateConfig({ - chart: { - ...config.chart, - enabled: v, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - }, - }) - } - /> -
- - {config.chart?.enabled && ( -
-
- + {/* ๊ณ ๊ธ‰ ์„ค์ • */} + {showAdvanced && ( +
+ {/* ํ‘œ์‹œ ์„ค์ • */} +
+ + +
+
+ + + updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) + } + /> +
+ +
+
- -
- - - updateConfig({ - chart: { - ...config.chart, - enabled: true, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - height: Number(e.target.value), - }, - }) + +
+ + +
+ +
+ + + updateConfig({ totals: { ...config.totals, showRowTotals: v } }) } - className="h-8 text-xs" />
- -
- + +
+ - updateConfig({ - chart: { - ...config.chart, - enabled: true, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - showLegend: v, - }, - }) + updateConfig({ totals: { ...config.totals, showColumnTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, alternateRowColors: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, mergeCells: v } }) + } + /> +
+ +
+ + + updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) + } + /> +
+ +
+ + + updateConfig({ saveState: v }) } />
- )} -
-
- - - - {/* ํ•„๋“œ ์„ ํƒ๊ธฐ ์„ค์ • */} -
- - -
-
- - - updateConfig({ - fieldChooser: { ...config.fieldChooser, enabled: v }, - }) - } - />
-
- - - updateConfig({ - fieldChooser: { ...config.fieldChooser, allowSearch: v }, - }) - } - /> + {/* ํฌ๊ธฐ ์„ค์ • */} +
+ +
+
+ + updateConfig({ height: e.target.value })} + placeholder="400px" + className="h-8 text-xs" + /> +
+
+ + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-8 text-xs" + /> +
+
+
+ + {/* ์กฐ๊ฑด๋ถ€ ์„œ์‹ */} +
+ +
+ {(config.style?.conditionalFormats || []).map((rule, index) => ( +
+ + + {rule.type === "colorScale" && ( +
+ { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: e.target.value, maxColor: rule.colorScale?.maxColor || "#00ff00" } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="์ตœ์†Œ๊ฐ’ ์ƒ‰์ƒ" + /> + โ†’ + { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: rule.colorScale?.minColor || "#ff0000", maxColor: e.target.value } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="์ตœ๋Œ€๊ฐ’ ์ƒ‰์ƒ" + /> +
+ )} + + {rule.type === "dataBar" && ( + { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, dataBar: { color: e.target.value } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="๋ฐ” ์ƒ‰์ƒ" + /> + )} + + {rule.type === "iconSet" && ( + + )} + + +
+ ))} + + +
-
- - - - {/* ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์„ค์ • */} -
- - -
-
- - r.type === "colorScale" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "colorScale" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "colorScale-1", - type: "colorScale" as const, - colorScale: { - minColor: "#ff6b6b", - midColor: "#ffd93d", - maxColor: "#6bcb77", - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- -
- - r.type === "dataBar" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "dataBar" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "dataBar-1", - type: "dataBar" as const, - dataBar: { - color: "#3b82f6", - showValue: true, - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- -
- - r.type === "iconSet" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "iconSet" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "iconSet-1", - type: "iconSet" as const, - iconSet: { - type: "traffic", - thresholds: [33, 66], - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- - {config.style?.conditionalFormats && - config.style.conditionalFormats.length > 0 && ( -

- {config.style.conditionalFormats.length}๊ฐœ์˜ ์กฐ๊ฑด๋ถ€ ์„œ์‹์ด - ์ ์šฉ๋จ -

- )} -
-
- - - - {/* ํฌ๊ธฐ ์„ค์ • */} -
- - -
-
- - updateConfig({ height: e.target.value })} - placeholder="auto ๋˜๋Š” 400px" - className="h-8 text-xs" - /> -
- -
- - updateConfig({ maxHeight: e.target.value })} - placeholder="600px" - className="h-8 text-xs" - /> -
-
-
+ )}
); }; export default PivotGridConfigPanel; - diff --git a/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx b/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx new file mode 100644 index 00000000..1dac623b --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx @@ -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 = ({ + 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 ( + + {children} + + {/* ์ •๋ ฌ ์˜ต์…˜ (ํ—ค๋”์—์„œ๋งŒ) */} + {(cellType === "rowHeader" || cellType === "columnHeader") && field && ( + <> + + + + ์ •๋ ฌ + + + onSort?.(field.field, "asc")}> + + ์˜ค๋ฆ„์ฐจ์ˆœ + + onSort?.(field.field, "desc")}> + + ๋‚ด๋ฆผ์ฐจ์ˆœ + + + + + + )} + + {/* ํ™•์žฅ/์ถ•์†Œ ์˜ต์…˜ */} + {(cellType === "rowHeader" || cellType === "columnHeader") && ( + <> + {rowPath && rowPath.length > 0 && ( + <> + onExpand?.(rowPath)}> + + ํ™•์žฅ + + onCollapse?.(rowPath)}> + + ์ถ•์†Œ + + + )} + + + ์ „์ฒด ํ™•์žฅ + + + + ์ „์ฒด ์ถ•์†Œ + + + + )} + + {/* ํ•„ํ„ฐ ์˜ต์…˜ */} + {field && onFilter && ( + <> + onFilter(field.field)}> + + ํ•„ํ„ฐ + + + + )} + + {/* ์ง‘๊ณ„ ํ•จ์ˆ˜ ๋ณ€๊ฒฝ (๋ฐ์ดํ„ฐ ํ•„๋“œ์—์„œ๋งŒ) */} + {cellType === "data" && field && onChangeSummary && ( + <> + + + + ์ง‘๊ณ„ ํ•จ์ˆ˜ + + + onChangeSummary(field.field, "sum")} + > + ํ•ฉ๊ณ„ + + onChangeSummary(field.field, "count")} + > + ๊ฐœ์ˆ˜ + + onChangeSummary(field.field, "avg")} + > + ํ‰๊ท  + + onChangeSummary(field.field, "min")} + > + ์ตœ์†Œ + + onChangeSummary(field.field, "max")} + > + ์ตœ๋Œ€ + + + + + + )} + + {/* ๋“œ๋ฆด๋‹ค์šด (๋ฐ์ดํ„ฐ ์…€์—์„œ๋งŒ) */} + {cellType === "data" && rowPath && columnPath && onDrillDown && ( + <> + onDrillDown(rowPath, columnPath)}> + + ์ƒ์„ธ ๋ฐ์ดํ„ฐ ๋ณด๊ธฐ + + + + )} + + {/* ํ•„๋“œ ์ˆจ๊ธฐ๊ธฐ */} + {field && onHideField && ( + onHideField(field.field)}> + + ํ•„๋“œ ์ˆจ๊ธฐ๊ธฐ + + )} + + {/* ๋ณต์‚ฌ */} + + + ๋ณต์‚ฌ + + + + ); +}; + +export default PivotContextMenu; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx index ec194a12..de4a8948 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ { 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: , number: , diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index 063b4c6c..fed43afb 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -2,7 +2,7 @@ /** * FieldPanel ์ปดํฌ๋„ŒํŠธ - * ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ƒ๋‹จ์˜ ํ•„๋“œ ๋ฐฐ์น˜ ์˜์—ญ (ํ•„ํ„ฐ, ์—ด, ํ–‰, ๋ฐ์ดํ„ฐ) + * ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ƒ๋‹จ์˜ ํ•„๋“œ ๋ฐฐ์น˜ ์˜์—ญ (์—ด, ํ–‰, ๋ฐ์ดํ„ฐ) * ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ์œผ๋กœ ํ•„๋“œ ์žฌ๋ฐฐ์น˜ ๊ฐ€๋Šฅ */ @@ -247,7 +247,7 @@ const DroppableArea: React.FC = ({ return (
= ({ data-area={area} > {/* ์˜์—ญ ํ—ค๋” */} -
+
{icon} {title} {areaFields.length > 0 && ( @@ -267,9 +267,9 @@ const DroppableArea: React.FC = ({ {/* ํ•„๋“œ ๋ชฉ๋ก */} -
+
{areaFields.length === 0 ? ( - + ํ•„๋“œ๋ฅผ ์—ฌ๊ธฐ๋กœ ๋“œ๋ž˜๊ทธ ) : ( @@ -443,16 +443,42 @@ export const FieldPanel: React.FC = ({ ? fields.find((f) => `${f.area}-${f.field}` === activeId) : 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) { return ( -
+
+
+ {filterCount > 0 && ( + + + ํ•„ํ„ฐ {filterCount} + + )} + + + ์—ด {columnCount} + + + + ํ–‰ {rowCount} + + + + ๋ฐ์ดํ„ฐ {dataCount} + +
); @@ -466,9 +492,9 @@ export const FieldPanel: React.FC = ({ onDragOver={handleDragOver} onDragEnd={handleDragEnd} > -
- {/* 2x2 ๊ทธ๋ฆฌ๋“œ๋กœ ์˜์—ญ ๋ฐฐ์น˜ */} -
+
+ {/* 4๊ฐœ ์˜์—ญ ๋ฐฐ์น˜: 2x2 ๊ทธ๋ฆฌ๋“œ */} +
{/* ํ•„ํ„ฐ ์˜์—ญ */} = ({ {/* ์ ‘๊ธฐ ๋ฒ„ํŠผ */} {onToggleCollapse && ( -
+
diff --git a/frontend/lib/registry/components/pivot-grid/components/index.ts b/frontend/lib/registry/components/pivot-grid/components/index.ts index a901a7cf..9272e7db 100644 --- a/frontend/lib/registry/components/pivot-grid/components/index.ts +++ b/frontend/lib/registry/components/pivot-grid/components/index.ts @@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser"; export { DrillDownModal } from "./DrillDownModal"; export { FilterPopup } from "./FilterPopup"; export { PivotChart } from "./PivotChart"; +export { PivotContextMenu } from "./ContextMenu"; diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index e711a255..87ba2414 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -90,6 +90,10 @@ export interface PivotFieldConfig { // ๊ณ„์ธต ๊ด€๋ จ displayFolder?: string; // ํ•„๋“œ ์„ ํƒ๊ธฐ์—์„œ ํด๋” ๊ตฌ์กฐ isMeasure?: boolean; // ์ธก์ •๊ฐ’ ์ „์šฉ ํ•„๋“œ (data ์˜์—ญ๋งŒ ๊ฐ€๋Šฅ) + + // ๊ณ„์‚ฐ ํ•„๋“œ + isCalculated?: boolean; // ๊ณ„์‚ฐ ํ•„๋“œ ์—ฌ๋ถ€ + calculateFormula?: string; // ๊ณ„์‚ฐ ์ˆ˜์‹ (์˜ˆ: "[Sales] / [Quantity]") } // ==================== ๋ฐ์ดํ„ฐ ์†Œ์Šค ์„ค์ • ==================== @@ -140,11 +144,13 @@ export interface PivotTotalsConfig { showRowGrandTotals?: boolean; // ํ–‰ ์ดํ•ฉ๊ณ„ ํ‘œ์‹œ showRowTotals?: boolean; // ํ–‰ ์†Œ๊ณ„ ํ‘œ์‹œ rowTotalsPosition?: "first" | "last"; // ์†Œ๊ณ„ ์œ„์น˜ + rowGrandTotalPosition?: "top" | "bottom"; // ํ–‰ ์ด๊ณ„ ์œ„์น˜ (์ƒ๋‹จ/ํ•˜๋‹จ) // ์—ด ์ดํ•ฉ๊ณ„ showColumnGrandTotals?: boolean; // ์—ด ์ดํ•ฉ๊ณ„ ํ‘œ์‹œ showColumnTotals?: boolean; // ์—ด ์†Œ๊ณ„ ํ‘œ์‹œ columnTotalsPosition?: "first" | "last"; // ์†Œ๊ณ„ ์œ„์น˜ + columnGrandTotalPosition?: "left" | "right"; // ์—ด ์ด๊ณ„ ์œ„์น˜ (์ขŒ์ธก/์šฐ์ธก) } // ํ•„๋“œ ์„ ํƒ๊ธฐ ์„ค์ • @@ -214,6 +220,7 @@ export interface PivotStyleConfig { alternateRowColors?: boolean; highlightTotals?: boolean; // ์ดํ•ฉ๊ณ„ ๊ฐ•์กฐ conditionalFormats?: ConditionalFormatRule[]; // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ๊ทœ์น™ + mergeCells?: boolean; // ๊ฐ™์€ ๊ฐ’ ์…€ ๋ณ‘ํ•ฉ } // ==================== ๋‚ด๋ณด๋‚ด๊ธฐ ์„ค์ • ==================== diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 4a21596e..9fc8c161 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1026,10 +1026,14 @@ export const SplitPanelLayoutComponent: React.FC // ์ถ”๊ฐ€ dataFilter ์ ์šฉ let filteredData = result.data || []; 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) => { - return dataFilter.conditions.every((cond: any) => { - const value = item[cond.column]; + return filterConditions.every((cond: any) => { + // columnName ๋˜๋Š” column ์ง€์› + const columnName = cond.columnName || cond.column; + const value = item[columnName]; const condValue = cond.value; switch (cond.operator) { case "equals": @@ -1038,6 +1042,12 @@ export const SplitPanelLayoutComponent: React.FC return value !== condValue; case "contains": 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: return true; } @@ -1193,10 +1203,14 @@ export const SplitPanelLayoutComponent: React.FC // ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ ์ ์šฉ 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) => { - return dataFilter.conditions.every((cond: any) => { - const value = item[cond.column]; + return filterConditions.every((cond: any) => { + // columnName ๋˜๋Š” column ์ง€์› + const columnName = cond.columnName || cond.column; + const value = item[columnName]; const condValue = cond.value; switch (cond.operator) { case "equals": @@ -1205,6 +1219,12 @@ export const SplitPanelLayoutComponent: React.FC return value !== condValue; case "contains": 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: return true; }