Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
e14e0bd029
|
|
@ -606,7 +606,7 @@ router.get(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { enableEntityJoin, groupByColumns } = req.query;
|
const { enableEntityJoin, groupByColumns, primaryKeyColumn } = req.query;
|
||||||
const enableEntityJoinFlag =
|
const enableEntityJoinFlag =
|
||||||
enableEntityJoin === "true" ||
|
enableEntityJoin === "true" ||
|
||||||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
|
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
|
||||||
|
|
@ -626,17 +626,22 @@ router.get(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 primaryKeyColumn 파싱
|
||||||
|
const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
|
||||||
|
|
||||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||||
enableEntityJoin: enableEntityJoinFlag,
|
enableEntityJoin: enableEntityJoinFlag,
|
||||||
groupByColumns: groupByColumnsArray,
|
groupByColumns: groupByColumnsArray,
|
||||||
|
primaryKeyColumn: primaryKeyColumnStr,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
|
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 + Primary Key 컬럼 포함)
|
||||||
const result = await dataService.getRecordDetail(
|
const result = await dataService.getRecordDetail(
|
||||||
tableName,
|
tableName,
|
||||||
id,
|
id,
|
||||||
enableEntityJoinFlag,
|
enableEntityJoinFlag,
|
||||||
groupByColumnsArray
|
groupByColumnsArray,
|
||||||
|
primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
|
||||||
|
|
@ -490,7 +490,8 @@ class DataService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
id: string | number,
|
id: string | number,
|
||||||
enableEntityJoin: boolean = false,
|
enableEntityJoin: boolean = false,
|
||||||
groupByColumns: string[] = []
|
groupByColumns: string[] = [],
|
||||||
|
primaryKeyColumn?: string // 🆕 클라이언트에서 전달한 Primary Key 컬럼명
|
||||||
): Promise<ServiceResponse<any>> {
|
): Promise<ServiceResponse<any>> {
|
||||||
try {
|
try {
|
||||||
// 테이블 접근 검증
|
// 테이블 접근 검증
|
||||||
|
|
@ -499,7 +500,11 @@ class DataService {
|
||||||
return validation.error!;
|
return validation.error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary Key 컬럼 찾기
|
// 🆕 클라이언트에서 전달한 Primary Key 컬럼이 있으면 우선 사용
|
||||||
|
let pkColumn = primaryKeyColumn || "";
|
||||||
|
|
||||||
|
// Primary Key 컬럼이 없으면 자동 감지
|
||||||
|
if (!pkColumn) {
|
||||||
const pkResult = await query<{ attname: string }>(
|
const pkResult = await query<{ attname: string }>(
|
||||||
`SELECT a.attname
|
`SELECT a.attname
|
||||||
FROM pg_index i
|
FROM pg_index i
|
||||||
|
|
@ -508,10 +513,16 @@ class DataService {
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
let pkColumn = "id"; // 기본값
|
pkColumn = "id"; // 기본값
|
||||||
if (pkResult.length > 0) {
|
if (pkResult.length > 0) {
|
||||||
pkColumn = pkResult[0].attname;
|
pkColumn = pkResult[0].attname;
|
||||||
}
|
}
|
||||||
|
console.log(`🔑 [getRecordDetail] 자동 감지된 Primary Key:`, pkResult);
|
||||||
|
} else {
|
||||||
|
console.log(`🔑 [getRecordDetail] 클라이언트 제공 Primary Key: ${pkColumn}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔑 [getRecordDetail] 테이블: ${tableName}, Primary Key 컬럼: ${pkColumn}, 조회 ID: ${id}`);
|
||||||
|
|
||||||
// 🆕 Entity Join이 활성화된 경우
|
// 🆕 Entity Join이 활성화된 경우
|
||||||
if (enableEntityJoin) {
|
if (enableEntityJoin) {
|
||||||
|
|
|
||||||
|
|
@ -374,8 +374,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const editId = urlParams.get("editId");
|
const editId = urlParams.get("editId");
|
||||||
const tableName = urlParams.get("tableName") || screenInfo.tableName;
|
const tableName = urlParams.get("tableName") || screenInfo.tableName;
|
||||||
const groupByColumnsParam = urlParams.get("groupByColumns");
|
const groupByColumnsParam = urlParams.get("groupByColumns");
|
||||||
|
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
|
||||||
|
|
||||||
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam });
|
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn });
|
||||||
|
|
||||||
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||||
if (mode === "edit" && editId && tableName) {
|
if (mode === "edit" && editId && tableName) {
|
||||||
|
|
@ -414,6 +415,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
params.groupByColumns = JSON.stringify(groupByColumns);
|
params.groupByColumns = JSON.stringify(groupByColumns);
|
||||||
console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns);
|
console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns);
|
||||||
}
|
}
|
||||||
|
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
|
||||||
|
if (primaryKeyColumn) {
|
||||||
|
params.primaryKeyColumn = primaryKeyColumn;
|
||||||
|
console.log("✅ [ScreenModal] primaryKeyColumn을 params에 추가:", primaryKeyColumn);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("📡 [ScreenModal] 실제 API 요청:", {
|
console.log("📡 [ScreenModal] 실제 API 요청:", {
|
||||||
url: `/data/${tableName}/${editId}`,
|
url: `/data/${tableName}/${editId}`,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ 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 { dynamicFormApi } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||||
|
import { AutoFillMapping } from "./config";
|
||||||
|
|
||||||
export function EntitySearchInputComponent({
|
export function EntitySearchInputComponent({
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -37,6 +38,8 @@ export function EntitySearchInputComponent({
|
||||||
formData,
|
formData,
|
||||||
// 다중선택 props
|
// 다중선택 props
|
||||||
multiple: multipleProp,
|
multiple: multipleProp,
|
||||||
|
// 자동 채움 매핑 props
|
||||||
|
autoFillMappings: autoFillMappingsProp,
|
||||||
// 추가 props
|
// 추가 props
|
||||||
component,
|
component,
|
||||||
isInteractive,
|
isInteractive,
|
||||||
|
|
@ -47,6 +50,7 @@ export function EntitySearchInputComponent({
|
||||||
isInteractive?: boolean;
|
isInteractive?: boolean;
|
||||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
|
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
|
||||||
|
autoFillMappings?: AutoFillMapping[]; // 자동 채움 매핑
|
||||||
}) {
|
}) {
|
||||||
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
||||||
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
||||||
|
|
@ -55,6 +59,18 @@ export function EntitySearchInputComponent({
|
||||||
const config = component?.componentConfig || component?.webTypeConfig || {};
|
const config = component?.componentConfig || component?.webTypeConfig || {};
|
||||||
const isMultiple = multipleProp ?? config.multiple ?? false;
|
const isMultiple = multipleProp ?? config.multiple ?? false;
|
||||||
|
|
||||||
|
// 자동 채움 매핑 설정 (props > config)
|
||||||
|
const autoFillMappings: AutoFillMapping[] = autoFillMappingsProp ?? config.autoFillMappings ?? [];
|
||||||
|
|
||||||
|
// 디버그: 자동 채움 매핑 설정 확인
|
||||||
|
console.log("🔧 [EntitySearchInput] 자동 채움 매핑 설정:", {
|
||||||
|
autoFillMappingsProp,
|
||||||
|
configAutoFillMappings: config.autoFillMappings,
|
||||||
|
effectiveAutoFillMappings: autoFillMappings,
|
||||||
|
isInteractive,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
});
|
||||||
|
|
||||||
// 연쇄관계 설정 추출
|
// 연쇄관계 설정 추출
|
||||||
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
||||||
// cascadingParentField: ConfigPanel에서 저장되는 필드명
|
// cascadingParentField: ConfigPanel에서 저장되는 필드명
|
||||||
|
|
@ -309,6 +325,23 @@ export function EntitySearchInputComponent({
|
||||||
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 자동 채움 매핑 적용
|
||||||
|
if (autoFillMappings.length > 0 && isInteractive && onFormDataChange && fullData) {
|
||||||
|
console.log("🔄 자동 채움 매핑 적용:", { mappings: autoFillMappings, fullData });
|
||||||
|
|
||||||
|
for (const mapping of autoFillMappings) {
|
||||||
|
if (mapping.sourceField && mapping.targetField) {
|
||||||
|
const sourceValue = fullData[mapping.sourceField];
|
||||||
|
if (sourceValue !== undefined) {
|
||||||
|
onFormDataChange(mapping.targetField, sourceValue);
|
||||||
|
console.log(` ✅ ${mapping.sourceField} → ${mapping.targetField}:`, sourceValue);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ ${mapping.sourceField} 값이 없음`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다중선택 모드에서 개별 항목 제거
|
// 다중선택 모드에서 개별 항목 제거
|
||||||
|
|
@ -436,7 +469,7 @@ export function EntitySearchInputComponent({
|
||||||
const isSelected = selectedValues.includes(String(option[valueField]));
|
const isSelected = selectedValues.includes(String(option[valueField]));
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option[valueField] || index}
|
key={option[valueField] ?? `option-${index}`}
|
||||||
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
||||||
onSelect={() => handleSelectOption(option)}
|
onSelect={() => handleSelectOption(option)}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
|
|
@ -509,7 +542,7 @@ export function EntitySearchInputComponent({
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{effectiveOptions.map((option, index) => (
|
{effectiveOptions.map((option, index) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option[valueField] || index}
|
key={option[valueField] ?? `select-option-${index}`}
|
||||||
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
||||||
onSelect={() => handleSelectOption(option)}
|
onSelect={() => handleSelectOption(option)}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
|
import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
|
||||||
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
|
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
|
||||||
import { EntitySearchInputConfig } from "./config";
|
import { EntitySearchInputConfig, AutoFillMapping } from "./config";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
|
|
@ -236,6 +236,7 @@ export function EntitySearchInputConfigPanel({
|
||||||
const newConfig = { ...localConfig, ...updates };
|
const newConfig = { ...localConfig, ...updates };
|
||||||
setLocalConfig(newConfig);
|
setLocalConfig(newConfig);
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
|
console.log("📝 [EntitySearchInput] 설정 업데이트:", { updates, newConfig });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연쇄 드롭다운 활성화/비활성화
|
// 연쇄 드롭다운 활성화/비활성화
|
||||||
|
|
@ -636,9 +637,9 @@ export function EntitySearchInputConfigPanel({
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{tableColumns.map((column) => (
|
{tableColumns.map((column, idx) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={column.columnName}
|
key={column.columnName || `display-col-${idx}`}
|
||||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateConfig({ displayField: column.columnName });
|
updateConfig({ displayField: column.columnName });
|
||||||
|
|
@ -690,9 +691,9 @@ export function EntitySearchInputConfigPanel({
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{tableColumns.map((column) => (
|
{tableColumns.map((column, idx) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={column.columnName}
|
key={column.columnName || `value-col-${idx}`}
|
||||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateConfig({ valueField: column.columnName });
|
updateConfig({ valueField: column.columnName });
|
||||||
|
|
@ -812,8 +813,8 @@ export function EntitySearchInputConfigPanel({
|
||||||
<SelectValue placeholder="컬럼 선택" />
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tableColumns.map((col) => (
|
{tableColumns.map((col, colIdx) => (
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
<SelectItem key={col.columnName || `modal-col-${colIdx}`} value={col.columnName}>
|
||||||
{col.displayName || col.columnName}
|
{col.displayName || col.columnName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -860,8 +861,8 @@ export function EntitySearchInputConfigPanel({
|
||||||
<SelectValue placeholder="필드 선택" />
|
<SelectValue placeholder="필드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tableColumns.map((col) => (
|
{tableColumns.map((col, colIdx) => (
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
<SelectItem key={col.columnName || `search-col-${colIdx}`} value={col.columnName}>
|
||||||
{col.displayName || col.columnName}
|
{col.displayName || col.columnName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -919,8 +920,8 @@ export function EntitySearchInputConfigPanel({
|
||||||
<SelectValue placeholder="필드 선택" />
|
<SelectValue placeholder="필드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tableColumns.map((col) => (
|
{tableColumns.map((col, colIdx) => (
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
<SelectItem key={col.columnName || `additional-col-${colIdx}`} value={col.columnName}>
|
||||||
{col.displayName || col.columnName}
|
{col.displayName || col.columnName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -939,6 +940,105 @@ export function EntitySearchInputConfigPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 자동 채움 매핑 설정 */}
|
||||||
|
<div className="border-t pt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
<h4 className="text-sm font-medium">자동 채움 매핑</h4>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const mappings = localConfig.autoFillMappings || [];
|
||||||
|
updateConfig({ autoFillMappings: [...mappings, { sourceField: "", targetField: "" }] });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!localConfig.tableName || isLoadingColumns}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
엔티티를 선택하면 소스 필드의 값이 대상 필드에 자동으로 채워집니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(localConfig.autoFillMappings || []).length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(localConfig.autoFillMappings || []).map((mapping, index) => (
|
||||||
|
<div key={`autofill-mapping-${index}`} className="flex items-center gap-2 rounded-md border p-2 bg-muted/30">
|
||||||
|
{/* 소스 필드 (선택된 엔티티) */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground mb-1 block">소스 (엔티티)</Label>
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceField || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const mappings = [...(localConfig.autoFillMappings || [])];
|
||||||
|
mappings[index] = { ...mappings[index], sourceField: value };
|
||||||
|
updateConfig({ autoFillMappings: mappings });
|
||||||
|
}}
|
||||||
|
disabled={!localConfig.tableName || isLoadingColumns}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col, colIdx) => (
|
||||||
|
<SelectItem key={col.columnName || `col-${colIdx}`} value={col.columnName}>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 화살표 */}
|
||||||
|
<div className="flex items-center justify-center pt-4">
|
||||||
|
<span className="text-muted-foreground text-sm">→</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 필드 (폼) */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground mb-1 block">대상 (폼)</Label>
|
||||||
|
<Input
|
||||||
|
value={mapping.targetField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const mappings = [...(localConfig.autoFillMappings || [])];
|
||||||
|
mappings[index] = { ...mappings[index], targetField: e.target.value };
|
||||||
|
updateConfig({ autoFillMappings: mappings });
|
||||||
|
}}
|
||||||
|
placeholder="폼 필드명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const mappings = [...(localConfig.autoFillMappings || [])];
|
||||||
|
mappings.splice(index, 1);
|
||||||
|
updateConfig({ autoFillMappings: mappings });
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 mt-4"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(localConfig.autoFillMappings || []).length === 0 && (
|
||||||
|
<div className="text-muted-foreground text-xs text-center py-3 rounded-md border border-dashed">
|
||||||
|
매핑이 없습니다. + 추가 버튼을 클릭하여 매핑을 추가하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,16 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
|
||||||
// placeholder
|
// placeholder
|
||||||
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
|
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
|
||||||
|
|
||||||
|
// 자동 채움 매핑 설정
|
||||||
|
const autoFillMappings = config.autoFillMappings || [];
|
||||||
|
|
||||||
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
|
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
|
||||||
tableName,
|
tableName,
|
||||||
displayField,
|
displayField,
|
||||||
valueField,
|
valueField,
|
||||||
uiMode,
|
uiMode,
|
||||||
multiple,
|
multiple,
|
||||||
|
autoFillMappings,
|
||||||
value,
|
value,
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
|
|
@ -68,6 +72,7 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
|
autoFillMappings={autoFillMappings}
|
||||||
component={component}
|
component={component}
|
||||||
isInteractive={props.isInteractive}
|
isInteractive={props.isInteractive}
|
||||||
onFormDataChange={props.onFormDataChange}
|
onFormDataChange={props.onFormDataChange}
|
||||||
|
|
|
||||||
|
|
@ -148,9 +148,9 @@ export function EntitySearchModal({
|
||||||
선택
|
선택
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{displayColumns.map((col) => (
|
{displayColumns.map((col, colIdx) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col || `header-${colIdx}`}
|
||||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
{col}
|
{col}
|
||||||
|
|
@ -179,7 +179,8 @@ export function EntitySearchModal({
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
results.map((item, index) => {
|
results.map((item, index) => {
|
||||||
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
|
// null과 undefined 모두 체크하여 유니크 키 생성
|
||||||
|
const uniqueKey = item[valueField] != null ? `${item[valueField]}` : `row-${index}`;
|
||||||
const isSelected = isItemSelected(item);
|
const isSelected = isItemSelected(item);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -200,8 +201,8 @@ export function EntitySearchModal({
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{displayColumns.map((col) => (
|
{displayColumns.map((col, colIdx) => (
|
||||||
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
|
<td key={`${uniqueKey}-${col || colIdx}`} className="px-4 py-2">
|
||||||
{item[col] || "-"}
|
{item[col] || "-"}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
|
// 자동 채움 매핑 타입
|
||||||
|
export interface AutoFillMapping {
|
||||||
|
sourceField: string; // 선택된 엔티티의 필드 (예: customer_name)
|
||||||
|
targetField: string; // 폼의 필드 (예: partner_name)
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntitySearchInputConfig {
|
export interface EntitySearchInputConfig {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
displayField: string;
|
displayField: string;
|
||||||
|
|
@ -18,5 +24,8 @@ export interface EntitySearchInputConfig {
|
||||||
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
||||||
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
||||||
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
|
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
|
||||||
|
|
||||||
|
// 자동 채움 매핑 설정
|
||||||
|
autoFillMappings?: AutoFillMapping[]; // 엔티티 선택 시 다른 필드에 자동으로 값 채우기
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,16 +1782,144 @@ 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 ? (
|
||||||
{/* 좌상단 코너 (행 필드 라벨 + 필터) */}
|
// 열 필드가 있는 경우: 각 레벨별로 행 생성
|
||||||
|
columnHeaderLevels.map((levelCells, levelIdx) => (
|
||||||
|
<tr key={`col-level-${levelIdx}`} className="bg-background">
|
||||||
|
{/* 좌상단 코너 (첫 번째 레벨에만 표시) */}
|
||||||
|
{levelIdx === 0 && (
|
||||||
<th
|
<th
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-b border-border",
|
"border-r border-b border-border",
|
||||||
"px-2 py-1 text-left text-xs font-medium",
|
"px-2 py-1 text-left text-xs font-medium",
|
||||||
"bg-background sticky left-0 top-0 z-20"
|
"bg-background sticky left-0 top-0 z-20"
|
||||||
)}
|
)}
|
||||||
rowSpan={columnFields.length > 0 ? 2 : 1}
|
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{rowFields.map((f, idx) => (
|
||||||
|
<div key={f.field} className="flex items-center gap-0.5 group">
|
||||||
|
<span>{f.caption}</span>
|
||||||
|
<FilterPopup
|
||||||
|
field={f}
|
||||||
|
data={data}
|
||||||
|
onFilterChange={(field, values, type) => {
|
||||||
|
const newFields = fields.map((fld) =>
|
||||||
|
fld.field === field.field && fld.area === "row"
|
||||||
|
? { ...fld, filterValues: values, filterType: type }
|
||||||
|
: fld
|
||||||
|
);
|
||||||
|
handleFieldsChange(newFields);
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
||||||
|
"hover:bg-accent",
|
||||||
|
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 행 총계 헤더 (첫 번째 레벨에만 표시) */}
|
||||||
|
{levelIdx === 0 && 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={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
|
||||||
|
>
|
||||||
|
총계
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 열 필드 필터 (첫 번째 레벨에만 표시) */}
|
||||||
|
{levelIdx === 0 && columnFields.length > 0 && (
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border",
|
||||||
|
"px-1 py-1 text-center text-xs",
|
||||||
|
"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>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// 열 필드가 없는 경우: 단일 행
|
||||||
|
<tr className="bg-background">
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
"border-r border-b border-border",
|
||||||
|
"px-2 py-1 text-left text-xs font-medium",
|
||||||
|
"bg-background sticky left-0 top-0 z-20"
|
||||||
|
)}
|
||||||
|
rowSpan={dataFields.length > 1 ? 2 : 1}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{rowFields.map((f, idx) => (
|
{rowFields.map((f, idx) => (
|
||||||
|
|
@ -1730,7 +1955,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
{/* 열 헤더 셀 */}
|
{/* 열 헤더 셀 (열 필드 없을 때) */}
|
||||||
{flatColumns.map((col, idx) => (
|
{flatColumns.map((col, idx) => (
|
||||||
<th
|
<th
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|
@ -1748,7 +1973,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
<span>{col.caption || "(전체)"}</span>
|
<span>{col.caption || "(전체)"}</span>
|
||||||
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
|
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
|
||||||
</div>
|
</div>
|
||||||
{/* 열 리사이즈 핸들 */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
|
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
|
||||||
|
|
@ -1774,48 +1998,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
총계
|
총계
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
|
|
||||||
{columnFields.length > 0 && (
|
|
||||||
<th
|
|
||||||
className={cn(
|
|
||||||
"border-b border-border",
|
|
||||||
"px-1 py-1 text-center text-xs",
|
|
||||||
"bg-background sticky top-0 z-10"
|
|
||||||
)}
|
|
||||||
rowSpan={dataFields.length > 1 ? 2 : 1}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
|
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
|
||||||
{dataFields.length > 1 && (
|
{dataFields.length > 1 && (
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,20 +750,61 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 영역 */}
|
||||||
|
<div className="flex items-center justify-between 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 && (
|
{onToggleCollapse && (
|
||||||
<div className="flex justify-center mt-1.5">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="text-xs h-5 px-2"
|
className="text-xs h-6 px-2"
|
||||||
>
|
>
|
||||||
필드 패널 접기
|
필드 패널 접기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 드래그 오버레이 */}
|
{/* 드래그 오버레이 */}
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1590,21 +1590,40 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 커스텀 모달 화면 열기
|
// 커스텀 모달 화면 열기
|
||||||
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
||||||
|
|
||||||
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
|
// Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드)
|
||||||
|
// 🔧 설정에서 primaryKeyColumn 지정 가능
|
||||||
|
const configuredPrimaryKey = componentConfig.rightPanel?.editButton?.primaryKeyColumn;
|
||||||
|
|
||||||
let primaryKeyName = "id";
|
let primaryKeyName = "id";
|
||||||
let primaryKeyValue: any;
|
let primaryKeyValue: any;
|
||||||
|
|
||||||
if (item.id !== undefined && item.id !== null) {
|
if (configuredPrimaryKey && item[configuredPrimaryKey] !== undefined && item[configuredPrimaryKey] !== null) {
|
||||||
|
// 설정된 Primary Key 사용
|
||||||
|
primaryKeyName = configuredPrimaryKey;
|
||||||
|
primaryKeyValue = item[configuredPrimaryKey];
|
||||||
|
} else if (item.id !== undefined && item.id !== null) {
|
||||||
primaryKeyName = "id";
|
primaryKeyName = "id";
|
||||||
primaryKeyValue = item.id;
|
primaryKeyValue = item.id;
|
||||||
} else if (item.ID !== undefined && item.ID !== null) {
|
} else if (item.ID !== undefined && item.ID !== null) {
|
||||||
primaryKeyName = "ID";
|
primaryKeyName = "ID";
|
||||||
primaryKeyValue = item.ID;
|
primaryKeyValue = item.ID;
|
||||||
} else {
|
} else {
|
||||||
// 첫 번째 필드를 Primary Key로 간주
|
// 🔧 첫 번째 non-null 필드를 Primary Key로 간주
|
||||||
const firstKey = Object.keys(item)[0];
|
const keys = Object.keys(item);
|
||||||
primaryKeyName = firstKey;
|
let found = false;
|
||||||
primaryKeyValue = item[firstKey];
|
for (const key of keys) {
|
||||||
|
if (item[key] !== undefined && item[key] !== null) {
|
||||||
|
primaryKeyName = key;
|
||||||
|
primaryKeyValue = item[key];
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 모든 필드가 null이면 첫 번째 필드 사용
|
||||||
|
if (!found && keys.length > 0) {
|
||||||
|
primaryKeyName = keys[0];
|
||||||
|
primaryKeyValue = item[keys[0]];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 수정 모달 열기:", {
|
console.log("✅ 수정 모달 열기:", {
|
||||||
|
|
@ -1629,7 +1648,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
hasGroupByColumns: groupByColumns.length > 0,
|
hasGroupByColumns: groupByColumns.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달)
|
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달)
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("openScreenModal", {
|
new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -1638,6 +1657,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
editId: primaryKeyValue,
|
editId: primaryKeyValue,
|
||||||
tableName: rightTableName,
|
tableName: rightTableName,
|
||||||
|
primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달
|
||||||
...(groupByColumns.length > 0 && {
|
...(groupByColumns.length > 0 && {
|
||||||
groupByColumns: JSON.stringify(groupByColumns),
|
groupByColumns: JSON.stringify(groupByColumns),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1650,6 +1670,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
screenId: modalScreenId,
|
screenId: modalScreenId,
|
||||||
editId: primaryKeyValue,
|
editId: primaryKeyValue,
|
||||||
tableName: rightTableName,
|
tableName: rightTableName,
|
||||||
|
primaryKeyColumn: primaryKeyName,
|
||||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2688,19 +2688,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const value = row[mappedColumnName];
|
const value = row[mappedColumnName];
|
||||||
|
|
||||||
// 카테고리 매핑된 값 처리
|
// 카테고리 매핑된 값 처리
|
||||||
if (categoryMappings[col.columnName] && value !== null && value !== undefined) {
|
if (value !== null && value !== undefined) {
|
||||||
const mapping = categoryMappings[col.columnName][String(value)];
|
const valueStr = String(value);
|
||||||
|
|
||||||
|
// 디버그 로그 (카테고리 값인 경우만)
|
||||||
|
if (valueStr.startsWith("CATEGORY_")) {
|
||||||
|
console.log("🔍 [엑셀다운로드] 카테고리 변환 시도:", {
|
||||||
|
columnName: col.columnName,
|
||||||
|
value: valueStr,
|
||||||
|
hasMappings: !!categoryMappings[col.columnName],
|
||||||
|
mappingsKeys: categoryMappings[col.columnName] ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryMappings[col.columnName]) {
|
||||||
|
// 쉼표로 구분된 중복 값 처리
|
||||||
|
if (valueStr.includes(",")) {
|
||||||
|
const values = valueStr.split(",").map((v) => v.trim()).filter((v) => v);
|
||||||
|
const labels = values.map((v) => {
|
||||||
|
const mapping = categoryMappings[col.columnName][v];
|
||||||
|
return mapping ? mapping.label : v;
|
||||||
|
});
|
||||||
|
return labels.join(", ");
|
||||||
|
}
|
||||||
|
// 단일 값 처리
|
||||||
|
const mapping = categoryMappings[col.columnName][valueStr];
|
||||||
if (mapping) {
|
if (mapping) {
|
||||||
return mapping.label;
|
return mapping.label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// null/undefined 처리
|
return value;
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
// null/undefined 처리
|
||||||
|
return "";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4801,7 +4801,24 @@ export class ButtonActionExecutor {
|
||||||
const filteredRow: Record<string, any> = {};
|
const filteredRow: Record<string, any> = {};
|
||||||
visibleColumns!.forEach((columnName: string) => {
|
visibleColumns!.forEach((columnName: string) => {
|
||||||
const label = columnLabels?.[columnName] || columnName;
|
const label = columnLabels?.[columnName] || columnName;
|
||||||
filteredRow[label] = row[columnName];
|
let value = row[columnName];
|
||||||
|
|
||||||
|
// 카테고리 코드를 라벨로 변환 (CATEGORY_로 시작하는 값)
|
||||||
|
if (value && typeof value === "string" && value.includes("CATEGORY_")) {
|
||||||
|
// 먼저 _label 필드 확인 (API에서 제공하는 경우)
|
||||||
|
const labelFieldName = `${columnName}_label`;
|
||||||
|
if (row[labelFieldName]) {
|
||||||
|
value = row[labelFieldName];
|
||||||
|
} else {
|
||||||
|
// _value_label 필드 확인
|
||||||
|
const valueLabelFieldName = `${columnName}_value_label`;
|
||||||
|
if (row[valueLabelFieldName]) {
|
||||||
|
value = row[valueLabelFieldName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredRow[label] = value;
|
||||||
});
|
});
|
||||||
return filteredRow;
|
return filteredRow;
|
||||||
});
|
});
|
||||||
|
|
@ -5074,9 +5091,16 @@ export class ButtonActionExecutor {
|
||||||
value = row[`${columnName}_name`];
|
value = row[`${columnName}_name`];
|
||||||
}
|
}
|
||||||
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
|
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
|
||||||
else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) {
|
else if (categoryMap[columnName] && typeof value === "string") {
|
||||||
|
// 쉼표로 구분된 다중 값 처리
|
||||||
|
if (value.includes(",")) {
|
||||||
|
const values = value.split(",").map((v) => v.trim()).filter((v) => v);
|
||||||
|
const labels = values.map((v) => categoryMap[columnName][v] || v);
|
||||||
|
value = labels.join(", ");
|
||||||
|
} else if (categoryMap[columnName][value]) {
|
||||||
value = categoryMap[columnName][value];
|
value = categoryMap[columnName][value];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
filteredRow[label] = value;
|
filteredRow[label] = value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,10 @@ export async function importFromExcel(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON으로 변환
|
// JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지)
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
|
||||||
|
defval: "", // 빈 셀에 빈 문자열 할당
|
||||||
|
});
|
||||||
|
|
||||||
console.log("✅ 엑셀 가져오기 완료:", {
|
console.log("✅ 엑셀 가져오기 완료:", {
|
||||||
sheetName: targetSheetName,
|
sheetName: targetSheetName,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue