feat: DISTINCT 값 조회 API 추가 및 라우터 설정
- 테이블 컬럼의 DISTINCT 값을 조회하는 API를 추가하였습니다. 이 API는 특정 테이블과 컬럼에서 DISTINCT 값을 반환하여 선택박스 옵션으로 사용할 수 있도록 합니다. - API 호출 시 멀티테넌시를 고려하여 회사 코드에 따라 필터링을 적용하였습니다. - 관련된 라우터 설정을 추가하여 API 접근을 가능하게 하였습니다. - 프론트엔드에서 DISTINCT 값을 조회할 수 있도록 UnifiedSelect 컴포넌트를 업데이트하였습니다.
This commit is contained in:
parent
cc742b27f1
commit
a06f2eb52c
|
|
@ -3,6 +3,107 @@ import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용)
|
||||||
|
* GET /api/entity/:tableName/distinct/:columnName
|
||||||
|
*
|
||||||
|
* 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환
|
||||||
|
*/
|
||||||
|
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { tableName, columnName } = req.params;
|
||||||
|
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
|
||||||
|
|
||||||
|
// 유효성 검증
|
||||||
|
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 지정되지 않았습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columnName || columnName === "undefined" || columnName === "null") {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "컬럼명이 지정되지 않았습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 테이블의 실제 컬럼 목록 조회
|
||||||
|
const columnsResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||||
|
|
||||||
|
// 요청된 컬럼 검증
|
||||||
|
if (!existingColumns.has(columnName)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `테이블 "${tableName}"에 컬럼 "${columnName}"이 존재하지 않습니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라벨 컬럼 결정 (지정되지 않으면 값 컬럼과 동일)
|
||||||
|
const effectiveLabelColumn = labelColumn && existingColumns.has(labelColumn as string)
|
||||||
|
? labelColumn as string
|
||||||
|
: columnName;
|
||||||
|
|
||||||
|
// WHERE 조건 (멀티테넌시)
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode !== "*" && existingColumns.has("company_code")) {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NULL 제외
|
||||||
|
whereConditions.push(`"${columnName}" IS NOT NULL`);
|
||||||
|
whereConditions.push(`"${columnName}" != ''`);
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// DISTINCT 쿼리 실행
|
||||||
|
const query = `
|
||||||
|
SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label
|
||||||
|
FROM "${tableName}"
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY "${effectiveLabelColumn}" ASC
|
||||||
|
LIMIT 500
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
labelColumn: effectiveLabelColumn,
|
||||||
|
companyCode,
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("컬럼 DISTINCT 값 조회 오류", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 엔티티 옵션 조회 API (UnifiedSelect용)
|
* 엔티티 옵션 조회 API (UnifiedSelect용)
|
||||||
* GET /api/entity/:tableName/options
|
* GET /api/entity/:tableName/options
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { searchEntity, getEntityOptions } from "../controllers/entitySearchController";
|
import { searchEntity, getEntityOptions, getDistinctColumnValues } from "../controllers/entitySearchController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -21,3 +21,9 @@ export const entityOptionsRouter = Router();
|
||||||
*/
|
*/
|
||||||
entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions);
|
entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용)
|
||||||
|
* GET /api/entity/:tableName/distinct/:columnName
|
||||||
|
*/
|
||||||
|
entityOptionsRouter.get("/:tableName/distinct/:columnName", authenticateToken, getDistinctColumnValues);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등)
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등)
|
||||||
|
// 🆕 집계 위젯 등에서 사용하는 컴포넌트 목록
|
||||||
|
screenComponents={allComponents.map((comp: any) => ({
|
||||||
|
id: comp.id,
|
||||||
|
componentType: comp.componentType || comp.type,
|
||||||
|
label: comp.label || comp.name || comp.id,
|
||||||
|
tableName: comp.componentConfig?.tableName || comp.tableName,
|
||||||
|
columnName: comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X } from "lucide-react";
|
import { X, Loader2 } from "lucide-react";
|
||||||
import type { TabsComponent, TabItem, TabInlineComponent } from "@/types/screen-management";
|
import type { TabsComponent, TabItem, TabInlineComponent, ComponentData } from "@/types/screen-management";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
|
||||||
|
// 확장된 TabItem 타입 (screenId 지원)
|
||||||
|
interface ExtendedTabItem extends TabItem {
|
||||||
|
screenId?: number;
|
||||||
|
screenName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TabsWidgetProps {
|
interface TabsWidgetProps {
|
||||||
component: TabsComponent;
|
component: TabsComponent;
|
||||||
|
|
@ -15,10 +22,10 @@ interface TabsWidgetProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
menuObjid?: number;
|
menuObjid?: number;
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
onFormDataChange?: (fieldName: string, value: any) => void; // DynamicComponentRenderer와 동일한 시그니처
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
isDesignMode?: boolean; // 디자인 모드 여부
|
isDesignMode?: boolean;
|
||||||
onComponentSelect?: (tabId: string, componentId: string) => void; // 컴포넌트 선택 콜백
|
onComponentSelect?: (tabId: string, componentId: string) => void;
|
||||||
selectedComponentId?: string; // 선택된 컴포넌트 ID
|
selectedComponentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabsWidget({
|
export function TabsWidget({
|
||||||
|
|
@ -56,14 +63,45 @@ export function TabsWidget({
|
||||||
};
|
};
|
||||||
|
|
||||||
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
||||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
|
||||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
||||||
|
|
||||||
|
// screenId 기반 화면 로드 상태
|
||||||
|
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||||
|
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
|
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// 컴포넌트 탭 목록 변경 시 동기화
|
// 컴포넌트 탭 목록 변경 시 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
|
setVisibleTabs((tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled));
|
||||||
}, [tabs]);
|
}, [tabs]);
|
||||||
|
|
||||||
|
// screenId가 있는 탭의 화면 레이아웃 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadScreenLayouts = async () => {
|
||||||
|
for (const tab of visibleTabs) {
|
||||||
|
const extTab = tab as ExtendedTabItem;
|
||||||
|
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
|
||||||
|
if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) {
|
||||||
|
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true }));
|
||||||
|
try {
|
||||||
|
const layoutData = await screenApi.getLayout(extTab.screenId);
|
||||||
|
if (layoutData && layoutData.components) {
|
||||||
|
setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
|
||||||
|
setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
||||||
|
} finally {
|
||||||
|
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadScreenLayouts();
|
||||||
|
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
||||||
|
|
||||||
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (persistSelection && typeof window !== "undefined") {
|
if (persistSelection && typeof window !== "undefined") {
|
||||||
|
|
@ -123,20 +161,110 @@ export function TabsWidget({
|
||||||
return `${baseClass} ${variantClass}`;
|
return `${baseClass} ${variantClass}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인라인 컴포넌트 렌더링
|
// 탭 컨텐츠 렌더링 (screenId 또는 인라인 컴포넌트)
|
||||||
const renderTabComponents = (tab: TabItem) => {
|
const renderTabContent = (tab: ExtendedTabItem) => {
|
||||||
const components = tab.components || [];
|
const extTab = tab as ExtendedTabItem;
|
||||||
|
const inlineComponents = tab.components || [];
|
||||||
if (components.length === 0) {
|
|
||||||
|
// 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식
|
||||||
|
if (extTab.screenId && inlineComponents.length === 0) {
|
||||||
|
// 로딩 중
|
||||||
|
if (screenLoadingStates[tab.id]) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<span className="ml-2 text-muted-foreground">화면을 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 발생
|
||||||
|
if (screenErrors[tab.id]) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5">
|
||||||
|
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 레이아웃이 로드된 경우
|
||||||
|
const loadedComponents = screenLayouts[tab.id];
|
||||||
|
if (loadedComponents && loadedComponents.length > 0) {
|
||||||
|
return renderScreenComponents(loadedComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아직 로드되지 않은 경우
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<p className="text-muted-foreground text-sm">
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
|
||||||
|
if (inlineComponents.length > 0) {
|
||||||
|
return renderInlineComponents(tab, inlineComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 둘 다 없는 경우
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// screenId로 로드한 화면 컴포넌트 렌더링
|
||||||
|
const renderScreenComponents = (components: ComponentData[]) => {
|
||||||
|
// InteractiveScreenViewerDynamic 동적 로드
|
||||||
|
const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
||||||
|
|
||||||
|
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||||
|
const maxBottom = Math.max(
|
||||||
|
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||||
|
300
|
||||||
|
);
|
||||||
|
const maxRight = Math.max(
|
||||||
|
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||||
|
400
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full overflow-auto"
|
||||||
|
style={{
|
||||||
|
minHeight: maxBottom + 20,
|
||||||
|
minWidth: maxRight + 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{components.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: comp.position?.x || 0,
|
||||||
|
top: comp.position?.y || 0,
|
||||||
|
width: comp.size?.width || "auto",
|
||||||
|
height: comp.size?.height || "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InteractiveScreenViewerDynamic
|
||||||
|
component={comp}
|
||||||
|
allComponents={components}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={onFormDataChange}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 컴포넌트 렌더링 (v2 방식)
|
||||||
|
const renderInlineComponents = (tab: TabItem, components: TabInlineComponent[]) => {
|
||||||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||||
const maxBottom = Math.max(
|
const maxBottom = Math.max(
|
||||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||||
|
|
@ -256,7 +384,7 @@ export function TabsWidget({
|
||||||
forceMount
|
forceMount
|
||||||
className={cn("h-full overflow-auto", !isActive && "hidden")}
|
className={cn("h-full overflow-auto", !isActive && "hidden")}
|
||||||
>
|
>
|
||||||
{shouldRender && renderTabComponents(tab)}
|
{shouldRender && renderTabContent(tab)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -618,6 +618,19 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
||||||
fetchedOptions = flattenTree(data.data);
|
fetchedOptions = flattenTree(data.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (source === "select" || source === "distinct") {
|
||||||
|
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
|
||||||
|
// tableName, columnName은 props에서 가져옴
|
||||||
|
if (tableName && columnName) {
|
||||||
|
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: String(item.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptions(fetchedOptions);
|
setOptions(fetchedOptions);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType, FilterCondition, DataSourceType } from "./types";
|
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType, FilterCondition, DataSourceType } from "./types";
|
||||||
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
|
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||||
|
|
||||||
interface AggregationWidgetComponentProps extends ComponentRendererProps {
|
interface AggregationWidgetComponentProps extends ComponentRendererProps {
|
||||||
config?: AggregationWidgetConfig;
|
config?: AggregationWidgetConfig;
|
||||||
|
|
@ -16,6 +17,14 @@ interface AggregationWidgetComponentProps extends ComponentRendererProps {
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
// 선택된 행 데이터
|
// 선택된 행 데이터
|
||||||
selectedRows?: any[];
|
selectedRows?: any[];
|
||||||
|
// 선택된 행 전체 데이터 (표준 Props)
|
||||||
|
selectedRowsData?: any[];
|
||||||
|
// 멀티테넌시용 회사 코드
|
||||||
|
companyCode?: string;
|
||||||
|
// 새로고침 트리거 키
|
||||||
|
refreshKey?: number;
|
||||||
|
// 새로고침 콜백
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -107,11 +116,16 @@ export function AggregationWidgetComponent({
|
||||||
externalData,
|
externalData,
|
||||||
formData = {},
|
formData = {},
|
||||||
selectedRows = [],
|
selectedRows = [],
|
||||||
|
selectedRowsData = [],
|
||||||
|
companyCode,
|
||||||
|
refreshKey,
|
||||||
|
onRefresh,
|
||||||
}: AggregationWidgetComponentProps) {
|
}: AggregationWidgetComponentProps) {
|
||||||
// 다국어 지원
|
// 다국어 지원
|
||||||
const { getText } = useScreenMultiLang();
|
const { getText } = useScreenMultiLang();
|
||||||
|
|
||||||
const componentConfig: AggregationWidgetConfig = {
|
// useMemo로 config 병합 (매 렌더링마다 새 객체 생성 방지)
|
||||||
|
const componentConfig = useMemo<AggregationWidgetConfig>(() => ({
|
||||||
dataSourceType: "table",
|
dataSourceType: "table",
|
||||||
items: [],
|
items: [],
|
||||||
layout: "horizontal",
|
layout: "horizontal",
|
||||||
|
|
@ -120,7 +134,7 @@ export function AggregationWidgetComponent({
|
||||||
gap: "16px",
|
gap: "16px",
|
||||||
...propsConfig,
|
...propsConfig,
|
||||||
...component?.config,
|
...component?.config,
|
||||||
};
|
}), [propsConfig, component?.config]);
|
||||||
|
|
||||||
// 다국어 라벨 가져오기
|
// 다국어 라벨 가져오기
|
||||||
const getItemLabel = (item: AggregationItem): string => {
|
const getItemLabel = (item: AggregationItem): string => {
|
||||||
|
|
@ -230,13 +244,13 @@ export function AggregationWidgetComponent({
|
||||||
}
|
}
|
||||||
}, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]);
|
}, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]);
|
||||||
|
|
||||||
// 테이블 데이터 조회 (초기 로드)
|
// 테이블 데이터 조회 (초기 로드 + refreshKey 변경 시)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dataSourceType === "table" && effectiveTableName && !isDesignMode) {
|
if (dataSourceType === "table" && effectiveTableName && !isDesignMode) {
|
||||||
fetchTableData();
|
fetchTableData();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dataSourceType, effectiveTableName, isDesignMode]);
|
}, [dataSourceType, effectiveTableName, isDesignMode, refreshKey]);
|
||||||
|
|
||||||
// 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때)
|
// 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때)
|
||||||
const formDataKey = JSON.stringify(formData);
|
const formDataKey = JSON.stringify(formData);
|
||||||
|
|
@ -260,16 +274,114 @@ export function AggregationWidgetComponent({
|
||||||
}, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]);
|
}, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]);
|
||||||
|
|
||||||
// 선택된 행 집계 (dataSourceType === "selection"일 때)
|
// 선택된 행 집계 (dataSourceType === "selection"일 때)
|
||||||
// props로 전달된 selectedRows 사용
|
// props로 전달된 selectedRows 또는 selectedRowsData 사용
|
||||||
const selectedRowsKey = JSON.stringify(selectedRows);
|
// 길이 정보를 포함하여 전체 데이터 변경 감지 개선
|
||||||
|
const selectedRowsKey = `${selectedRows?.length || 0}:${JSON.stringify(selectedRows?.slice(0, 5))}`;
|
||||||
|
const selectedRowsDataKey = `${selectedRowsData?.length || 0}:${JSON.stringify(selectedRowsData?.slice(0, 5))}`;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dataSourceType === "selection" && Array.isArray(selectedRows) && selectedRows.length > 0) {
|
// selectedRowsData가 있으면 우선 사용 (표준 Props)
|
||||||
setData(selectedRows);
|
const rowsToUse = selectedRowsData?.length > 0 ? selectedRowsData : selectedRows;
|
||||||
|
if (dataSourceType === "selection") {
|
||||||
|
if (Array.isArray(rowsToUse) && rowsToUse.length > 0) {
|
||||||
|
const filteredData = applyFilters(
|
||||||
|
rowsToUse,
|
||||||
|
filtersRef.current || [],
|
||||||
|
filterLogic,
|
||||||
|
formDataRef.current,
|
||||||
|
selectedRowsRef.current
|
||||||
|
);
|
||||||
|
setData(filteredData);
|
||||||
|
} else {
|
||||||
|
// 선택 해제 시 빈 배열로 초기화
|
||||||
|
setData([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dataSourceType, selectedRowsKey]);
|
}, [dataSourceType, selectedRowsKey, selectedRowsDataKey, filterLogic]);
|
||||||
|
|
||||||
// 전역 선택 이벤트 수신 (dataSourceType === "selection"일 때)
|
// V2 이벤트 버스 구독 (selection 또는 component 타입일 때)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDesignMode) return;
|
||||||
|
if (dataSourceType !== "selection" && dataSourceType !== "component") return;
|
||||||
|
|
||||||
|
// 핸들러 함수 정의
|
||||||
|
const handleV2TableDataChange = (payload: any) => {
|
||||||
|
// component 타입: source가 dataSourceComponentId와 일치할 때만
|
||||||
|
// selection 타입: 모든 테이블 데이터 변경 수신
|
||||||
|
if (dataSourceType === "component" && payload.source !== dataSourceComponentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.data)) {
|
||||||
|
const filteredData = applyFilters(
|
||||||
|
payload.data,
|
||||||
|
filtersRef.current || [],
|
||||||
|
filterLogic,
|
||||||
|
formDataRef.current,
|
||||||
|
selectedRowsRef.current
|
||||||
|
);
|
||||||
|
setData(filteredData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleV2TableSelectionChange = (payload: any) => {
|
||||||
|
// component 타입: source가 dataSourceComponentId와 일치할 때만
|
||||||
|
// selection 타입: 모든 선택 변경 수신
|
||||||
|
if (dataSourceType === "component" && payload.source !== dataSourceComponentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.selectedRows)) {
|
||||||
|
const filteredData = applyFilters(
|
||||||
|
payload.selectedRows,
|
||||||
|
filtersRef.current || [],
|
||||||
|
filterLogic,
|
||||||
|
formDataRef.current,
|
||||||
|
selectedRowsRef.current
|
||||||
|
);
|
||||||
|
setData(filteredData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleV2RepeaterDataChange = (payload: any) => {
|
||||||
|
if (dataSourceType === "component" && payload.repeaterId !== dataSourceComponentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.data)) {
|
||||||
|
const filteredData = applyFilters(
|
||||||
|
payload.data,
|
||||||
|
filtersRef.current || [],
|
||||||
|
filterLogic,
|
||||||
|
formDataRef.current,
|
||||||
|
selectedRowsRef.current
|
||||||
|
);
|
||||||
|
setData(filteredData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// V2 이벤트 버스 구독
|
||||||
|
const unsubscribeTableData = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.TABLE_DATA_CHANGE,
|
||||||
|
handleV2TableDataChange
|
||||||
|
);
|
||||||
|
const unsubscribeTableSelection = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.TABLE_SELECTION_CHANGE,
|
||||||
|
handleV2TableSelectionChange
|
||||||
|
);
|
||||||
|
const unsubscribeRepeaterData = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.REPEATER_DATA_CHANGE,
|
||||||
|
handleV2RepeaterDataChange
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeTableData();
|
||||||
|
unsubscribeTableSelection();
|
||||||
|
unsubscribeRepeaterData();
|
||||||
|
};
|
||||||
|
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);
|
||||||
|
|
||||||
|
// 전역 선택 이벤트 수신 - 레거시 지원 (dataSourceType === "selection"일 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dataSourceType !== "selection" || isDesignMode) return;
|
if (dataSourceType !== "selection" || isDesignMode) return;
|
||||||
|
|
||||||
|
|
@ -346,7 +458,10 @@ export function AggregationWidgetComponent({
|
||||||
}, [dataSourceType, isDesignMode, filterLogic]);
|
}, [dataSourceType, isDesignMode, filterLogic]);
|
||||||
|
|
||||||
// 외부 데이터가 있으면 사용
|
// 외부 데이터가 있으면 사용
|
||||||
const externalDataKey = externalData ? JSON.stringify(externalData.slice(0, 5)) : null; // 첫 5개만 비교
|
// 길이 정보를 포함하여 전체 데이터 변경 감지 개선
|
||||||
|
const externalDataKey = externalData
|
||||||
|
? `${externalData.length}:${JSON.stringify(externalData.slice(0, 5))}`
|
||||||
|
: null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (externalData && Array.isArray(externalData)) {
|
if (externalData && Array.isArray(externalData)) {
|
||||||
// 필터 적용
|
// 필터 적용
|
||||||
|
|
@ -475,6 +590,61 @@ export function AggregationWidgetComponent({
|
||||||
});
|
});
|
||||||
}, [data, items, getText]);
|
}, [data, items, getText]);
|
||||||
|
|
||||||
|
// aggregationResults를 ref로 유지 (이벤트 핸들러에서 최신 값 참조)
|
||||||
|
const aggregationResultsRef = useRef(aggregationResults);
|
||||||
|
aggregationResultsRef.current = aggregationResults;
|
||||||
|
|
||||||
|
// beforeFormSave 이벤트 리스너 (저장 시 집계 결과를 폼 데이터에 포함)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDesignMode) return;
|
||||||
|
|
||||||
|
const handleBeforeFormSave = (event: CustomEvent) => {
|
||||||
|
const componentKey = component?.id || "aggregation_data";
|
||||||
|
if (event.detail) {
|
||||||
|
// 집계 결과를 객체 형태로 저장
|
||||||
|
const aggregationData: Record<string, any> = {};
|
||||||
|
aggregationResultsRef.current.forEach((result) => {
|
||||||
|
aggregationData[result.id] = {
|
||||||
|
label: result.label,
|
||||||
|
value: result.value,
|
||||||
|
formattedValue: result.formattedValue,
|
||||||
|
type: result.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
event.detail.formData[componentKey] = aggregationData;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// V2 이벤트 버스 구독
|
||||||
|
const unsubscribe = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.FORM_SAVE_COLLECT,
|
||||||
|
(payload) => {
|
||||||
|
const componentKey = component?.id || "aggregation_data";
|
||||||
|
const aggregationData: Record<string, any> = {};
|
||||||
|
aggregationResultsRef.current.forEach((result) => {
|
||||||
|
aggregationData[result.id] = {
|
||||||
|
label: result.label,
|
||||||
|
value: result.value,
|
||||||
|
formattedValue: result.formattedValue,
|
||||||
|
type: result.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// V2 이벤트로 응답
|
||||||
|
if (payload.formData) {
|
||||||
|
payload.formData[componentKey] = aggregationData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 레거시 이벤트도 지원
|
||||||
|
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
};
|
||||||
|
}, [isDesignMode, component?.id]);
|
||||||
|
|
||||||
// 집계 타입에 따른 아이콘
|
// 집계 타입에 따른 아이콘
|
||||||
const getIcon = (type: AggregationType) => {
|
const getIcon = (type: AggregationType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -627,47 +797,52 @@ export function AggregationWidgetComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<V2ErrorBoundary
|
||||||
className={cn(
|
componentId={component?.id || "aggregation-widget"}
|
||||||
"flex items-center rounded-md border bg-slate-50 p-3",
|
componentType="v2-aggregation-widget"
|
||||||
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
gap: gap || "12px",
|
|
||||||
backgroundColor: backgroundColor || undefined,
|
|
||||||
borderRadius: borderRadius || undefined,
|
|
||||||
padding: padding || undefined,
|
|
||||||
fontSize: fontSize || undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{aggregationResults.map((result, index) => (
|
<div
|
||||||
<div
|
className={cn(
|
||||||
key={result.id || index}
|
"flex items-center rounded-md border bg-slate-50 p-3",
|
||||||
className={cn(
|
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
||||||
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
)}
|
||||||
layout === "vertical" ? "w-full justify-between" : ""
|
style={{
|
||||||
)}
|
gap: gap || "12px",
|
||||||
>
|
backgroundColor: backgroundColor || undefined,
|
||||||
{showIcons && (
|
borderRadius: borderRadius || undefined,
|
||||||
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
padding: padding || undefined,
|
||||||
)}
|
fontSize: fontSize || undefined,
|
||||||
{showLabels && (
|
}}
|
||||||
<span
|
>
|
||||||
className="text-muted-foreground text-xs"
|
{aggregationResults.map((result, index) => (
|
||||||
style={{ fontSize: labelFontSize, color: labelColor }}
|
<div
|
||||||
>
|
key={result.id || index}
|
||||||
{result.label} ({getTypeLabel(result.type)}):
|
className={cn(
|
||||||
</span>
|
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
||||||
)}
|
layout === "vertical" ? "w-full justify-between" : ""
|
||||||
<span
|
)}
|
||||||
className="font-semibold"
|
|
||||||
style={{ fontSize: valueFontSize, color: valueColor }}
|
|
||||||
>
|
>
|
||||||
{result.formattedValue}
|
{showIcons && (
|
||||||
</span>
|
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
||||||
</div>
|
)}
|
||||||
))}
|
{showLabels && (
|
||||||
</div>
|
<span
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
style={{ fontSize: labelFontSize, color: labelColor }}
|
||||||
|
>
|
||||||
|
{result.label} ({getTypeLabel(result.type)}):
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="font-semibold"
|
||||||
|
style={{ fontSize: valueFontSize, color: valueColor }}
|
||||||
|
>
|
||||||
|
{result.formattedValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</V2ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ interface AggregationWidgetConfigPanelProps {
|
||||||
onChange: (config: Partial<AggregationWidgetConfig>) => void;
|
onChange: (config: Partial<AggregationWidgetConfig>) => void;
|
||||||
screenTableName?: string;
|
screenTableName?: string;
|
||||||
// 화면 내 컴포넌트 목록 (컴포넌트 연결용)
|
// 화면 내 컴포넌트 목록 (컴포넌트 연결용)
|
||||||
screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string }>;
|
screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string; columnName?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -172,13 +172,14 @@ export function AggregationWidgetConfigPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await tableManagementApi.getColumns(sourceComp.tableName);
|
const response = await tableManagementApi.getColumnList(sourceComp.tableName);
|
||||||
const cols = (response.data?.columns || response.data || []).map((col: any) => ({
|
const rawCols = response.data?.columns || (Array.isArray(response.data) ? response.data : []);
|
||||||
|
const cols = rawCols.map((col: any) => ({
|
||||||
columnName: col.column_name || col.columnName,
|
columnName: col.column_name || col.columnName,
|
||||||
label: col.column_label || col.columnLabel || col.display_name || col.column_name || col.columnName,
|
label: col.column_label || col.columnLabel || col.display_name || col.column_name || col.columnName,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setSourceComponentColumnsCache(prev => ({
|
setSourceComponentColumnsCache((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[componentId]: cols,
|
[componentId]: cols,
|
||||||
}));
|
}));
|
||||||
|
|
@ -290,19 +291,20 @@ export function AggregationWidgetConfigPanel({
|
||||||
try {
|
try {
|
||||||
// 카테고리 API 호출
|
// 카테고리 API 호출
|
||||||
const result = await getCategoryValues(targetTableName, col.columnName, false);
|
const result = await getCategoryValues(targetTableName, col.columnName, false);
|
||||||
if (result.success && Array.isArray(result.data)) {
|
if (result.success && "data" in result && Array.isArray(result.data)) {
|
||||||
// 중복 제거 (valueCode 기준)
|
// 중복 제거 (valueCode 기준)
|
||||||
const seenCodes = new Set<string>();
|
const seenCodes = new Set<string>();
|
||||||
const uniqueOptions: Array<{ value: string; label: string }> = [];
|
const uniqueOptions: Array<{ value: string; label: string }> = [];
|
||||||
|
|
||||||
for (const item of result.data) {
|
for (const item of result.data) {
|
||||||
const code = item.valueCode || item.code || item.value || item.id;
|
const itemAny = item as any;
|
||||||
|
const code = item.valueCode || itemAny.code || itemAny.value || itemAny.id;
|
||||||
if (!seenCodes.has(code)) {
|
if (!seenCodes.has(code)) {
|
||||||
seenCodes.add(code);
|
seenCodes.add(code);
|
||||||
uniqueOptions.push({
|
uniqueOptions.push({
|
||||||
value: code,
|
value: code,
|
||||||
// valueLabel이 실제 표시명
|
// valueLabel이 실제 표시명
|
||||||
label: item.valueLabel || item.valueName || item.name || item.label || item.displayName || code,
|
label: item.valueLabel || itemAny.valueName || itemAny.name || itemAny.label || itemAny.displayName || code,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -418,6 +420,52 @@ export function AggregationWidgetConfigPanel({
|
||||||
c.componentType === "table-list"
|
c.componentType === "table-list"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 폼 필드로 사용 가능한 컴포넌트 (입력 위젯들만)
|
||||||
|
const formFieldComponents = useMemo(() => {
|
||||||
|
// 제외할 컴포넌트 타입 (표시 전용, 레이아웃, 컨테이너 등)
|
||||||
|
const excludeTypes = [
|
||||||
|
"aggregation", "widget", "button", "label", "display", "table-list",
|
||||||
|
"repeat", "container", "layout", "section", "card", "tabs", "modal",
|
||||||
|
"flow", "rack", "map", "chart", "image", "file", "media"
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = screenComponents.filter((comp) => {
|
||||||
|
const type = comp.componentType?.toLowerCase() || "";
|
||||||
|
|
||||||
|
// 제외 대상인지 먼저 체크
|
||||||
|
const isExcluded = excludeTypes.some(exclude => type.includes(exclude));
|
||||||
|
if (isExcluded) return false;
|
||||||
|
|
||||||
|
// 입력 가능한 컴포넌트 타입들
|
||||||
|
const isInputType = (
|
||||||
|
type.includes("input") ||
|
||||||
|
type.includes("select") ||
|
||||||
|
type.includes("date") ||
|
||||||
|
type.includes("checkbox") ||
|
||||||
|
type.includes("radio") ||
|
||||||
|
type.includes("textarea") ||
|
||||||
|
type.includes("number") ||
|
||||||
|
// unified-input, unified-select, unified-date 등 (unified-repeater 등은 제외)
|
||||||
|
type === "unified-input" ||
|
||||||
|
type === "unified-select" ||
|
||||||
|
type === "unified-date" ||
|
||||||
|
type === "unified-hierarchy"
|
||||||
|
);
|
||||||
|
|
||||||
|
// columnName이 있으면 입력 필드로 간주 (드래그로 배치된 필드)
|
||||||
|
const hasColumnName = !!comp.columnName;
|
||||||
|
|
||||||
|
return isInputType || hasColumnName;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.map((comp) => ({
|
||||||
|
id: comp.id,
|
||||||
|
label: comp.label || comp.columnName || comp.id,
|
||||||
|
columnName: comp.columnName || comp.id,
|
||||||
|
componentType: comp.componentType,
|
||||||
|
}));
|
||||||
|
}, [screenComponents]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">집계 위젯 설정</div>
|
<div className="text-sm font-medium">집계 위젯 설정</div>
|
||||||
|
|
@ -444,7 +492,14 @@ export function AggregationWidgetConfigPanel({
|
||||||
variant={dataSourceType === "component" ? "default" : "outline"}
|
variant={dataSourceType === "component" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-auto flex-col gap-1 py-2 text-xs"
|
className="h-auto flex-col gap-1 py-2 text-xs"
|
||||||
onClick={() => onChange({ dataSourceType: "component" })}
|
onClick={() => {
|
||||||
|
// 컴포넌트 모드로 변경 시 화면의 메인 테이블로 자동 설정
|
||||||
|
onChange({
|
||||||
|
dataSourceType: "component",
|
||||||
|
tableName: screenTableName || config.tableName,
|
||||||
|
useCustomTable: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
<span>컴포넌트</span>
|
<span>컴포넌트</span>
|
||||||
|
|
@ -453,7 +508,14 @@ export function AggregationWidgetConfigPanel({
|
||||||
variant={dataSourceType === "selection" ? "default" : "outline"}
|
variant={dataSourceType === "selection" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-auto flex-col gap-1 py-2 text-xs"
|
className="h-auto flex-col gap-1 py-2 text-xs"
|
||||||
onClick={() => onChange({ dataSourceType: "selection" })}
|
onClick={() => {
|
||||||
|
// 선택 데이터 모드로 변경 시 화면의 메인 테이블로 자동 설정
|
||||||
|
onChange({
|
||||||
|
dataSourceType: "selection",
|
||||||
|
tableName: screenTableName || config.tableName,
|
||||||
|
useCustomTable: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MousePointer className="h-4 w-4" />
|
<MousePointer className="h-4 w-4" />
|
||||||
<span>선택 데이터</span>
|
<span>선택 데이터</span>
|
||||||
|
|
@ -797,12 +859,32 @@ export function AggregationWidgetConfigPanel({
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{filter.valueSourceType === "formField" && (
|
{filter.valueSourceType === "formField" && (
|
||||||
<Input
|
formFieldComponents.length > 0 ? (
|
||||||
value={filter.formFieldName || ""}
|
<Select
|
||||||
onChange={(e) => updateFilter(filter.id, { formFieldName: e.target.value })}
|
value={filter.formFieldName || ""}
|
||||||
placeholder="필드명 입력"
|
onValueChange={(value) => updateFilter(filter.id, { formFieldName: value })}
|
||||||
className="h-7 text-xs"
|
>
|
||||||
/>
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="폼 필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{formFieldComponents.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.columnName}>
|
||||||
|
{field.label}
|
||||||
|
{field.columnName !== field.label && (
|
||||||
|
<span className="ml-1 text-muted-foreground text-[10px]">
|
||||||
|
({field.columnName})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground h-7 px-2 border rounded-md bg-slate-50">
|
||||||
|
<span>배치된 입력 필드가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{filter.valueSourceType === "selection" && (
|
{filter.valueSourceType === "selection" && (
|
||||||
<div className="space-y-2 col-span-2">
|
<div className="space-y-2 col-span-2">
|
||||||
|
|
|
||||||
|
|
@ -370,13 +370,25 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
// 🆕 allComponents를 screenComponents 형태로 변환 (집계 위젯 등에서 사용)
|
// 🆕 allComponents를 screenComponents 형태로 변환 (집계 위젯 등에서 사용)
|
||||||
// Hooks 규칙: 조건부 return 전에 선언해야 함
|
// Hooks 규칙: 조건부 return 전에 선언해야 함
|
||||||
const screenComponents = React.useMemo(() => {
|
const screenComponents = React.useMemo(() => {
|
||||||
if (!allComponents) return [];
|
if (!allComponents) {
|
||||||
return allComponents.map((comp: any) => ({
|
console.log("[getComponentConfigPanel] allComponents is undefined or null");
|
||||||
id: comp.id,
|
return [];
|
||||||
componentType: comp.componentType || comp.type,
|
}
|
||||||
label: comp.label || comp.name || comp.id,
|
console.log("[getComponentConfigPanel] allComponents 변환 시작:", allComponents.length, "개");
|
||||||
tableName: comp.componentConfig?.tableName || comp.tableName,
|
const result = allComponents.map((comp: any) => {
|
||||||
}));
|
const columnName = comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName;
|
||||||
|
console.log(`[getComponentConfigPanel] comp: ${comp.id}, type: ${comp.componentType || comp.type}, columnName: ${columnName}`);
|
||||||
|
return {
|
||||||
|
id: comp.id,
|
||||||
|
componentType: comp.componentType || comp.type,
|
||||||
|
label: comp.label || comp.name || comp.id,
|
||||||
|
tableName: comp.componentConfig?.tableName || comp.tableName,
|
||||||
|
// 🆕 폼 필드 인식용 columnName 추가
|
||||||
|
columnName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log("[getComponentConfigPanel] screenComponents 변환 완료:", result);
|
||||||
|
return result;
|
||||||
}, [allComponents]);
|
}, [allComponents]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,12 @@ export function createUnifiedConfigFromColumn(column: {
|
||||||
componentConfig.searchable = true;
|
componentConfig.searchable = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// select 타입인 경우: 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
|
||||||
|
if (column.widgetType === "select" || column.inputType === "select") {
|
||||||
|
componentConfig.source = "select"; // DISTINCT 조회 모드
|
||||||
|
componentConfig.searchable = true;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
componentType: mapping.componentType,
|
componentType: mapping.componentType,
|
||||||
componentConfig,
|
componentConfig,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue