Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
0beb8b20a3
|
|
@ -50,6 +50,9 @@ export class EntityJoinController {
|
|||
// search가 문자열인 경우 JSON 파싱
|
||||
searchConditions =
|
||||
typeof search === "string" ? JSON.parse(search) : search;
|
||||
|
||||
// 🔍 디버그: 파싱된 검색 조건 로깅
|
||||
logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2));
|
||||
} catch (error) {
|
||||
logger.warn("검색 조건 파싱 오류:", error);
|
||||
searchConditions = {};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
|
||||
import { UnifiedColumnInfo } from "@/types/table-management";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
|
@ -19,6 +20,67 @@ interface DataFilterConfigPanelProps {
|
|||
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
|
||||
}
|
||||
|
||||
/**
|
||||
* 접을 수 있는 필터 항목 컴포넌트
|
||||
*/
|
||||
interface FilterItemCollapsibleProps {
|
||||
filter: ColumnFilter;
|
||||
index: number;
|
||||
filterSummary: string;
|
||||
onRemove: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FilterItemCollapsible: React.FC<FilterItemCollapsibleProps> = ({
|
||||
filter,
|
||||
index,
|
||||
filterSummary,
|
||||
onRemove,
|
||||
children,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="rounded-lg border p-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="hover:bg-muted/50 cursor-pointer rounded p-1">
|
||||
{/* 상단: 필터 번호 + 삭제 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs font-medium">필터 {index + 1}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 shrink-0 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 하단: 필터 요약 (전체 너비 사용) */}
|
||||
<div className="mt-1 pl-4">
|
||||
<span className="text-xs font-medium text-blue-600" title={filterSummary}>
|
||||
{filterSummary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">{children}</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터 필터 설정 패널
|
||||
* 테이블 리스트, 분할 패널, 플로우 위젯 등에서 사용
|
||||
|
|
@ -36,13 +98,13 @@ export function DataFilterConfigPanel({
|
|||
menuObjid,
|
||||
sampleColumns: columns.slice(0, 3),
|
||||
});
|
||||
|
||||
|
||||
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
|
||||
config || {
|
||||
enabled: false,
|
||||
filters: [],
|
||||
matchType: "all",
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
|
||||
|
|
@ -52,7 +114,7 @@ export function DataFilterConfigPanel({
|
|||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalConfig(config);
|
||||
|
||||
|
||||
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
|
||||
config.filters?.forEach((filter) => {
|
||||
if (filter.valueType === "category" && filter.columnName) {
|
||||
|
|
@ -69,7 +131,7 @@ export function DataFilterConfigPanel({
|
|||
return; // 이미 로드되었거나 로딩 중이면 스킵
|
||||
}
|
||||
|
||||
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
|
||||
setLoadingCategories((prev) => ({ ...prev, [columnName]: true }));
|
||||
|
||||
try {
|
||||
console.log("🔍 카테고리 값 로드 시작:", {
|
||||
|
|
@ -82,7 +144,7 @@ export function DataFilterConfigPanel({
|
|||
tableName,
|
||||
columnName,
|
||||
false, // includeInactive
|
||||
menuObjid // 🆕 메뉴 OBJID 전달
|
||||
menuObjid, // 🆕 메뉴 OBJID 전달
|
||||
);
|
||||
|
||||
console.log("📦 카테고리 값 로드 응답:", response);
|
||||
|
|
@ -92,16 +154,16 @@ export function DataFilterConfigPanel({
|
|||
value: item.valueCode,
|
||||
label: item.valueLabel,
|
||||
}));
|
||||
|
||||
|
||||
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
|
||||
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
|
||||
setCategoryValues((prev) => ({ ...prev, [columnName]: values }));
|
||||
} else {
|
||||
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
|
||||
} finally {
|
||||
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
|
||||
setLoadingCategories((prev) => ({ ...prev, [columnName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -145,9 +207,7 @@ export function DataFilterConfigPanel({
|
|||
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((filter) =>
|
||||
filter.id === filterId ? { ...filter, [field]: value } : filter
|
||||
),
|
||||
filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
|
|
@ -178,7 +238,7 @@ export function DataFilterConfigPanel({
|
|||
<>
|
||||
{/* 테이블명 표시 */}
|
||||
{tableName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
테이블: <span className="font-medium">{tableName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -200,235 +260,127 @@ export function DataFilterConfigPanel({
|
|||
)}
|
||||
|
||||
{/* 필터 목록 */}
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
{localConfig.filters.map((filter, index) => (
|
||||
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
필터 {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleRemoveFilter(filter.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[600px] space-y-2 overflow-y-auto pr-2">
|
||||
{localConfig.filters.map((filter, index) => {
|
||||
// 연산자 표시 텍스트
|
||||
const operatorLabels: Record<string, string> = {
|
||||
equals: "=",
|
||||
not_equals: "!=",
|
||||
greater_than: ">",
|
||||
less_than: "<",
|
||||
greater_than_or_equal: ">=",
|
||||
less_than_or_equal: "<=",
|
||||
between: "BETWEEN",
|
||||
in: "IN",
|
||||
not_in: "NOT IN",
|
||||
contains: "LIKE",
|
||||
starts_with: "시작",
|
||||
ends_with: "끝",
|
||||
is_null: "IS NULL",
|
||||
is_not_null: "IS NOT NULL",
|
||||
date_range_contains: "기간 내",
|
||||
};
|
||||
|
||||
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||
{filter.operator !== "date_range_contains" && (
|
||||
<div>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, columnName: value, valueType, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({col.input_type})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
// 컬럼 라벨 찾기
|
||||
const columnLabel =
|
||||
columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName;
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연산자</Label>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value: any) => {
|
||||
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
|
||||
if (value === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
handleFilterChange(filter.id, "operator", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</SelectItem>
|
||||
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
||||
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
||||
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
||||
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||
<SelectItem value="starts_with">시작 (LIKE value%)</SelectItem>
|
||||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||
<SelectItem value="is_null">NULL</SelectItem>
|
||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
// 필터 요약 텍스트 생성
|
||||
const filterSummary = filter.columnName
|
||||
? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${
|
||||
filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value
|
||||
? ` ${filter.value}`
|
||||
: ""
|
||||
}`
|
||||
: "설정 필요";
|
||||
|
||||
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||
💡 날짜 범위 필터링 규칙:
|
||||
<br />• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터
|
||||
<br />• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터
|
||||
<br />• 둘 다 있으면 → 기간 내 데이터만
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<FilterItemCollapsible
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
index={index}
|
||||
filterSummary={filterSummary}
|
||||
onRemove={() => handleRemoveFilter(filter.id)}
|
||||
>
|
||||
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||
{filter.operator !== "date_range_contains" && (
|
||||
<div>
|
||||
<Label className="text-xs">시작일 컬럼</Label>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.startColumn || ""}
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: value,
|
||||
endColumn: filter.rangeConfig?.endColumn || "",
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="시작일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.filter(col =>
|
||||
col.dataType?.toLowerCase().includes('date') ||
|
||||
col.dataType?.toLowerCase().includes('time')
|
||||
).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종료일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.endColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: filter.rangeConfig?.startColumn || "",
|
||||
endColumn: value,
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="종료일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.filter(col =>
|
||||
col.dataType?.toLowerCase().includes('date') ||
|
||||
col.dataType?.toLowerCase().includes('time')
|
||||
).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
|
||||
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
|
||||
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
|
||||
<div>
|
||||
<Label className="text-xs">값 타입</Label>
|
||||
<Select
|
||||
value={filter.valueType}
|
||||
onValueChange={(value: any) => {
|
||||
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
||||
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "TODAY" }
|
||||
: f
|
||||
f.id === filter.id ? { ...f, columnName: value, valueType, value: "" } : f,
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">({col.input_type})</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연산자</Label>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value: any) => {
|
||||
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
|
||||
if (value === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id ? { ...f, operator: value, valueType: "dynamic", value: "TODAY" } : f,
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
handleFilterChange(filter.id, "operator", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -436,106 +388,240 @@ export function DataFilterConfigPanel({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">직접 입력</SelectItem>
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||
)}
|
||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||
<>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
</>
|
||||
)}
|
||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</SelectItem>
|
||||
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
||||
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
||||
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
||||
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||
<SelectItem value="starts_with">시작 (LIKE value%)</SelectItem>
|
||||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||
<SelectItem value="is_null">NULL</SelectItem>
|
||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||
{filter.operator !== "is_null" &&
|
||||
filter.operator !== "is_not_null" &&
|
||||
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground bg-muted/50 rounded p-2 text-xs">
|
||||
💡 날짜 범위 필터링 규칙:
|
||||
<br />• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터
|
||||
<br />• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터
|
||||
<br />• 둘 다 있으면 → 기간 내 데이터만
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">시작일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.startColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: value,
|
||||
endColumn: filter.rangeConfig?.endColumn || "",
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="시작일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.dataType?.toLowerCase().includes("date") ||
|
||||
col.dataType?.toLowerCase().includes("time"),
|
||||
)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종료일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.endColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: filter.rangeConfig?.startColumn || "",
|
||||
endColumn: value,
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="종료일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.dataType?.toLowerCase().includes("date") ||
|
||||
col.dataType?.toLowerCase().includes("time"),
|
||||
)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
|
||||
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
|
||||
<div>
|
||||
<Label className="text-xs">값 타입</Label>
|
||||
<Select
|
||||
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
||||
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
||||
value={filter.valueType}
|
||||
onValueChange={(value: any) => {
|
||||
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
||||
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id ? { ...f, valueType: value, value: "TODAY" } : f,
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id ? { ...f, valueType: value, value: "" } : f,
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder={
|
||||
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
|
||||
} />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues[filter.columnName].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="static">직접 입력</SelectItem>
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||
)}
|
||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||
<>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : filter.operator === "in" || filter.operator === "not_in" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split(",").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values);
|
||||
}}
|
||||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : filter.operator === "between" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split("~").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
|
||||
}}
|
||||
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||
{filter.operator !== "is_null" &&
|
||||
filter.operator !== "is_not_null" &&
|
||||
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||
<Select
|
||||
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
||||
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue
|
||||
placeholder={loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues[filter.columnName].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : filter.operator === "in" || filter.operator === "not_in" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split(",").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values);
|
||||
}}
|
||||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : filter.operator === "between" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split("~").map((v) => v.trim());
|
||||
handleFilterChange(
|
||||
filter.id,
|
||||
"value",
|
||||
values.length === 2 ? values : [values[0] || "", ""],
|
||||
);
|
||||
}}
|
||||
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||
placeholder={
|
||||
filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"
|
||||
}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName]
|
||||
? "카테고리 값을 선택하세요"
|
||||
: filter.operator === "in" || filter.operator === "not_in"
|
||||
? "여러 값은 쉼표(,)로 구분하세요"
|
||||
: filter.operator === "between"
|
||||
? "시작과 종료 값을 ~로 구분하세요"
|
||||
: filter.operator === "date_range_contains"
|
||||
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||
: "필터링할 값을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName]
|
||||
? "카테고리 값을 선택하세요"
|
||||
: filter.operator === "in" || filter.operator === "not_in"
|
||||
? "여러 값은 쉼표(,)로 구분하세요"
|
||||
: filter.operator === "between"
|
||||
? "시작과 종료 값을 ~로 구분하세요"
|
||||
: filter.operator === "date_range_contains"
|
||||
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||
: "필터링할 값을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* date_range_contains의 dynamic 타입 안내 */}
|
||||
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<p className="text-[10px] text-blue-700">
|
||||
ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* date_range_contains의 dynamic 타입 안내 */}
|
||||
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<p className="text-[10px] text-blue-700">오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</FilterItemCollapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 필터 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
|
||||
className="h-8 w-full text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleAddFilter}
|
||||
disabled={columns.length === 0}
|
||||
>
|
||||
|
|
@ -544,13 +630,10 @@ export function DataFilterConfigPanel({
|
|||
</Button>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
테이블을 먼저 선택해주세요
|
||||
</p>
|
||||
<p className="text-muted-foreground text-center text-xs">테이블을 먼저 선택해주세요</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
|||
// 🆕 연관 데이터 버튼 컴포넌트
|
||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||
|
||||
// 🆕 피벗 그리드 컴포넌트
|
||||
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,213 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 컨텍스트 메뉴 컴포넌트
|
||||
* 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
ArrowUpAZ,
|
||||
ArrowDownAZ,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { PivotFieldConfig, AggregationType } from "../types";
|
||||
|
||||
interface PivotContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
// 현재 컨텍스트 정보
|
||||
cellType: "header" | "data" | "rowHeader" | "columnHeader";
|
||||
field?: PivotFieldConfig;
|
||||
rowPath?: string[];
|
||||
columnPath?: string[];
|
||||
value?: any;
|
||||
// 콜백
|
||||
onSort?: (field: string, direction: "asc" | "desc") => void;
|
||||
onFilter?: (field: string) => void;
|
||||
onExpand?: (path: string[]) => void;
|
||||
onCollapse?: (path: string[]) => void;
|
||||
onExpandAll?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
onCopy?: (value: any) => void;
|
||||
onHideField?: (field: string) => void;
|
||||
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
|
||||
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
|
||||
}
|
||||
|
||||
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
|
||||
children,
|
||||
cellType,
|
||||
field,
|
||||
rowPath,
|
||||
columnPath,
|
||||
value,
|
||||
onSort,
|
||||
onFilter,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
onCopy,
|
||||
onHideField,
|
||||
onChangeSummary,
|
||||
onDrillDown,
|
||||
}) => {
|
||||
const handleCopy = () => {
|
||||
if (value !== undefined && value !== null) {
|
||||
navigator.clipboard.writeText(String(value));
|
||||
onCopy?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{/* 정렬 옵션 (헤더에서만) */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
정렬
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
오름차순
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
|
||||
<ArrowDownAZ className="mr-2 h-4 w-4" />
|
||||
내림차순
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 확장/축소 옵션 */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && (
|
||||
<>
|
||||
{rowPath && rowPath.length > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
축소
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={onExpandAll}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
전체 확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onCollapseAll}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
전체 축소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필터 옵션 */}
|
||||
{field && onFilter && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onFilter(field.field)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
필터
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 집계 함수 변경 (데이터 필드에서만) */}
|
||||
{cellType === "data" && field && onChangeSummary && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
집계 함수
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "sum")}
|
||||
>
|
||||
합계
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "count")}
|
||||
>
|
||||
개수
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "avg")}
|
||||
>
|
||||
평균
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "min")}
|
||||
>
|
||||
최소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "max")}
|
||||
>
|
||||
최대
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 드릴다운 (데이터 셀에서만) */}
|
||||
{cellType === "data" && rowPath && columnPath && onDrillDown && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세 데이터 보기
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필드 숨기기 */}
|
||||
{field && onHideField && (
|
||||
<ContextMenuItem onClick={() => onHideField(field.field)}>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
필드 숨기기
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{/* 복사 */}
|
||||
<ContextMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotContextMenu;
|
||||
|
||||
|
|
@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
|||
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
||||
];
|
||||
|
||||
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "none", label: "그룹 없음" },
|
||||
{ value: "year", label: "년" },
|
||||
{ value: "quarter", label: "분기" },
|
||||
{ value: "month", label: "월" },
|
||||
{ value: "week", label: "주" },
|
||||
{ value: "day", label: "일" },
|
||||
];
|
||||
|
||||
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
string: <Type className="h-3.5 w-3.5" />,
|
||||
number: <Hash className="h-3.5 w-3.5" />,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
/**
|
||||
* FieldPanel 컴포넌트
|
||||
* 피벗 그리드 상단의 필드 배치 영역 (필터, 열, 행, 데이터)
|
||||
* 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터)
|
||||
* 드래그 앤 드롭으로 필드 재배치 가능
|
||||
*/
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
|
||||
"flex-1 min-h-[44px] rounded border border-dashed p-1.5",
|
||||
"transition-colors duration-200",
|
||||
config.color,
|
||||
isOver && "border-primary bg-primary/5"
|
||||
|
|
@ -255,7 +255,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
|||
data-area={area}
|
||||
>
|
||||
{/* 영역 헤더 */}
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-1 mb-1 text-[11px] font-medium text-muted-foreground">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{areaFields.length > 0 && (
|
||||
|
|
@ -267,9 +267,9 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
|||
|
||||
{/* 필드 목록 */}
|
||||
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||
<div className="flex flex-wrap gap-1 min-h-[22px]">
|
||||
{areaFields.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50 italic">
|
||||
<span className="text-[10px] text-muted-foreground/50 italic">
|
||||
필드를 여기로 드래그
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -443,16 +443,42 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
|||
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
||||
: null;
|
||||
|
||||
// 각 영역의 필드 수 계산
|
||||
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
|
||||
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
|
||||
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
|
||||
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{filterCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Filter className="h-3 w-3" />
|
||||
필터 {filterCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Columns className="h-3 w-3" />
|
||||
열 {columnCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Rows className="h-3 w-3" />
|
||||
행 {rowCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
데이터 {dataCount}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs"
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
필드 패널 펼치기
|
||||
필드 설정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -466,9 +492,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
|||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="border-b border-border bg-muted/20 p-3">
|
||||
{/* 2x2 그리드로 영역 배치 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="border-b border-border bg-muted/20 p-2">
|
||||
{/* 4개 영역 배치: 2x2 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 필터 영역 */}
|
||||
<DroppableArea
|
||||
area="filter"
|
||||
|
|
@ -516,12 +542,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
|||
|
||||
{/* 접기 버튼 */}
|
||||
{onToggleCollapse && (
|
||||
<div className="flex justify-center mt-2">
|
||||
<div className="flex justify-center mt-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-6"
|
||||
className="text-xs h-5 px-2"
|
||||
>
|
||||
필드 패널 접기
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
|
|||
export { DrillDownModal } from "./DrillDownModal";
|
||||
export { FilterPopup } from "./FilterPopup";
|
||||
export { PivotChart } from "./PivotChart";
|
||||
export { PivotContextMenu } from "./ContextMenu";
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ export interface PivotFieldConfig {
|
|||
// 계층 관련
|
||||
displayFolder?: string; // 필드 선택기에서 폴더 구조
|
||||
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
|
||||
|
||||
// 계산 필드
|
||||
isCalculated?: boolean; // 계산 필드 여부
|
||||
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
|
||||
}
|
||||
|
||||
// ==================== 데이터 소스 설정 ====================
|
||||
|
|
@ -140,11 +144,13 @@ export interface PivotTotalsConfig {
|
|||
showRowGrandTotals?: boolean; // 행 총합계 표시
|
||||
showRowTotals?: boolean; // 행 소계 표시
|
||||
rowTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
|
||||
|
||||
// 열 총합계
|
||||
showColumnGrandTotals?: boolean; // 열 총합계 표시
|
||||
showColumnTotals?: boolean; // 열 소계 표시
|
||||
columnTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
|
||||
}
|
||||
|
||||
// 필드 선택기 설정
|
||||
|
|
@ -214,6 +220,7 @@ export interface PivotStyleConfig {
|
|||
alternateRowColors?: boolean;
|
||||
highlightTotals?: boolean; // 총합계 강조
|
||||
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||
mergeCells?: boolean; // 같은 값 셀 병합
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
|
|
|||
|
|
@ -298,11 +298,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
warehouseName: fieldMapping.warehouseNameField
|
||||
? formData[fieldMapping.warehouseNameField]
|
||||
: undefined,
|
||||
// 카테고리 값은 라벨로 변환
|
||||
// 카테고리 값은 라벨로 변환 (화면 표시용)
|
||||
floor: getCategoryLabel(rawFloor?.toString()),
|
||||
zone: getCategoryLabel(rawZone),
|
||||
locationType: getCategoryLabel(rawLocationType),
|
||||
status: getCategoryLabel(rawStatus),
|
||||
// 카테고리 코드 원본값 (DB 쿼리/저장용)
|
||||
floorCode: rawFloor?.toString(),
|
||||
zoneCode: rawZone?.toString(),
|
||||
locationTypeCode: rawLocationType?.toString(),
|
||||
statusCode: rawStatus?.toString(),
|
||||
};
|
||||
|
||||
console.log("🏗️ [RackStructure] context 생성:", {
|
||||
|
|
@ -399,8 +404,12 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
|
||||
// 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지)
|
||||
const warehouseCodeForQuery = context.warehouseCode;
|
||||
const floorForQuery = context.floor; // 라벨 값 (예: "1층")
|
||||
const zoneForQuery = context.zone; // 라벨 값 (예: "A구역")
|
||||
// DB 쿼리 시에는 카테고리 코드 사용 (코드로 통일)
|
||||
const floorForQuery = (context as any).floorCode || context.floor;
|
||||
const zoneForQuery = (context as any).zoneCode || context.zone;
|
||||
// 화면 표시용 라벨
|
||||
const floorLabel = context.floor;
|
||||
const zoneLabel = context.zone;
|
||||
|
||||
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
|
||||
useEffect(() => {
|
||||
|
|
@ -426,7 +435,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
// DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링
|
||||
// equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용)
|
||||
const searchParams = {
|
||||
warehouse_id: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
floor: { value: floorForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
|
|
@ -597,18 +606,20 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
for (let level = 1; level <= cond.levels; level++) {
|
||||
const { code, name } = generateLocationCode(row, level);
|
||||
// 테이블 컬럼명과 동일하게 생성
|
||||
// DB 저장 시에는 카테고리 코드 사용 (코드로 통일)
|
||||
const ctxAny = context as any;
|
||||
locations.push({
|
||||
row_num: String(row),
|
||||
level_num: String(level),
|
||||
location_code: code,
|
||||
location_name: name,
|
||||
location_type: context?.locationType || "선반",
|
||||
status: context?.status || "사용",
|
||||
// 추가 필드 (테이블 컬럼명과 동일)
|
||||
location_type: ctxAny?.locationTypeCode || context?.locationType || "선반",
|
||||
status: ctxAny?.statusCode || context?.status || "사용",
|
||||
// 추가 필드 (테이블 컬럼명과 동일) - 카테고리 코드 사용
|
||||
warehouse_code: context?.warehouseCode,
|
||||
warehouse_name: context?.warehouseName,
|
||||
floor: context?.floor,
|
||||
zone: context?.zone,
|
||||
floor: ctxAny?.floorCode || context?.floor,
|
||||
zone: ctxAny?.zoneCode || context?.zone,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -930,13 +941,14 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono">{loc.location_code}</TableCell>
|
||||
<TableCell>{loc.location_name}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
|
||||
{/* 미리보기에서는 카테고리 코드를 라벨로 변환하여 표시 */}
|
||||
<TableCell className="text-center">{getCategoryLabel(loc.floor) || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{getCategoryLabel(loc.zone) || context?.zone || "A"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{loc.row_num.padStart(2, "0")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{loc.level_num}</TableCell>
|
||||
<TableCell className="text-center">{loc.location_type}</TableCell>
|
||||
<TableCell className="text-center">{getCategoryLabel(loc.location_type) || loc.location_type}</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -72,10 +72,15 @@ export interface RackStructureContext {
|
|||
warehouseId?: string; // 창고 ID
|
||||
warehouseCode?: string; // 창고 코드 (예: WH001)
|
||||
warehouseName?: string; // 창고명 (예: 제1창고)
|
||||
floor?: string; // 층 (예: 1)
|
||||
zone?: string; // 구역 (예: A)
|
||||
locationType?: string; // 위치 유형 (예: 선반)
|
||||
status?: string; // 사용 여부 (예: 사용)
|
||||
floor?: string; // 층 라벨 (예: 1층) - 화면 표시용
|
||||
zone?: string; // 구역 라벨 (예: A구역) - 화면 표시용
|
||||
locationType?: string; // 위치 유형 라벨 (예: 선반)
|
||||
status?: string; // 사용 여부 라벨 (예: 사용)
|
||||
// 카테고리 코드 (DB 저장/쿼리용)
|
||||
floorCode?: string; // 층 카테고리 코드 (예: CATEGORY_767659DCUF)
|
||||
zoneCode?: string; // 구역 카테고리 코드 (예: CATEGORY_82925656Q8)
|
||||
locationTypeCode?: string; // 위치 유형 카테고리 코드
|
||||
statusCode?: string; // 사용 여부 카테고리 코드
|
||||
}
|
||||
|
||||
// 컴포넌트 Props
|
||||
|
|
|
|||
|
|
@ -285,11 +285,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
|
||||
// onChange 호출하여 부모에게 알림
|
||||
if (onChange && items.length > 0) {
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: targetTable,
|
||||
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
|
||||
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
}
|
||||
|
|
@ -388,10 +391,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// onChange 호출 (effectiveTargetTable 사용)
|
||||
if (onChange) {
|
||||
if (items.length > 0) {
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
_existingRecord: !!item.id,
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
} else {
|
||||
|
|
@ -673,26 +679,25 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||
const handleRepeaterChange = useCallback(
|
||||
(newValue: any[]) => {
|
||||
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||
let valueWithMeta = newValue;
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
|
||||
|
||||
// 🆕 모든 항목에 메타데이터 추가
|
||||
let valueWithMeta = newValue.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable || targetTable,
|
||||
_existingRecord: !!item.id,
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
}));
|
||||
|
||||
if (isRightPanel && effectiveTargetTable) {
|
||||
valueWithMeta = newValue.map((item: any) => {
|
||||
const itemWithMeta = {
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
};
|
||||
|
||||
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||
if (fkColumn && fkValue && item._isNewItem) {
|
||||
itemWithMeta[fkColumn] = fkValue;
|
||||
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||
fkColumn,
|
||||
fkValue,
|
||||
});
|
||||
// 🆕 분할 패널에서 우측인 경우, FK 값 추가
|
||||
if (isRightPanel && fkColumn && fkValue) {
|
||||
valueWithMeta = valueWithMeta.map((item: any) => {
|
||||
if (item._isNewItem) {
|
||||
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", { fkColumn, fkValue });
|
||||
return { ...item, [fkColumn]: fkValue };
|
||||
}
|
||||
|
||||
return itemWithMeta;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -754,6 +759,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
screenContext?.updateFormData,
|
||||
isRightPanel,
|
||||
effectiveTargetTable,
|
||||
targetTable,
|
||||
fkColumn,
|
||||
fkValue,
|
||||
fieldName,
|
||||
|
|
|
|||
|
|
@ -1026,10 +1026,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 추가 dataFilter 적용
|
||||
let filteredData = result.data || [];
|
||||
const dataFilter = componentConfig.rightPanel?.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||
// filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
|
||||
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
|
||||
if (dataFilter?.enabled && filterConditions.length > 0) {
|
||||
filteredData = filteredData.filter((item: any) => {
|
||||
return dataFilter.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
return filterConditions.every((cond: any) => {
|
||||
// columnName 또는 column 지원
|
||||
const columnName = cond.columnName || cond.column;
|
||||
const value = item[columnName];
|
||||
const condValue = cond.value;
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
|
|
@ -1038,6 +1042,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return value !== condValue;
|
||||
case "contains":
|
||||
return String(value).includes(String(condValue));
|
||||
case "is_null":
|
||||
case "NULL":
|
||||
return value === null || value === undefined || value === "";
|
||||
case "is_not_null":
|
||||
case "NOT NULL":
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1137,8 +1147,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 🆕 추가 탭 데이터 로딩 함수
|
||||
const loadTabData = useCallback(
|
||||
async (tabIndex: number, leftItem: any) => {
|
||||
console.log(`📥 loadTabData 호출됨: tabIndex=${tabIndex}`, {
|
||||
leftItem: leftItem ? Object.keys(leftItem) : null,
|
||||
additionalTabs: componentConfig.rightPanel?.additionalTabs?.length,
|
||||
isDesignMode,
|
||||
});
|
||||
|
||||
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
||||
if (!tabConfig || !leftItem || isDesignMode) return;
|
||||
|
||||
console.log(`📥 tabConfig:`, {
|
||||
tabIndex,
|
||||
configIndex: tabIndex - 1,
|
||||
tabConfig: tabConfig ? {
|
||||
tableName: tabConfig.tableName,
|
||||
relation: tabConfig.relation,
|
||||
dataFilter: tabConfig.dataFilter
|
||||
} : null,
|
||||
});
|
||||
|
||||
if (!tabConfig || !leftItem || isDesignMode) {
|
||||
console.log(`⚠️ loadTabData 중단:`, { hasTabConfig: !!tabConfig, hasLeftItem: !!leftItem, isDesignMode });
|
||||
return;
|
||||
}
|
||||
|
||||
const tabTableName = tabConfig.tableName;
|
||||
if (!tabTableName) return;
|
||||
|
|
@ -1150,6 +1180,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
||||
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
||||
|
||||
console.log(`🔑 [추가탭 ${tabIndex}] 조인 키 분석:`, {
|
||||
hasRelation: !!tabConfig.relation,
|
||||
keys,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
willUseJoin: !!(leftColumn && rightColumn),
|
||||
});
|
||||
|
||||
let resultData: any[] = [];
|
||||
|
||||
if (leftColumn && rightColumn) {
|
||||
|
|
@ -1161,14 +1199,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 복합키
|
||||
keys.forEach((key) => {
|
||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
||||
// operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색)
|
||||
searchConditions[key.rightColumn] = {
|
||||
value: leftItem[key.leftColumn],
|
||||
operator: "equals",
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 단일키
|
||||
const leftValue = leftItem[leftColumn];
|
||||
if (leftValue !== undefined) {
|
||||
searchConditions[rightColumn] = leftValue;
|
||||
// operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색)
|
||||
searchConditions[rightColumn] = {
|
||||
value: leftValue,
|
||||
operator: "equals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1183,33 +1229,68 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
resultData = result.data || [];
|
||||
} else {
|
||||
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
|
||||
console.log(`📋 [추가탭 ${tabIndex}] 조인 없이 전체 데이터 조회: ${tabTableName}`);
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
});
|
||||
resultData = result.data || [];
|
||||
console.log(`📋 [추가탭 ${tabIndex}] 전체 데이터 조회 결과:`, resultData.length);
|
||||
}
|
||||
|
||||
// 데이터 필터 적용
|
||||
const dataFilter = tabConfig.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||
// filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
|
||||
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
|
||||
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] 필터 설정:`, {
|
||||
enabled: dataFilter?.enabled,
|
||||
filterConditions,
|
||||
dataBeforeFilter: resultData.length,
|
||||
});
|
||||
|
||||
if (dataFilter?.enabled && filterConditions.length > 0) {
|
||||
const beforeCount = resultData.length;
|
||||
resultData = resultData.filter((item: any) => {
|
||||
return dataFilter.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
return filterConditions.every((cond: any) => {
|
||||
// columnName 또는 column 지원
|
||||
const columnName = cond.columnName || cond.column;
|
||||
const value = item[columnName];
|
||||
const condValue = cond.value;
|
||||
|
||||
let result = true;
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === condValue;
|
||||
result = value === condValue;
|
||||
break;
|
||||
case "notEquals":
|
||||
return value !== condValue;
|
||||
result = value !== condValue;
|
||||
break;
|
||||
case "contains":
|
||||
return String(value).includes(String(condValue));
|
||||
result = String(value).includes(String(condValue));
|
||||
break;
|
||||
case "is_null":
|
||||
case "NULL":
|
||||
result = value === null || value === undefined || value === "";
|
||||
break;
|
||||
case "is_not_null":
|
||||
case "NOT NULL":
|
||||
result = value !== null && value !== undefined && value !== "";
|
||||
break;
|
||||
default:
|
||||
return true;
|
||||
result = true;
|
||||
}
|
||||
|
||||
// 첫 5개 항목만 로그 출력
|
||||
if (resultData.indexOf(item) < 5) {
|
||||
console.log(` 필터 체크: ${columnName}=${value}, operator=${cond.operator}, result=${result}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
});
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] 필터 적용 후: ${beforeCount} → ${resultData.length}`);
|
||||
}
|
||||
|
||||
// 중복 제거 적용
|
||||
|
|
@ -1281,6 +1362,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 🆕 탭 변경 핸들러
|
||||
const handleTabChange = useCallback(
|
||||
(newTabIndex: number) => {
|
||||
console.log(`🔄 탭 변경: ${activeTabIndex} → ${newTabIndex}`, {
|
||||
selectedLeftItem: !!selectedLeftItem,
|
||||
tabsData: Object.keys(tabsData),
|
||||
hasTabData: !!tabsData[newTabIndex],
|
||||
});
|
||||
|
||||
setActiveTabIndex(newTabIndex);
|
||||
|
||||
// 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드
|
||||
|
|
@ -1291,14 +1378,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
} else {
|
||||
// 추가 탭: 해당 탭 데이터가 없으면 로드
|
||||
if (!tabsData[newTabIndex]) {
|
||||
loadTabData(newTabIndex, selectedLeftItem);
|
||||
}
|
||||
// 추가 탭: 항상 새로 로드 (필터 설정 변경 반영을 위해)
|
||||
console.log(`🔄 추가 탭 ${newTabIndex} 데이터 로드 (항상 새로고침)`);
|
||||
loadTabData(newTabIndex, selectedLeftItem);
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ 좌측 항목이 선택되지 않아 탭 데이터를 로드하지 않음`);
|
||||
}
|
||||
},
|
||||
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
||||
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, activeTabIndex],
|
||||
);
|
||||
|
||||
// 우측 항목 확장/축소 토글
|
||||
|
|
|
|||
|
|
@ -237,7 +237,12 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
// 탭 업데이트 헬퍼
|
||||
const updateTab = (updates: Partial<AdditionalTabConfig>) => {
|
||||
const newTabs = [...(config.rightPanel?.additionalTabs || [])];
|
||||
newTabs[tabIndex] = { ...tab, ...updates };
|
||||
// undefined 값도 명시적으로 덮어쓰기 위해 Object.assign 대신 직접 처리
|
||||
const updatedTab = { ...tab };
|
||||
Object.keys(updates).forEach((key) => {
|
||||
(updatedTab as any)[key] = (updates as any)[key];
|
||||
});
|
||||
newTabs[tabIndex] = updatedTab;
|
||||
updateRightPanel({ additionalTabs: newTabs });
|
||||
};
|
||||
|
||||
|
|
@ -393,21 +398,31 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">좌측 컬럼</Label>
|
||||
<Select
|
||||
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || ""}
|
||||
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || "__none__"}
|
||||
onValueChange={(value) => {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: value, rightColumn: tab.relation?.keys?.[0]?.rightColumn || "" }],
|
||||
},
|
||||
});
|
||||
if (value === "__none__") {
|
||||
// 선택 안 함 - 조인 키 제거
|
||||
updateTab({
|
||||
relation: undefined,
|
||||
});
|
||||
} else {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: value, rightColumn: tab.relation?.keys?.[0]?.rightColumn || "" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터)</span>
|
||||
</SelectItem>
|
||||
{leftTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
|
|
@ -419,21 +434,31 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">우측 컬럼</Label>
|
||||
<Select
|
||||
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || ""}
|
||||
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"}
|
||||
onValueChange={(value) => {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }],
|
||||
},
|
||||
});
|
||||
if (value === "__none__") {
|
||||
// 선택 안 함 - 조인 키 제거
|
||||
updateTab({
|
||||
relation: undefined,
|
||||
});
|
||||
} else {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터)</span>
|
||||
</SelectItem>
|
||||
{tabColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
|
|
|
|||
|
|
@ -690,6 +690,151 @@ export class ButtonActionExecutor {
|
|||
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
|
||||
}
|
||||
|
||||
// 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리
|
||||
// formData에 JSON 배열 문자열이 저장된 경우 처리 (반복_필드_그룹 등)
|
||||
const repeaterJsonKeys = Object.keys(context.formData).filter((key) => {
|
||||
const value = context.formData[key];
|
||||
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) && parsed.length > 0 && parsed[0]._targetTable;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (repeaterJsonKeys.length > 0) {
|
||||
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
|
||||
|
||||
// 🆕 상단 폼 데이터(마스터 정보) 추출
|
||||
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
|
||||
const masterFields: Record<string, any> = {};
|
||||
Object.keys(context.formData).forEach((fieldKey) => {
|
||||
// 제외 조건
|
||||
if (fieldKey.startsWith("comp_")) return;
|
||||
if (fieldKey.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) return;
|
||||
if (fieldKey.endsWith("_label") || fieldKey.endsWith("_value_label")) return;
|
||||
|
||||
const value = context.formData[fieldKey];
|
||||
|
||||
// JSON 배열 문자열 제외 (RepeaterFieldGroup 데이터)
|
||||
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) return;
|
||||
|
||||
// 객체 타입인 경우 (범용_폼_모달 등) 내부 필드를 펼쳐서 추가
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
Object.entries(value).forEach(([innerKey, innerValue]) => {
|
||||
if (innerKey.endsWith("_label") || innerKey.endsWith("_value_label")) return;
|
||||
if (innerValue !== undefined && innerValue !== null && innerValue !== "") {
|
||||
masterFields[innerKey] = innerValue;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효한 값만 포함
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
masterFields[fieldKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("📋 [handleSave] 상단 마스터 정보 (모든 품목에 적용):", masterFields);
|
||||
|
||||
for (const key of repeaterJsonKeys) {
|
||||
try {
|
||||
const parsedData = JSON.parse(context.formData[key]);
|
||||
const repeaterTargetTable = parsedData[0]?._targetTable;
|
||||
|
||||
if (!repeaterTargetTable) {
|
||||
console.warn(`⚠️ [handleSave] RepeaterFieldGroup targetTable 없음 (key: ${key})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📦 [handleSave] RepeaterFieldGroup 저장 시작: ${repeaterTargetTable}, ${parsedData.length}건`);
|
||||
|
||||
// 🆕 품목 고유 필드 목록 (RepeaterFieldGroup 설정에서 가져옴)
|
||||
// 첫 번째 아이템의 _repeaterFields에서 추출
|
||||
const repeaterFields: string[] = parsedData[0]?._repeaterFields || [];
|
||||
const itemOnlyFields = new Set([...repeaterFields, 'id']); // id는 항상 포함
|
||||
|
||||
console.log("📋 [handleSave] RepeaterFieldGroup 품목 필드:", repeaterFields);
|
||||
|
||||
for (const item of parsedData) {
|
||||
// 메타 필드 제거
|
||||
const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, ...itemData } = item;
|
||||
|
||||
// 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반)
|
||||
const itemOnlyData: Record<string, any> = {};
|
||||
Object.keys(itemData).forEach((field) => {
|
||||
if (itemOnlyFields.has(field)) {
|
||||
itemOnlyData[field] = itemData[field];
|
||||
}
|
||||
});
|
||||
|
||||
// 🔧 마스터 정보 + 품목 고유 정보 병합
|
||||
// masterFields: 상단 폼에서 수정한 최신 마스터 정보
|
||||
// itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등)
|
||||
const dataWithMeta: Record<string, unknown> = {
|
||||
...masterFields, // 상단 마스터 정보 (최신)
|
||||
...itemOnlyData, // 품목 고유 필드만
|
||||
created_by: context.userId,
|
||||
updated_by: context.userId,
|
||||
company_code: context.companyCode,
|
||||
};
|
||||
|
||||
// 불필요한 필드 제거
|
||||
Object.keys(dataWithMeta).forEach((field) => {
|
||||
if (field.endsWith("_label") || field.endsWith("_value_label") || field.endsWith("_numberingRuleId")) {
|
||||
delete dataWithMeta[field];
|
||||
}
|
||||
});
|
||||
|
||||
// 새 레코드 vs 기존 레코드 판단
|
||||
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
|
||||
|
||||
console.log(`📦 [handleSave] 저장할 데이터 (${isNewRecord ? 'INSERT' : 'UPDATE'}):`, {
|
||||
id: item.id,
|
||||
dataWithMeta,
|
||||
});
|
||||
|
||||
if (isNewRecord) {
|
||||
// INSERT - DynamicFormApi 사용하여 제어관리 실행
|
||||
delete dataWithMeta.id;
|
||||
|
||||
const insertResult = await DynamicFormApi.saveFormData({
|
||||
screenId: context.screenId || 0,
|
||||
tableName: repeaterTargetTable,
|
||||
data: dataWithMeta as Record<string, any>,
|
||||
});
|
||||
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
|
||||
} else if (item.id && _existingRecord === true) {
|
||||
// UPDATE - 기존 레코드
|
||||
const originalData = { id: item.id };
|
||||
const updatedData = { ...dataWithMeta, id: item.id };
|
||||
|
||||
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
|
||||
originalData,
|
||||
updatedData,
|
||||
});
|
||||
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ [handleSave] RepeaterFieldGroup 저장 실패 (key: ${key}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// RepeaterFieldGroup 저장 완료 후 새로고침
|
||||
console.log("✅ [handleSave] RepeaterFieldGroup 저장 완료");
|
||||
context.onRefresh?.();
|
||||
context.onFlowRefresh?.();
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
|
||||
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
|
||||
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
|
||||
|
|
@ -1467,11 +1612,12 @@ export class ButtonActionExecutor {
|
|||
console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseCode, floor, zone });
|
||||
|
||||
try {
|
||||
// search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨)
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
filters: {
|
||||
warehouse_code: warehouseCode,
|
||||
floor: floor,
|
||||
zone: zone,
|
||||
search: {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
floor: { value: floor, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
|
|
|
|||
Loading…
Reference in New Issue