Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal

This commit is contained in:
kjs 2026-01-21 18:26:40 +09:00
commit 67f00643bc
11 changed files with 873 additions and 297 deletions

View File

@ -927,11 +927,12 @@ export class TableManagementService {
...layout.properties, ...layout.properties,
widgetType: inputType, widgetType: inputType,
inputType: inputType, inputType: inputType,
// componentConfig 내부의 type 업데이트 // componentConfig 내부의 type, inputType, webType 모두 업데이트
componentConfig: { componentConfig: {
...layout.properties?.componentConfig, ...layout.properties?.componentConfig,
type: newComponentType, type: newComponentType,
inputType: inputType, inputType: inputType,
webType: inputType, // 프론트엔드 SelectBasicComponent에서 카테고리 로딩 여부 판단에 사용
}, },
}; };
@ -947,7 +948,7 @@ export class TableManagementService {
); );
logger.info( logger.info(
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}` `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, webType=${inputType}, componentType=${newComponentType}`
); );
} }

View File

@ -8,7 +8,18 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react"; import {
Search,
Database,
RefreshCw,
Settings,
Plus,
Activity,
Trash2,
Copy,
Check,
ChevronsUpDown,
} from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
@ -97,11 +108,16 @@ export default function TableManagementPage() {
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({}); const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
// 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리) // 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리)
const [entityComboboxOpen, setEntityComboboxOpen] = useState<Record<string, { const [entityComboboxOpen, setEntityComboboxOpen] = useState<
table: boolean; Record<
joinColumn: boolean; string,
displayColumn: boolean; {
}>>({}); table: boolean;
joinColumn: boolean;
displayColumn: boolean;
}
>
>({});
// DDL 기능 관련 상태 // DDL 기능 관련 상태
const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
@ -337,7 +353,11 @@ export default function TableManagementPage() {
if (col.detailSettings && typeof col.detailSettings === "string") { if (col.detailSettings && typeof col.detailSettings === "string") {
try { try {
const parsed = JSON.parse(col.detailSettings); const parsed = JSON.parse(col.detailSettings);
if (parsed.hierarchyRole === "large" || parsed.hierarchyRole === "medium" || parsed.hierarchyRole === "small") { if (
parsed.hierarchyRole === "large" ||
parsed.hierarchyRole === "medium" ||
parsed.hierarchyRole === "small"
) {
hierarchyRole = parsed.hierarchyRole; hierarchyRole = parsed.hierarchyRole;
} }
if (parsed.numberingRuleId) { if (parsed.numberingRuleId) {
@ -439,7 +459,7 @@ export default function TableManagementPage() {
const existingHierarchyRole = hierarchyRole; const existingHierarchyRole = hierarchyRole;
newDetailSettings = JSON.stringify({ newDetailSettings = JSON.stringify({
codeCategory: value, codeCategory: value,
hierarchyRole: existingHierarchyRole hierarchyRole: existingHierarchyRole,
}); });
codeCategory = value; codeCategory = value;
codeValue = value; codeValue = value;
@ -1403,63 +1423,7 @@ export default function TableManagementPage() {
)} )}
</> </>
)} )}
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
{column.inputType === "category" && (
<div className="space-y-2">
<label className="text-muted-foreground mb-1 block text-xs">
(2)
</label>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3">
{secondLevelMenus.length === 0 ? (
<p className="text-muted-foreground text-xs">
2 . .
</p>
) : (
secondLevelMenus.map((menu) => {
// menuObjid를 숫자로 변환하여 비교
const menuObjidNum = Number(menu.menuObjid);
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
return (
<div key={menu.menuObjid} className="flex items-center gap-2">
<input
type="checkbox"
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
checked={isChecked}
onChange={(e) => {
const currentMenus = column.categoryMenus || [];
const newMenus = e.target.checked
? [...currentMenus, menuObjidNum]
: currentMenus.filter((id) => id !== menuObjidNum);
setColumns((prev) =>
prev.map((col) =>
col.columnName === column.columnName
? { ...col, categoryMenus: newMenus }
: col,
),
);
}}
className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2"
/>
<label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
className="flex-1 cursor-pointer text-xs"
>
{menu.parentMenuName} {menu.menuName}
</label>
</div>
);
})
)}
</div>
{column.categoryMenus && column.categoryMenus.length > 0 && (
<p className="text-primary text-xs">
{column.categoryMenus.length}
</p>
)}
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && ( {column.inputType === "entity" && (
<> <>
@ -1483,8 +1447,8 @@ export default function TableManagementPage() {
className="bg-background h-8 w-full justify-between text-xs" className="bg-background h-8 w-full justify-between text-xs"
> >
{column.referenceTable && column.referenceTable !== "none" {column.referenceTable && column.referenceTable !== "none"
? referenceTableOptions.find((opt) => opt.value === column.referenceTable)?.label || ? referenceTableOptions.find((opt) => opt.value === column.referenceTable)
column.referenceTable ?.label || column.referenceTable
: "테이블 선택..."} : "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button> </Button>
@ -1502,10 +1466,17 @@ export default function TableManagementPage() {
key={option.value} key={option.value}
value={`${option.label} ${option.value}`} value={`${option.label} ${option.value}`}
onSelect={() => { onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity", option.value); handleDetailSettingsChange(
column.columnName,
"entity",
option.value,
);
setEntityComboboxOpen((prev) => ({ setEntityComboboxOpen((prev) => ({
...prev, ...prev,
[column.columnName]: { ...prev[column.columnName], table: false }, [column.columnName]: {
...prev[column.columnName],
table: false,
},
})); }));
}} }}
className="text-xs" className="text-xs"
@ -1513,13 +1484,17 @@ export default function TableManagementPage() {
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
column.referenceTable === option.value ? "opacity-100" : "opacity-0", column.referenceTable === option.value
? "opacity-100"
: "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{option.label}</span> <span className="font-medium">{option.label}</span>
{option.value !== "none" && ( {option.value !== "none" && (
<span className="text-muted-foreground text-[10px]">{option.value}</span> <span className="text-muted-foreground text-[10px]">
{option.value}
</span>
)} )}
</div> </div>
</CommandItem> </CommandItem>
@ -1550,9 +1525,13 @@ export default function TableManagementPage() {
role="combobox" role="combobox"
aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false} aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false}
className="bg-background h-8 w-full justify-between text-xs" className="bg-background h-8 w-full justify-between text-xs"
disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0} disabled={
!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0
}
> >
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? ( {!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0 ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div> <div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
... ...
@ -1576,10 +1555,17 @@ export default function TableManagementPage() {
<CommandItem <CommandItem
value="none" value="none"
onSelect={() => { onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_reference_column", "none"); handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
"none",
);
setEntityComboboxOpen((prev) => ({ setEntityComboboxOpen((prev) => ({
...prev, ...prev,
[column.columnName]: { ...prev[column.columnName], joinColumn: false }, [column.columnName]: {
...prev[column.columnName],
joinColumn: false,
},
})); }));
}} }}
className="text-xs" className="text-xs"
@ -1587,7 +1573,9 @@ export default function TableManagementPage() {
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
column.referenceColumn === "none" || !column.referenceColumn ? "opacity-100" : "opacity-0", column.referenceColumn === "none" || !column.referenceColumn
? "opacity-100"
: "opacity-0",
)} )}
/> />
-- -- -- --
@ -1597,10 +1585,17 @@ export default function TableManagementPage() {
key={refCol.columnName} key={refCol.columnName}
value={`${refCol.columnLabel || ""} ${refCol.columnName}`} value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
onSelect={() => { onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName); handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
refCol.columnName,
);
setEntityComboboxOpen((prev) => ({ setEntityComboboxOpen((prev) => ({
...prev, ...prev,
[column.columnName]: { ...prev[column.columnName], joinColumn: false }, [column.columnName]: {
...prev[column.columnName],
joinColumn: false,
},
})); }));
}} }}
className="text-xs" className="text-xs"
@ -1608,13 +1603,17 @@ export default function TableManagementPage() {
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0", column.referenceColumn === refCol.columnName
? "opacity-100"
: "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span> <span className="font-medium">{refCol.columnName}</span>
{refCol.columnLabel && ( {refCol.columnLabel && (
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span> <span className="text-muted-foreground text-[10px]">
{refCol.columnLabel}
</span>
)} )}
</div> </div>
</CommandItem> </CommandItem>
@ -1639,7 +1638,10 @@ export default function TableManagementPage() {
onOpenChange={(open) => onOpenChange={(open) =>
setEntityComboboxOpen((prev) => ({ setEntityComboboxOpen((prev) => ({
...prev, ...prev,
[column.columnName]: { ...prev[column.columnName], displayColumn: open }, [column.columnName]: {
...prev[column.columnName],
displayColumn: open,
},
})) }))
} }
> >
@ -1647,11 +1649,17 @@ export default function TableManagementPage() {
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={entityComboboxOpen[column.columnName]?.displayColumn || false} aria-expanded={
entityComboboxOpen[column.columnName]?.displayColumn || false
}
className="bg-background h-8 w-full justify-between text-xs" className="bg-background h-8 w-full justify-between text-xs"
disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0} disabled={
!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0
}
> >
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? ( {!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0 ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div> <div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
... ...
@ -1675,10 +1683,17 @@ export default function TableManagementPage() {
<CommandItem <CommandItem
value="none" value="none"
onSelect={() => { onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_display_column", "none"); handleDetailSettingsChange(
column.columnName,
"entity_display_column",
"none",
);
setEntityComboboxOpen((prev) => ({ setEntityComboboxOpen((prev) => ({
...prev, ...prev,
[column.columnName]: { ...prev[column.columnName], displayColumn: false }, [column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
})); }));
}} }}
className="text-xs" className="text-xs"
@ -1686,7 +1701,9 @@ export default function TableManagementPage() {
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
column.displayColumn === "none" || !column.displayColumn ? "opacity-100" : "opacity-0", column.displayColumn === "none" || !column.displayColumn
? "opacity-100"
: "opacity-0",
)} )}
/> />
-- -- -- --
@ -1696,10 +1713,17 @@ export default function TableManagementPage() {
key={refCol.columnName} key={refCol.columnName}
value={`${refCol.columnLabel || ""} ${refCol.columnName}`} value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
onSelect={() => { onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName); handleDetailSettingsChange(
column.columnName,
"entity_display_column",
refCol.columnName,
);
setEntityComboboxOpen((prev) => ({ setEntityComboboxOpen((prev) => ({
...prev, ...prev,
[column.columnName]: { ...prev[column.columnName], displayColumn: false }, [column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
})); }));
}} }}
className="text-xs" className="text-xs"
@ -1707,13 +1731,17 @@ export default function TableManagementPage() {
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
column.displayColumn === refCol.columnName ? "opacity-100" : "opacity-0", column.displayColumn === refCol.columnName
? "opacity-100"
: "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span> <span className="font-medium">{refCol.columnName}</span>
{refCol.columnLabel && ( {refCol.columnLabel && (
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span> <span className="text-muted-foreground text-[10px]">
{refCol.columnLabel}
</span>
)} )}
</div> </div>
</CommandItem> </CommandItem>
@ -1765,8 +1793,8 @@ export default function TableManagementPage() {
{numberingRulesLoading {numberingRulesLoading
? "로딩 중..." ? "로딩 중..."
: column.numberingRuleId : column.numberingRuleId
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)?.ruleName || ? numberingRules.find((r) => r.ruleId === column.numberingRuleId)
column.numberingRuleId ?.ruleName || column.numberingRuleId
: "채번규칙 선택..."} : "채번규칙 선택..."}
</span> </span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
@ -1783,7 +1811,9 @@ export default function TableManagementPage() {
<CommandItem <CommandItem
value="none" value="none"
onSelect={async () => { onSelect={async () => {
const columnIndex = columns.findIndex((c) => c.columnName === column.columnName); const columnIndex = columns.findIndex(
(c) => c.columnName === column.columnName,
);
handleColumnChange(columnIndex, "numberingRuleId", undefined); handleColumnChange(columnIndex, "numberingRuleId", undefined);
setNumberingComboboxOpen((prev) => ({ setNumberingComboboxOpen((prev) => ({
...prev, ...prev,
@ -1798,45 +1828,49 @@ export default function TableManagementPage() {
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
!column.numberingRuleId ? "opacity-100" : "opacity-0" !column.numberingRuleId ? "opacity-100" : "opacity-0",
)} )}
/> />
-- -- -- --
</CommandItem> </CommandItem>
{numberingRules.map((rule) => ( {numberingRules.map((rule) => (
<CommandItem <CommandItem
key={rule.ruleId} key={rule.ruleId}
value={`${rule.ruleName} ${rule.ruleId}`} value={`${rule.ruleName} ${rule.ruleId}`}
onSelect={async () => { onSelect={async () => {
const columnIndex = columns.findIndex((c) => c.columnName === column.columnName); const columnIndex = columns.findIndex(
// 상태 업데이트 (c) => c.columnName === column.columnName,
handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId); );
setNumberingComboboxOpen((prev) => ({ // 상태 업데이트
...prev, handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId);
[column.columnName]: false, setNumberingComboboxOpen((prev) => ({
})); ...prev,
// 🆕 자동 저장 [column.columnName]: false,
const updatedColumn = { ...column, numberingRuleId: rule.ruleId }; }));
await handleSaveColumn(updatedColumn); // 🆕 자동 저장
}} const updatedColumn = { ...column, numberingRuleId: rule.ruleId };
className="text-xs" await handleSaveColumn(updatedColumn);
> }}
<Check className="text-xs"
className={cn( >
"mr-2 h-3 w-3", <Check
column.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0" className={cn(
)} "mr-2 h-3 w-3",
/> column.numberingRuleId === rule.ruleId
<div className="flex flex-col"> ? "opacity-100"
<span className="font-medium">{rule.ruleName}</span> : "opacity-0",
{rule.tableName && ( )}
<span className="text-muted-foreground text-[10px]"> />
{rule.tableName}.{rule.columnName} <div className="flex flex-col">
</span> <span className="font-medium">{rule.ruleName}</span>
)} {rule.tableName && (
</div> <span className="text-muted-foreground text-[10px]">
</CommandItem> {rule.tableName}.{rule.columnName}
))} </span>
)}
</div>
</CommandItem>
))}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>

View File

@ -33,6 +33,7 @@ interface EditModalState {
dataflowConfig?: any; dataflowConfig?: any;
dataflowTiming?: string; dataflowTiming?: string;
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정 }; // 🆕 모달 내부 저장 버튼의 제어로직 설정
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 스코프용)
} }
interface EditModalProps { interface EditModalProps {
@ -91,6 +92,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
buttonConfig: undefined, buttonConfig: undefined,
buttonContext: undefined, buttonContext: undefined,
saveButtonConfig: undefined, saveButtonConfig: undefined,
menuObjid: undefined,
}); });
const [screenData, setScreenData] = useState<{ const [screenData, setScreenData] = useState<{
@ -234,7 +236,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너 // 전역 모달 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleOpenEditModal = async (event: CustomEvent) => { const handleOpenEditModal = async (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail; const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail;
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined; let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
@ -258,6 +260,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
buttonConfig, // 🆕 버튼 설정 buttonConfig, // 🆕 버튼 설정
buttonContext, // 🆕 버튼 컨텍스트 buttonContext, // 🆕 버튼 컨텍스트
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정 saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
menuObjid, // 🆕 메뉴 OBJID (카테고리 스코프용)
}); });
// 편집 데이터로 폼 데이터 초기화 // 편집 데이터로 폼 데이터 초기화
@ -1079,6 +1082,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!, id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName, tableName: screenData.screenInfo?.tableName,
}} }}
// 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
menuObjid={modalState.menuObjid}
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용 // 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용 // groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
onSave={shouldUseEditModalSave ? handleSave : undefined} onSave={shouldUseEditModalSave ? handleSave : undefined}

View File

@ -1223,13 +1223,14 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
description: editModalDescription, description: editModalDescription,
modalSize: "lg", modalSize: "lg",
editData: initialData, editData: initialData,
menuObjid, // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
onSave: () => { onSave: () => {
loadData(); // 테이블 데이터 새로고침 loadData(); // 테이블 데이터 새로고침
}, },
}, },
}); });
window.dispatchEvent(event); window.dispatchEvent(event);
}, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]); }, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData, menuObjid]);
// 수정 폼 데이터 변경 핸들러 // 수정 폼 데이터 변경 핸들러
const handleEditFormChange = useCallback((columnName: string, value: any) => { const handleEditFormChange = useCallback((columnName: string, value: any) => {
@ -2730,6 +2731,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
screenId={saveModalScreenId} screenId={saveModalScreenId}
modalSize={component.addModalConfig?.modalSize || "lg"} modalSize={component.addModalConfig?.modalSize || "lg"}
initialData={saveModalData} initialData={saveModalData}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
onSaveSuccess={() => { onSaveSuccess={() => {
// 저장 성공 시 테이블 새로고침 // 저장 성공 시 테이블 새로고침
loadData(currentPage, searchValues); // 현재 페이지로 다시 로드 loadData(currentPage, searchValues); // 현재 페이지로 다시 로드

View File

@ -19,6 +19,7 @@ interface SaveModalProps {
modalSize?: "sm" | "md" | "lg" | "xl" | "full"; modalSize?: "sm" | "md" | "lg" | "xl" | "full";
initialData?: any; // 수정 모드일 때 기존 데이터 initialData?: any; // 수정 모드일 때 기존 데이터
onSaveSuccess?: () => void; // 저장 성공 시 콜백 (테이블 새로고침용) onSaveSuccess?: () => void; // 저장 성공 시 콜백 (테이블 새로고침용)
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 스코프용)
} }
/** /**
@ -33,6 +34,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
modalSize = "lg", modalSize = "lg",
initialData, initialData,
onSaveSuccess, onSaveSuccess,
menuObjid,
}) => { }) => {
const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기 const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기
const [formData, setFormData] = useState<Record<string, any>>(initialData || {}); const [formData, setFormData] = useState<Record<string, any>>(initialData || {});
@ -373,6 +375,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
})); }));
}} }}
hideLabel={false} hideLabel={false}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
/> />
) : ( ) : (
<DynamicComponentRenderer <DynamicComponentRenderer
@ -385,6 +388,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}} }}
screenId={screenId} screenId={screenId}
tableName={screenData.tableName} tableName={screenData.tableName}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
userId={user?.userId} // ✅ 사용자 ID 전달 userId={user?.userId} // ✅ 사용자 ID 전달
userName={user?.userName} // ✅ 사용자 이름 전달 userName={user?.userName} // ✅ 사용자 이름 전달
companyCode={user?.companyCode} // ✅ 회사 코드 전달 companyCode={user?.companyCode} // ✅ 회사 코드 전달

View File

@ -7,6 +7,8 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { import {
PivotGridProps, PivotGridProps,
PivotResult, PivotResult,
@ -50,6 +52,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
// ==================== 상수 ====================
const PIVOT_STATE_VERSION = "1.0"; // 상태 저장 버전 (호환성 체크용)
// ==================== 유틸리티 함수 ==================== // ==================== 유틸리티 함수 ====================
// 셀 병합 정보 계산 // 셀 병합 정보 계산
@ -128,7 +134,10 @@ const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{row.hasChildren && ( {row.hasChildren && (
<button <button
onClick={() => onToggleExpand(row.path)} onClick={(e) => {
e.stopPropagation();
onToggleExpand(row.path);
}}
className="p-0.5 hover:bg-accent rounded" className="p-0.5 hover:bg-accent rounded"
> >
{row.isExpanded ? ( {row.isExpanded ? (
@ -299,6 +308,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// ==================== 상태 ==================== // ==================== 상태 ====================
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields); const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
// 초기 필드 설정 저장 (초기화용)
const initialFieldsRef = useRef<PivotFieldConfig[]>(initialFields);
const [pivotState, setPivotState] = useState<PivotGridState>({ const [pivotState, setPivotState] = useState<PivotGridState>({
expandedRowPaths: [], expandedRowPaths: [],
expandedColumnPaths: [], expandedColumnPaths: [],
@ -344,41 +355,44 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const [resizeStartX, setResizeStartX] = useState<number>(0); const [resizeStartX, setResizeStartX] = useState<number>(0);
const [resizeStartWidth, setResizeStartWidth] = useState<number>(0); const [resizeStartWidth, setResizeStartWidth] = useState<number>(0);
// 외부 fields 변경 시 동기화
useEffect(() => {
if (initialFields.length > 0) {
setFields(initialFields);
}
}, [initialFields]);
// 상태 저장 키 // 상태 저장 키
const stateStorageKey = `pivot-state-${title || "default"}`; const stateStorageKey = `pivot-state-${title || "default"}`;
const persistSettingKey = `pivot-persist-${title || "default"}`;
// 상태 저장 (localStorage) // 상태 유지 설정 (체크박스용)
const saveStateToStorage = useCallback(() => { const [persistState, setPersistState] = useState<boolean>(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return true;
const stateToSave = { const saved = localStorage.getItem(persistSettingKey);
fields, return saved !== null ? saved === "true" : true; // 기본값 true
pivotState, });
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 복원 (localStorage) - 프로덕션 안전성 강화 // 복원 완료 여부 (initialFields 덮어쓰기 방지)
const [isStateRestored, setIsStateRestored] = useState(false);
// 상태 복원 (localStorage) - 마운트 시 한 번만 실행
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// 상태 유지가 꺼져 있으면 복원하지 않음
if (!persistState) {
localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return;
}
try { try {
const savedState = localStorage.getItem(stateStorageKey); const savedState = localStorage.getItem(stateStorageKey);
if (!savedState) return; if (!savedState) {
setIsStateRestored(true);
return;
}
const parsed = JSON.parse(savedState); const parsed = JSON.parse(savedState);
// 버전 체크 - 버전이 다르면 이전 상태 무시 // 버전 체크 - 버전이 다르면 이전 상태 무시
if (parsed.version !== PIVOT_STATE_VERSION) { if (parsed.version !== PIVOT_STATE_VERSION) {
localStorage.removeItem(stateStorageKey); localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return; return;
} }
@ -424,7 +438,72 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 손상된 상태는 제거 // 손상된 상태는 제거
localStorage.removeItem(stateStorageKey); localStorage.removeItem(stateStorageKey);
} }
}, [stateStorageKey]);
setIsStateRestored(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 마운트 시 한 번만 실행
// 외부 fields 변경 시 동기화 (복원이 완료된 후에만, 저장된 상태가 없을 때만)
useEffect(() => {
if (!isStateRestored) return; // 복원 완료 전에는 무시
// 저장된 상태가 있으면 initialFields로 덮어쓰지 않음
if (typeof window !== "undefined") {
const savedState = localStorage.getItem(stateStorageKey);
if (savedState) return; // 이미 저장된 상태가 있으면 무시
}
if (initialFields.length > 0) {
setFields(initialFields);
}
// persistState는 의존성에서 제외 - 체크박스 변경 시 현재 상태 유지
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialFields, isStateRestored, stateStorageKey]);
// 상태 유지 설정 저장 + 켜질 때 현재 상태 즉시 저장
useEffect(() => {
if (typeof window === "undefined") return;
localStorage.setItem(persistSettingKey, String(persistState));
// 상태 유지를 켜면 현재 상태를 즉시 저장
if (persistState && isStateRestored && fields.length > 0) {
const stateToSave = {
version: PIVOT_STATE_VERSION,
fields,
pivotState,
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}
// 상태 유지를 끄면 저장된 상태 삭제
if (!persistState) {
localStorage.removeItem(stateStorageKey);
}
}, [persistState, persistSettingKey, isStateRestored, fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 저장 (localStorage)
const saveStateToStorage = useCallback(() => {
if (typeof window === "undefined" || !persistState) return;
const stateToSave = {
version: PIVOT_STATE_VERSION,
fields,
pivotState,
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey, persistState]);
// 상태 변경 시 자동 저장 (복원 완료 후에만)
useEffect(() => {
if (!persistState || !isStateRestored) return;
// 초기 로드 후에만 저장 (빈 필드일 때는 저장 안 함)
if (fields.length > 0) {
saveStateToStorage();
}
}, [fields, pivotState, sortConfig, columnWidths, persistState, isStateRestored, saveStateToStorage]);
// 데이터 // 데이터
const data = externalData || []; const data = externalData || [];
@ -500,9 +579,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!data || data.length === 0) return data; if (!data || data.length === 0) return data;
// 필터 영역의 필드들로 데이터 필터링 // 모든 영역(행/열/필터)의 필터 값이 있는 필드로 데이터 필터링
const activeFilters = fields.filter( const activeFilters = fields.filter(
(f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 (f) => f.filterValues && f.filterValues.length > 0
); );
if (activeFilters.length === 0) return data; if (activeFilters.length === 0) return data;
@ -1129,6 +1208,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
onFieldsChange={handleFieldsChange} onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel} collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)} onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
initialFields={initialFieldsRef.current}
/> />
{/* 안내 메시지 */} {/* 안내 메시지 */}
@ -1170,7 +1250,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
); );
} }
const { flatColumns, dataMatrix, grandTotals } = pivotResult; const { flatColumns, dataMatrix, grandTotals, columnHeaderLevels } = pivotResult;
// ==================== 키보드 네비게이션 ==================== // ==================== 키보드 네비게이션 ====================
@ -1405,6 +1485,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
onFieldsChange={handleFieldsChange} onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel} collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)} onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
initialFields={initialFieldsRef.current}
/> />
{/* 헤더 툴바 */} {/* 헤더 툴바 */}
@ -1467,6 +1548,22 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</> </>
)} )}
{/* 상태 유지 체크박스 */}
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l">
<Checkbox
id="persist-state"
checked={persistState}
onCheckedChange={(checked) => setPersistState(checked === true)}
className="h-3.5 w-3.5"
/>
<Label
htmlFor="persist-state"
className="text-xs text-muted-foreground cursor-pointer whitespace-nowrap"
>
</Label>
</div>
{/* 차트 토글 */} {/* 차트 토글 */}
{chartConfig && ( {chartConfig && (
<Button <Button
@ -1685,137 +1782,224 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
> >
<table ref={tableRef} className="w-full border-collapse"> <table ref={tableRef} className="w-full border-collapse">
<thead> <thead>
{/* 열 헤더 */} {/* 다중 행 열 헤더 */}
<tr className="bg-background"> {columnHeaderLevels.length > 0 ? (
{/* 좌상단 코너 (행 필드 라벨 + 필터) */} // 열 필드가 있는 경우: 각 레벨별로 행 생성
<th columnHeaderLevels.map((levelCells, levelIdx) => (
className={cn( <tr key={`col-level-${levelIdx}`} className="bg-background">
"border-r border-b border-border", {/* 좌상단 코너 (첫 번째 레벨에만 표시) */}
"px-2 py-1 text-left text-xs font-medium", {levelIdx === 0 && (
"bg-background sticky left-0 top-0 z-20" <th
)} className={cn(
rowSpan={columnFields.length > 0 ? 2 : 1} "border-r border-b border-border",
> "px-2 py-1 text-left text-xs font-medium",
<div className="flex items-center gap-1 flex-wrap"> "bg-background sticky left-0 top-0 z-20"
{rowFields.map((f, idx) => ( )}
<div key={f.field} className="flex items-center gap-0.5 group"> rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
<span>{f.caption}</span> >
<FilterPopup <div className="flex items-center gap-1 flex-wrap">
field={f} {rowFields.map((f, idx) => (
data={data} <div key={f.field} className="flex items-center gap-0.5 group">
onFilterChange={(field, values, type) => { <span>{f.caption}</span>
const newFields = fields.map((fld) => <FilterPopup
fld.field === field.field && fld.area === "row" field={f}
? { ...fld, filterValues: values, filterType: type } data={data}
: fld onFilterChange={(field, values, type) => {
); const newFields = fields.map((fld) =>
handleFieldsChange(newFields); fld.field === field.field && fld.area === "row"
}} ? { ...fld, filterValues: values, filterType: type }
trigger={ : fld
<button );
className={cn( handleFieldsChange(newFields);
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity", }}
"hover:bg-accent", trigger={
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary" <button
)} className={cn(
> "p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
<Filter className="h-3 w-3" /> "hover:bg-accent",
</button> f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
} )}
/> >
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>} <Filter className="h-3 w-3" />
</div> </button>
}
/>
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
</div>
))}
{rowFields.length === 0 && <span></span>}
</div>
</th>
)}
{/* 열 헤더 셀 - 해당 레벨 */}
{levelCells.map((cell, cellIdx) => (
<th
key={`${levelIdx}-${cellIdx}`}
className={cn(
"border-r border-b border-border relative",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10",
levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={cell.colSpan * (dataFields.length || 1)}
>
<div className="flex items-center justify-center gap-1">
<span>{cell.caption || "(전체)"}</span>
{levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && (
<SortIcon field={dataFields[0].field} />
)}
</div>
</th>
))} ))}
{rowFields.length === 0 && <span></span>}
</div>
</th>
{/* 열 헤더 셀 */} {/* 행 총계 헤더 (첫 번째 레벨에만 표시) */}
{flatColumns.map((col, idx) => ( {levelIdx === 0 && totals?.showRowGrandTotals && (
<th <th
key={idx} className={cn(
className={cn( "border-b border-border",
"border-r border-b border-border relative group", "px-2 py-1 text-center text-xs font-medium",
"px-2 py-1 text-center text-xs font-medium", "bg-background sticky top-0 z-10"
"bg-background sticky top-0 z-10", )}
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50" colSpan={dataFields.length || 1}
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
>
</th>
)} )}
colSpan={dataFields.length || 1}
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
>
<div className="flex items-center justify-center gap-1">
<span>{col.caption || "(전체)"}</span>
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
</div>
{/* 열 리사이즈 핸들 */}
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
"hover:bg-primary/50 transition-colors",
resizingColumn === idx && "bg-primary"
)}
onMouseDown={(e) => handleResizeStart(idx, e)}
/>
</th>
))}
{/* 행 총계 헤더 */} {/* 열 필드 필터 (첫 번째 레벨에만 표시) */}
{totals?.showRowGrandTotals && ( {levelIdx === 0 && columnFields.length > 0 && (
<th <th
className={cn( className={cn(
"border-b border-border", "border-b border-border",
"px-2 py-1 text-center text-xs font-medium", "px-1 py-1 text-center text-xs",
"bg-background sticky top-0 z-10" "bg-background sticky top-0 z-10"
)}
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
>
<div className="flex flex-col gap-0.5">
{columnFields.map((f) => (
<FilterPopup
key={f.field}
field={f}
data={data}
onFilterChange={(field, values, type) => {
const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "column"
? { ...fld, filterValues: values, filterType: type }
: fld
);
handleFieldsChange(newFields);
}}
trigger={
<button
className={cn(
"p-0.5 rounded hover:bg-accent",
f.filterValues && f.filterValues.length > 0 && "text-primary"
)}
title={`${f.caption} 필터`}
>
<Filter className="h-3 w-3" />
</button>
}
/>
))}
</div>
</th>
)} )}
colSpan={dataFields.length || 1} </tr>
rowSpan={dataFields.length > 1 ? 2 : 1} ))
> ) : (
// 열 필드가 없는 경우: 단일 행
</th> <tr className="bg-background">
)}
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
{columnFields.length > 0 && (
<th <th
className={cn( className={cn(
"border-b border-border", "border-r border-b border-border",
"px-1 py-1 text-center text-xs", "px-2 py-1 text-left text-xs font-medium",
"bg-background sticky top-0 z-10" "bg-background sticky left-0 top-0 z-20"
)} )}
rowSpan={dataFields.length > 1 ? 2 : 1} rowSpan={dataFields.length > 1 ? 2 : 1}
> >
<div className="flex flex-col gap-0.5"> <div className="flex items-center gap-1 flex-wrap">
{columnFields.map((f) => ( {rowFields.map((f, idx) => (
<FilterPopup <div key={f.field} className="flex items-center gap-0.5 group">
key={f.field} <span>{f.caption}</span>
field={f} <FilterPopup
data={data} field={f}
onFilterChange={(field, values, type) => { data={data}
const newFields = fields.map((fld) => onFilterChange={(field, values, type) => {
fld.field === field.field && fld.area === "column" const newFields = fields.map((fld) =>
? { ...fld, filterValues: values, filterType: type } fld.field === field.field && fld.area === "row"
: fld ? { ...fld, filterValues: values, filterType: type }
); : fld
handleFieldsChange(newFields); );
}} handleFieldsChange(newFields);
trigger={ }}
<button trigger={
className={cn( <button
"p-0.5 rounded hover:bg-accent", className={cn(
f.filterValues && f.filterValues.length > 0 && "text-primary" "p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
)} "hover:bg-accent",
title={`${f.caption} 필터`} f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
> )}
<Filter className="h-3 w-3" /> >
</button> <Filter className="h-3 w-3" />
} </button>
/> }
/>
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
</div>
))} ))}
{rowFields.length === 0 && <span></span>}
</div> </div>
</th> </th>
)}
</tr> {/* 열 헤더 셀 (열 필드 없을 때) */}
{flatColumns.map((col, idx) => (
<th
key={idx}
className={cn(
"border-r border-b border-border relative group",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10",
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={dataFields.length || 1}
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
>
<div className="flex items-center justify-center gap-1">
<span>{col.caption || "(전체)"}</span>
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
</div>
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
"hover:bg-primary/50 transition-colors",
resizingColumn === idx && "bg-primary"
)}
onMouseDown={(e) => handleResizeStart(idx, e)}
/>
</th>
))}
{/* 행 총계 헤더 */}
{totals?.showRowGrandTotals && (
<th
className={cn(
"border-b border-border",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
rowSpan={dataFields.length > 1 ? 2 : 1}
>
</th>
)}
</tr>
)}
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
{dataFields.length > 1 && ( {dataFields.length > 1 && (

View File

@ -16,6 +16,7 @@ import {
PivotAreaType, PivotAreaType,
AggregationType, AggregationType,
FieldDataType, FieldDataType,
DateGroupInterval,
} from "./types"; } from "./types";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -202,6 +203,28 @@ const AreaDropZone: React.FC<AreaDropZoneProps> = ({
</Select> </Select>
)} )}
{/* 행/열 영역에서 날짜 타입일 때 그룹화 옵션 */}
{(area === "row" || area === "column") && field.dataType === "date" && (
<Select
value={field.groupInterval || "__none__"}
onValueChange={(v) => onUpdateField(idx, {
groupInterval: v === "__none__" ? undefined : v as DateGroupInterval
})}
>
<SelectTrigger className="h-6 w-16 text-xs">
<SelectValue placeholder="그룹" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
<SelectItem value="year"></SelectItem>
<SelectItem value="quarter"></SelectItem>
<SelectItem value="month"></SelectItem>
<SelectItem value="week"></SelectItem>
<SelectItem value="day"></SelectItem>
</SelectContent>
</Select>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -295,7 +318,8 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({ const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({
column_name: c.columnName || c.column_name, column_name: c.columnName || c.column_name,
data_type: c.dataType || c.data_type || "text", data_type: c.dataType || c.data_type || "text",
column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name, // 라벨 우선순위: displayName > comment > columnLabel > columnName
column_comment: c.displayName || c.comment || c.columnLabel || c.column_label || c.columnName || c.column_name,
})); }));
setColumns(mappedColumns); setColumns(mappedColumns);
} catch (error) { } catch (error) {

View File

@ -37,6 +37,12 @@ import {
BarChart3, BarChart3,
GripVertical, GripVertical,
ChevronDown, ChevronDown,
RotateCcw,
FilterX,
LayoutGrid,
Trash2,
Calendar,
Check,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -56,6 +62,8 @@ interface FieldPanelProps {
onFieldSettingsChange?: (field: PivotFieldConfig) => void; onFieldSettingsChange?: (field: PivotFieldConfig) => void;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapse?: () => void; onToggleCollapse?: () => void;
/** 초기 필드 설정 (필드 배치 초기화용) */
initialFields?: PivotFieldConfig[];
} }
interface FieldChipProps { interface FieldChipProps {
@ -123,15 +131,33 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
transition, transition,
}; };
// 필터 적용 여부 확인
const hasFilter = field.filterValues && field.filterValues.length > 0;
const filterCount = field.filterValues?.length || 0;
// 그룹화 상태 확인
const hasGrouping = field.groupInterval && field.dataType === "date";
const groupLabels: Record<string, string> = {
year: "연도",
quarter: "분기",
month: "월",
week: "주",
day: "일",
};
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs", "inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
"bg-background border border-border shadow-sm", "bg-background border shadow-sm",
"hover:bg-accent/50 transition-colors", "hover:bg-accent/50 transition-colors",
isDragging && "opacity-50 shadow-lg" isDragging && "opacity-50 shadow-lg",
// 필터 적용 시 강조 표시
hasFilter
? "border-primary bg-primary/5"
: "border-border"
)} )}
> >
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
@ -143,11 +169,30 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
<GripVertical className="h-3 w-3" /> <GripVertical className="h-3 w-3" />
</button> </button>
{/* 필터 아이콘 (필터 적용 시) */}
{hasFilter && (
<Filter className="h-3 w-3 text-primary" />
)}
{/* 필드 라벨 */} {/* 필드 라벨 */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 hover:text-primary"> <button className="flex items-center gap-1 hover:text-primary">
<span className="font-medium">{field.caption}</span> <span className={cn("font-medium", hasFilter && "text-primary")}>
{field.caption}
</span>
{/* 그룹화 적용 표시 */}
{hasGrouping && (
<span className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 text-[10px] px-1 rounded">
{groupLabels[field.groupInterval!]}
</span>
)}
{/* 필터 적용 개수 배지 */}
{hasFilter && (
<span className="bg-primary text-primary-foreground text-[10px] px-1 rounded">
{filterCount}
</span>
)}
{field.area === "data" && field.summaryType && ( {field.area === "data" && field.summaryType && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({getSummaryLabel(field.summaryType)}) ({getSummaryLabel(field.summaryType)})
@ -197,6 +242,59 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
{/* 날짜 그룹화 옵션 (행/열 영역의 날짜 타입 필드만) */}
{(field.area === "row" || field.area === "column") &&
field.dataType === "date" && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
</div>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: undefined })}
className="pl-6"
>
{!field.groupInterval && <Check className="h-3 w-3 mr-2" />}
<span className={!field.groupInterval ? "font-medium" : ""}> </span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "year" })}
className="pl-6"
>
{field.groupInterval === "year" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "year" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "quarter" })}
className="pl-6"
>
{field.groupInterval === "quarter" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "quarter" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "month" })}
className="pl-6"
>
{field.groupInterval === "month" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "month" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "week" })}
className="pl-6"
>
{field.groupInterval === "week" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "week" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "day" })}
className="pl-6"
>
{field.groupInterval === "day" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "day" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
onSettingsChange?.({ onSettingsChange?.({
@ -208,6 +306,19 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* 필터 초기화 (필터가 적용된 경우에만 표시) */}
{hasFilter && (
<>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, filterValues: [] })}
className="text-orange-600"
>
<Filter className="h-3 w-3 mr-2" />
({filterCount} )
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, visible: false })} onClick={() => onSettingsChange?.({ ...field, visible: false })}
> >
@ -326,10 +437,73 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onFieldSettingsChange, onFieldSettingsChange,
collapsed = false, collapsed = false,
onToggleCollapse, onToggleCollapse,
initialFields,
}) => { }) => {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [overArea, setOverArea] = useState<PivotAreaType | null>(null); const [overArea, setOverArea] = useState<PivotAreaType | null>(null);
// 필터만 초기화
const handleResetFilters = () => {
const newFields = fields.map((f) => ({
...f,
filterValues: [],
filterType: "include" as const,
}));
onFieldsChange(newFields);
};
// 필드 배치 초기화 (initialFields가 있으면 사용, 없으면 모든 필드를 row로)
const handleResetLayout = () => {
if (initialFields && initialFields.length > 0) {
// initialFields의 영역 배치를 복원하되 현재 필터 값은 유지
const newFields = fields.map((f) => {
const initial = initialFields.find((i) => i.field === f.field);
if (initial) {
return {
...f,
area: initial.area,
areaIndex: initial.areaIndex,
};
}
return f;
});
onFieldsChange(newFields);
} else {
// 기본값: 숫자는 data, 나머지는 row로
const newFields = fields.map((f, idx) => ({
...f,
area: f.dataType === "number" ? "data" : "row" as PivotAreaType,
areaIndex: idx,
visible: true,
}));
onFieldsChange(newFields);
}
};
// 전체 초기화 (필드 배치 + 필터)
const handleResetAll = () => {
if (initialFields && initialFields.length > 0) {
// initialFields로 완전히 복원
onFieldsChange([...initialFields]);
} else {
// 기본값으로 초기화
const newFields = fields.map((f, idx) => ({
...f,
area: f.dataType === "number" ? "data" : "row" as PivotAreaType,
areaIndex: idx,
visible: true,
filterValues: [],
filterType: "include" as const,
}));
onFieldsChange(newFields);
}
};
// 필터가 적용된 필드 개수
const filteredFieldCount = fields.filter(
(f) => f.filterValues && f.filterValues.length > 0
).length;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
@ -576,19 +750,60 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
/> />
</div> </div>
{/* 접기 버튼 */} {/* 하단 버튼 영역 */}
{onToggleCollapse && ( <div className="flex items-center justify-between mt-1.5">
<div className="flex justify-center mt-1.5"> {/* 초기화 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2 text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-3 w-3 mr-1" />
{filteredFieldCount > 0 && (
<span className="ml-1 bg-orange-500 text-white text-[10px] px-1 rounded">
{filteredFieldCount}
</span>
)}
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={handleResetFilters}>
<FilterX className="h-3.5 w-3.5 mr-2 text-orange-500" />
{filteredFieldCount > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
({filteredFieldCount})
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleResetLayout}>
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-blue-500" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleResetAll} className="text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 접기 버튼 */}
{onToggleCollapse && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-xs h-5 px-2" className="text-xs h-6 px-2"
> >
</Button> </Button>
</div> )}
)} </div>
</div> </div>
{/* 드래그 오버레이 */} {/* 드래그 오버레이 */}

View File

@ -304,6 +304,7 @@ export interface PivotHeaderNode {
level: number; // 깊이 level: number; // 깊이
children?: PivotHeaderNode[]; // 자식 노드 children?: PivotHeaderNode[]; // 자식 노드
isExpanded: boolean; // 확장 상태 isExpanded: boolean; // 확장 상태
hasChildren: boolean; // 자식 존재 가능 여부 (다음 레벨 필드 있음)
path: string[]; // 경로 (드릴다운용) path: string[]; // 경로 (드릴다운용)
subtotal?: PivotCellValue[]; // 소계 subtotal?: PivotCellValue[]; // 소계
span?: number; // colspan/rowspan span?: number; // colspan/rowspan
@ -330,9 +331,12 @@ export interface PivotResult {
// 플랫 행 목록 (렌더링용) // 플랫 행 목록 (렌더링용)
flatRows: PivotFlatRow[]; flatRows: PivotFlatRow[];
// 플랫 열 목록 (렌더링용) // 플랫 열 목록 (렌더링용) - 리프 노드만
flatColumns: PivotFlatColumn[]; flatColumns: PivotFlatColumn[];
// 열 헤더 레벨별 (다중 행 헤더용)
columnHeaderLevels: PivotColumnHeaderCell[][];
// 총합계 // 총합계
grandTotals: { grandTotals: {
row: Map<string, PivotCellValue[]>; // 행별 총합 row: Map<string, PivotCellValue[]>; // 행별 총합
@ -360,6 +364,14 @@ export interface PivotFlatColumn {
isTotal?: boolean; isTotal?: boolean;
} }
// 열 헤더 셀 (다중 행 헤더용)
export interface PivotColumnHeaderCell {
caption: string; // 표시 텍스트
colSpan: number; // 병합할 열 수
path: string[]; // 전체 경로
level: number; // 레벨 (0부터 시작)
}
// ==================== 상태 관리 ==================== // ==================== 상태 관리 ====================
export interface PivotGridState { export interface PivotGridState {

View File

@ -10,6 +10,7 @@ import {
PivotFlatRow, PivotFlatRow,
PivotFlatColumn, PivotFlatColumn,
PivotCellValue, PivotCellValue,
PivotColumnHeaderCell,
DateGroupInterval, DateGroupInterval,
AggregationType, AggregationType,
SummaryDisplayMode, SummaryDisplayMode,
@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string {
return path.join("||"); return path.join("||");
} }
/**
* ( )
*/
function generateAllPaths(
data: Record<string, any>[],
fields: PivotFieldConfig[]
): string[] {
const allPaths: string[] = [];
// 각 레벨까지의 고유 경로 수집
for (let depth = 1; depth <= fields.length; depth++) {
const fieldsAtDepth = fields.slice(0, depth);
const pathSet = new Set<string>();
data.forEach((row) => {
const path = fieldsAtDepth.map((f) => getFieldValue(row, f));
pathSet.add(pathToKey(path));
});
pathSet.forEach((pathKey) => allPaths.push(pathKey));
}
return allPaths;
}
/** /**
* *
*/ */
@ -129,6 +155,7 @@ function buildHeaderTree(
caption: key, caption: key,
level: 0, level: 0,
isExpanded: expandedPaths.has(pathKey), isExpanded: expandedPaths.has(pathKey),
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
path: path, path: path,
span: 1, span: 1,
}; };
@ -195,6 +222,7 @@ function buildChildNodes(
caption: key, caption: key,
level: level, level: level,
isExpanded: expandedPaths.has(pathKey), isExpanded: expandedPaths.has(pathKey),
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
path: path, path: path,
span: 1, span: 1,
}; };
@ -238,7 +266,7 @@ function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
level: node.level, level: node.level,
caption: node.caption, caption: node.caption,
isExpanded: node.isExpanded, isExpanded: node.isExpanded,
hasChildren: !!(node.children && node.children.length > 0), hasChildren: node.hasChildren, // 노드에서 직접 가져옴 (다음 레벨 필드 존재 여부 기준)
}); });
if (node.isExpanded && node.children) { if (node.isExpanded && node.children) {
@ -324,6 +352,66 @@ function getMaxColumnLevel(
return Math.min(maxLevel, totalFields - 1); return Math.min(maxLevel, totalFields - 1);
} }
/**
*
* colSpan
*/
function buildColumnHeaderLevels(
nodes: PivotHeaderNode[],
totalLevels: number
): PivotColumnHeaderCell[][] {
if (totalLevels === 0 || nodes.length === 0) {
return [];
}
const levels: PivotColumnHeaderCell[][] = Array.from(
{ length: totalLevels },
() => []
);
// 리프 노드 수 계산 (colSpan 계산용)
function countLeaves(node: PivotHeaderNode): number {
if (!node.children || node.children.length === 0 || !node.isExpanded) {
return 1;
}
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
}
// 트리 순회하며 각 레벨에 셀 추가
function traverse(node: PivotHeaderNode, level: number) {
const colSpan = countLeaves(node);
levels[level].push({
caption: node.caption,
colSpan,
path: node.path,
level,
});
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, level + 1);
}
} else if (level < totalLevels - 1) {
// 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움
for (let i = level + 1; i < totalLevels; i++) {
levels[i].push({
caption: "",
colSpan,
path: node.path,
level: i,
});
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return levels;
}
// ==================== 데이터 매트릭스 생성 ==================== // ==================== 데이터 매트릭스 생성 ====================
/** /**
@ -733,12 +821,11 @@ export function processPivotData(
uniqueValues.forEach((val) => expandedRowSet.add(val)); uniqueValues.forEach((val) => expandedRowSet.add(val));
} }
if (expandedColumnPaths.length === 0 && columnFields.length > 0) { // 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음)
const firstField = columnFields[0]; // 모든 가능한 열 경로를 확장 상태로 설정
const uniqueValues = new Set( if (columnFields.length > 0) {
filteredData.map((row) => getFieldValue(row, firstField)) const allColumnPaths = generateAllPaths(filteredData, columnFields);
); allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey));
uniqueValues.forEach((val) => expandedColSet.add(val));
} }
// 헤더 트리 생성 // 헤더 트리 생성
@ -786,6 +873,12 @@ export function processPivotData(
grandTotals.grand grandTotals.grand
); );
// 다중 행 열 헤더 생성
const columnHeaderLevels = buildColumnHeaderLevels(
columnHeaders,
columnFields.length
);
return { return {
rowHeaders, rowHeaders,
columnHeaders, columnHeaders,
@ -797,6 +890,7 @@ export function processPivotData(
caption: path[path.length - 1] || "", caption: path[path.length - 1] || "",
span: 1, span: 1,
})), })),
columnHeaderLevels,
grandTotals, grandTotals,
}; };
} }

View File

@ -235,7 +235,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
setIsLoadingCategories(true); setIsLoadingCategories(true);
import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => { import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => {
getCategoryValues(component.tableName!, component.columnName!) // 🆕 menuObjid를 4번째 파라미터로 전달 (카테고리 스코프 적용)
getCategoryValues(component.tableName!, component.columnName!, false, menuObjid)
.then((response) => { .then((response) => {
if (response.success && "data" in response && response.data) { if (response.success && "data" in response && response.data) {
const activeValues = response.data.filter((v: any) => v.isActive !== false); const activeValues = response.data.filter((v: any) => v.isActive !== false);