Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
d1ce14de7a
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "../types/screen";
|
||||
|
||||
import { generateId } from "../utils/generateId";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// 화면 복사 요청 인터페이스
|
||||
interface CopyScreenRequest {
|
||||
|
|
@ -24,7 +25,7 @@ interface CopyScreenRequest {
|
|||
screenCode: string;
|
||||
description?: string;
|
||||
companyCode: string; // 요청한 사용자의 회사 코드 (인증용)
|
||||
userId: string;
|
||||
createdBy?: string; // 생성자 ID
|
||||
targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용)
|
||||
}
|
||||
|
||||
|
|
@ -2153,9 +2154,9 @@ export class ScreenManagementService {
|
|||
targetCompanyCode, // 대상 회사 코드 사용
|
||||
sourceScreen.table_name,
|
||||
sourceScreen.is_active,
|
||||
copyData.userId,
|
||||
copyData.createdBy,
|
||||
new Date(),
|
||||
copyData.userId,
|
||||
copyData.createdBy,
|
||||
new Date(),
|
||||
]
|
||||
);
|
||||
|
|
@ -2267,7 +2268,7 @@ export class ScreenManagementService {
|
|||
screenCode: data.mainScreen.screenCode,
|
||||
description: data.mainScreen.description || "",
|
||||
companyCode: data.companyCode,
|
||||
userId: data.userId,
|
||||
createdBy: data.userId,
|
||||
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
|
||||
|
|
@ -2283,7 +2284,7 @@ export class ScreenManagementService {
|
|||
screenCode: modalData.screenCode,
|
||||
description: "",
|
||||
companyCode: data.companyCode,
|
||||
userId: data.userId,
|
||||
createdBy: data.userId,
|
||||
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,16 +26,31 @@ export interface DataFilterConfig {
|
|||
*/
|
||||
export function buildDataFilterWhereClause(
|
||||
dataFilter: DataFilterConfig | undefined,
|
||||
tableAlias?: string,
|
||||
tableAliasOrStartIndex?: string | number,
|
||||
startParamIndex: number = 1
|
||||
): { whereClause: string; params: any[] } {
|
||||
if (!dataFilter || !dataFilter.enabled || !dataFilter.filters || dataFilter.filters.length === 0) {
|
||||
return { whereClause: "", params: [] };
|
||||
}
|
||||
|
||||
// 파라미터 처리: 첫 번째 파라미터가 숫자면 startParamIndex, 문자열이면 tableAlias
|
||||
let tableAlias: string | undefined;
|
||||
let actualStartIndex: number;
|
||||
|
||||
if (typeof tableAliasOrStartIndex === "number") {
|
||||
actualStartIndex = tableAliasOrStartIndex;
|
||||
tableAlias = undefined;
|
||||
} else if (typeof tableAliasOrStartIndex === "string") {
|
||||
tableAlias = tableAliasOrStartIndex;
|
||||
actualStartIndex = startParamIndex;
|
||||
} else {
|
||||
actualStartIndex = startParamIndex;
|
||||
tableAlias = undefined;
|
||||
}
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = startParamIndex;
|
||||
let paramIndex = actualStartIndex;
|
||||
|
||||
// 테이블 별칭이 있으면 "alias."를 붙이고, 없으면 그냥 컬럼명만
|
||||
const getColumnRef = (colName: string) => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
|
|
@ -146,6 +147,9 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
// 커스텀 메트릭 설정
|
||||
const [customMetricConfig, setCustomMetricConfig] = useState<CustomMetricConfig>({});
|
||||
|
||||
// 자동 새로고침 간격 (지도 위젯용)
|
||||
const [refreshInterval, setRefreshInterval] = useState<number>(5);
|
||||
|
||||
// 사이드바 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen && element) {
|
||||
|
|
@ -155,6 +159,8 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 가져옴
|
||||
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
|
||||
setQueryResult(null);
|
||||
// 자동 새로고침 간격 초기화
|
||||
setRefreshInterval(element.chartConfig?.refreshInterval ?? 5);
|
||||
|
||||
// 리스트 위젯 설정 초기화
|
||||
if (element.subtype === "list-v2" && element.listConfig) {
|
||||
|
|
@ -290,6 +296,24 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
element.subtype === "custom-metric-v2" ||
|
||||
element.subtype === "risk-alert-v2";
|
||||
|
||||
// chartConfig 구성 (위젯 타입별로 다르게 처리)
|
||||
let finalChartConfig = { ...chartConfig };
|
||||
|
||||
if (isMultiDataSourceWidget) {
|
||||
finalChartConfig = {
|
||||
...finalChartConfig,
|
||||
dataSources: dataSources,
|
||||
};
|
||||
}
|
||||
|
||||
// 지도 위젯인 경우 refreshInterval 추가
|
||||
if (element.subtype === "map-summary-v2") {
|
||||
finalChartConfig = {
|
||||
...finalChartConfig,
|
||||
refreshInterval,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
customTitle: customTitle.trim() || undefined,
|
||||
|
|
@ -302,8 +326,6 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
...(isMultiDataSourceWidget
|
||||
? {
|
||||
dataSources: dataSources,
|
||||
// chartConfig에도 dataSources 포함 (일부 위젯은 chartConfig에서 읽음)
|
||||
chartConfig: { ...chartConfig, dataSources: dataSources },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
|
@ -314,21 +336,10 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
listConfig,
|
||||
}
|
||||
: {}),
|
||||
// 차트 설정 (차트 타입이거나 차트 기능이 있는 위젯)
|
||||
...(element.type === "chart" ||
|
||||
element.subtype === "chart" ||
|
||||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
|
||||
// 차트 설정 (모든 위젯 공통)
|
||||
...(needsDataSource(element.subtype)
|
||||
? {
|
||||
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 (빈 배열도 허용 - 연결 해제)
|
||||
chartConfig: isMultiDataSourceWidget
|
||||
? { ...chartConfig, dataSources: dataSources }
|
||||
: chartConfig,
|
||||
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 (빈 배열도 허용 - 연결 해제)
|
||||
...(isMultiDataSourceWidget
|
||||
? {
|
||||
dataSources: dataSources,
|
||||
}
|
||||
: {}),
|
||||
chartConfig: finalChartConfig,
|
||||
}
|
||||
: {}),
|
||||
// 커스텀 메트릭 설정
|
||||
|
|
@ -341,6 +352,10 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
|
||||
console.log("🔧 [WidgetConfigSidebar] handleApply 호출:", {
|
||||
subtype: element.subtype,
|
||||
isMultiDataSourceWidget,
|
||||
dataSources,
|
||||
listConfig,
|
||||
finalChartConfig,
|
||||
customMetricConfig,
|
||||
updatedElement,
|
||||
});
|
||||
|
|
@ -356,6 +371,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
listConfig,
|
||||
chartConfig,
|
||||
customMetricConfig,
|
||||
refreshInterval,
|
||||
onApply,
|
||||
onClose,
|
||||
]);
|
||||
|
|
@ -432,6 +448,40 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
<Switch id="show-header" checked={showHeader} onCheckedChange={setShowHeader} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
|
||||
{element.subtype === "map-summary-v2" && (
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label htmlFor="refresh-interval" className="mb-2 block text-xs font-semibold">
|
||||
자동 새로고침 간격
|
||||
</Label>
|
||||
<Select value={refreshInterval.toString()} onValueChange={(value) => setRefreshInterval(parseInt(value))}>
|
||||
<SelectTrigger id="refresh-interval" className="h-9 text-sm">
|
||||
<SelectValue placeholder="간격 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-sm">
|
||||
없음
|
||||
</SelectItem>
|
||||
<SelectItem value="5" className="text-sm">
|
||||
5초
|
||||
</SelectItem>
|
||||
<SelectItem value="10" className="text-sm">
|
||||
10초
|
||||
</SelectItem>
|
||||
<SelectItem value="30" className="text-sm">
|
||||
30초
|
||||
</SelectItem>
|
||||
<SelectItem value="60" className="text-sm">
|
||||
1분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1.5 text-xs">
|
||||
위젯의 모든 데이터를 자동으로 갱신하는 주기를 설정합니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -530,30 +530,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="marker-refresh-interval" className="text-xs">
|
||||
마커 새로고침 간격
|
||||
</Label>
|
||||
<Select
|
||||
value={(dataSource.refreshInterval ?? 5).toString()}
|
||||
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger id="marker-refresh-interval" className="h-9 text-xs">
|
||||
<SelectValue placeholder="간격 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
||||
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
마커 데이터를 자동으로 갱신하는 주기를 설정합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 마커 종류 선택 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -321,37 +321,28 @@ ORDER BY 하위부서수 DESC`,
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
|
||||
{/* 마커 종류 선택 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="marker-refresh-interval" className="text-xs">
|
||||
데이터 새로고침 간격
|
||||
<Label htmlFor="marker-type" className="text-xs">
|
||||
마커 종류
|
||||
</Label>
|
||||
<Select
|
||||
value={(dataSource.refreshInterval ?? 5).toString()}
|
||||
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
|
||||
value={dataSource.markerType || "circle"}
|
||||
onValueChange={(value) => onChange({ markerType: value })}
|
||||
>
|
||||
<SelectTrigger id="marker-refresh-interval" className="h-8 text-xs">
|
||||
<SelectValue placeholder="간격 선택" />
|
||||
<SelectTrigger id="marker-type" className="h-8 text-xs">
|
||||
<SelectValue placeholder="마커 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">
|
||||
없음
|
||||
<SelectItem value="circle" className="text-xs">
|
||||
동그라미
|
||||
</SelectItem>
|
||||
<SelectItem value="5" className="text-xs">
|
||||
5초
|
||||
</SelectItem>
|
||||
<SelectItem value="10" className="text-xs">
|
||||
10초
|
||||
</SelectItem>
|
||||
<SelectItem value="30" className="text-xs">
|
||||
30초
|
||||
</SelectItem>
|
||||
<SelectItem value="60" className="text-xs">
|
||||
1분
|
||||
<SelectItem value="arrow" className="text-xs">
|
||||
화살표
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-[10px]">마커 데이터를 자동으로 갱신하는 주기를 설정합니다</p>
|
||||
<p className="text-muted-foreground text-[10px]">지도에 표시할 마커의 모양을 선택합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||
|
|
|
|||
|
|
@ -155,7 +155,6 @@ export interface ChartDataSource {
|
|||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||
|
||||
// 공통
|
||||
refreshInterval?: number; // 자동 새로고침 (초, 0이면 수동)
|
||||
lastExecuted?: string; // 마지막 실행 시간
|
||||
lastError?: string; // 마지막 오류 메시지
|
||||
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
|
||||
|
|
@ -184,6 +183,9 @@ export interface ChartConfig {
|
|||
// 다중 데이터 소스 (테스트 위젯용)
|
||||
dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능)
|
||||
|
||||
// 위젯 레벨 설정 (MapTestWidgetV2용)
|
||||
refreshInterval?: number; // 위젯 전체 자동 새로고침 간격 (초, 0이면 수동)
|
||||
|
||||
// 멀티 차트 설정 (ChartTestWidget용)
|
||||
chartType?: string; // 차트 타입 (line, bar, pie, etc.)
|
||||
mergeMode?: boolean; // 데이터 병합 모드 (여러 데이터 소스를 하나의 라인/바로 합침)
|
||||
|
|
|
|||
|
|
@ -209,10 +209,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background">
|
||||
<div className="bg-background flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -220,12 +220,12 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-destructive">⚠️ {error}</p>
|
||||
<p className="text-destructive text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
className="bg-destructive/10 text-destructive hover:bg-destructive/20 mt-2 rounded px-3 py-1 text-xs"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -244,10 +244,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
// 설정이 없으면 안내 화면
|
||||
if (!hasDataSource || !hasConfig) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<h3 className="text-sm font-bold text-foreground">통계 카드</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<h3 className="text-foreground text-sm font-bold">통계 카드</h3>
|
||||
<div className="text-foreground space-y-1.5 text-xs">
|
||||
<p className="font-medium">📊 단일 통계 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• 데이터 소스에서 쿼리를 실행합니다</li>
|
||||
|
|
@ -256,7 +256,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
<li>• COUNT, SUM, AVG, MIN, MAX 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<div className="bg-primary/10 text-primary mt-2 rounded-lg p-2 text-[10px]">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p>1. 데이터 탭에서 쿼리 실행</p>
|
||||
<p>2. 필터 조건 추가 (선택사항)</p>
|
||||
|
|
@ -274,7 +274,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
// 통계 카드 렌더링 (전체 크기 꽉 차게)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
|
||||
<div className="bg-card flex h-full w-full flex-col items-center justify-center rounded-lg p-6 text-center">
|
||||
{/* 제목 */}
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||
|
||||
|
|
@ -283,11 +283,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* 필터 표시 (디버깅용, 작게) */}
|
||||
{config?.filters && config.filters.length > 0 && (
|
||||
<div className="text-muted-foreground mt-2 text-xs">필터: {config.filters.length}개 적용됨</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,23 +268,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
// 통계 카드 렌더링
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background p-4">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
|
||||
{/* 제목 */}
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-card p-6 text-center">
|
||||
{/* 제목 */}
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||
|
||||
{/* 값 */}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* 필터 표시 (디버깅용, 작게) */}
|
||||
{config?.filters && config.filters.length > 0 && (
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
필터: {config.filters.length}개 적용됨
|
||||
</div>
|
||||
)}
|
||||
{/* 값 */}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,8 +37,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
// // console.log("🧪 ListTestWidget 렌더링!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
// 다중 데이터 소스 우선
|
||||
const multiSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
if (multiSources && multiSources.length > 0) {
|
||||
return multiSources;
|
||||
}
|
||||
|
||||
// 단일 데이터 소스 fallback (배열로 변환)
|
||||
if (element?.dataSource) {
|
||||
return [element.dataSource];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources, element?.dataSource]);
|
||||
|
||||
// // console.log("📊 dataSources 확인:", {
|
||||
// hasDataSources: !!dataSources,
|
||||
|
|
@ -58,6 +69,27 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
cardColumns: 3,
|
||||
};
|
||||
|
||||
// visible 컬럼 설정 객체 배열 (field + label)
|
||||
const visibleColumnConfigs = useMemo(() => {
|
||||
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
|
||||
return config.columns.filter((col: any) => col.visible !== false);
|
||||
}
|
||||
return [];
|
||||
}, [config.columns]);
|
||||
|
||||
// 표시할 컬럼 필드명 (데이터 접근용)
|
||||
const displayColumns = useMemo(() => {
|
||||
if (!data?.columns) return [];
|
||||
|
||||
// 컬럼 설정이 있으면 field 사용
|
||||
if (visibleColumnConfigs.length > 0) {
|
||||
return visibleColumnConfigs.map((col: any) => col.field);
|
||||
}
|
||||
|
||||
// 자동 모드: 모든 컬럼 표시
|
||||
return data.columns;
|
||||
}, [data?.columns, visibleColumnConfigs]);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
|
|
@ -313,50 +345,66 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const paginatedRows = data?.rows.slice(startIndex, endIndex) || [];
|
||||
|
||||
// 테이블 뷰
|
||||
const renderTable = () => (
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
{config.showHeader && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{data?.columns.map((col) => (
|
||||
<TableHead key={col} className="whitespace-nowrap">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
|
||||
{data?.columns.map((col) => (
|
||||
<TableCell key={col} className="whitespace-nowrap">
|
||||
{String(row[col] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
const renderTable = () => {
|
||||
// 헤더명 가져오기 (label 우선, 없으면 field 그대로)
|
||||
const getHeaderLabel = (field: string) => {
|
||||
const colConfig = visibleColumnConfigs.find((col: any) => col.field === field);
|
||||
return colConfig?.label || field;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
{config.showHeader && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{displayColumns.map((field) => (
|
||||
<TableHead key={field} className="whitespace-nowrap">
|
||||
{getHeaderLabel(field)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
|
||||
{displayColumns.map((field) => (
|
||||
<TableCell key={field} className="whitespace-nowrap">
|
||||
{String(row[field] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 카드 뷰
|
||||
const renderCards = () => (
|
||||
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<Card key={idx} className="p-4">
|
||||
{data?.columns.map((col) => (
|
||||
<div key={col} className="mb-2">
|
||||
<span className="font-semibold">{col}: </span>
|
||||
<span>{String(row[col] ?? "")}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const renderCards = () => {
|
||||
// 헤더명 가져오기 (label 우선, 없으면 field 그대로)
|
||||
const getLabel = (field: string) => {
|
||||
const colConfig = visibleColumnConfigs.find((col: any) => col.field === field);
|
||||
return colConfig?.label || field;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<Card key={idx} className="p-4">
|
||||
{displayColumns.map((field) => (
|
||||
<div key={field} className="mb-2">
|
||||
<span className="font-semibold">{getLabel(field)}: </span>
|
||||
<span>{String(row[field] ?? "")}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-card shadow-sm">
|
||||
|
|
@ -396,7 +444,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
|
||||
) : !dataSources || dataSources.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터 소스를 연결해주세요
|
||||
|
|
|
|||
|
|
@ -916,9 +916,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 즉시 첫 로드 (마커 데이터)
|
||||
loadMultipleDataSources();
|
||||
|
||||
// 첫 번째 데이터 소스의 새로고침 간격 사용 (초)
|
||||
const firstDataSource = dataSources[0];
|
||||
const refreshInterval = firstDataSource?.refreshInterval ?? 5;
|
||||
// 위젯 레벨의 새로고침 간격 사용 (초)
|
||||
const refreshInterval = element?.chartConfig?.refreshInterval ?? 5;
|
||||
|
||||
// 0이면 자동 새로고침 비활성화
|
||||
if (refreshInterval === 0) {
|
||||
|
|
@ -933,7 +932,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
clearInterval(intervalId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSources]);
|
||||
}, [dataSources, element?.chartConfig?.refreshInterval]);
|
||||
|
||||
// 타일맵 URL (chartConfig에서 가져오기)
|
||||
const tileMapUrl =
|
||||
|
|
|
|||
|
|
@ -634,7 +634,26 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
)}
|
||||
<p className="text-[10px] text-foreground mt-0.5 line-clamp-2">{alert.description}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-[9px] text-muted-foreground">
|
||||
<span>{new Date(alert.timestamp).toLocaleString("ko-KR")}</span>
|
||||
<span>
|
||||
{(() => {
|
||||
const ts = String(alert.timestamp);
|
||||
|
||||
// yyyyMMddHHmm 형식 감지 (예: 20251114 1000)
|
||||
if (/^\d{12}$/.test(ts)) {
|
||||
const year = ts.substring(0, 4);
|
||||
const month = ts.substring(4, 6);
|
||||
const day = ts.substring(6, 8);
|
||||
const hour = ts.substring(8, 10);
|
||||
const minute = ts.substring(10, 12);
|
||||
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
|
||||
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
// ISO 형식 또는 일반 날짜 형식
|
||||
const date = new Date(ts);
|
||||
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
|
||||
})()}
|
||||
</span>
|
||||
{alert.source && <span>· {alert.source}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CustomerItemMappingConfig } from "./types";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CustomerItemMappingComponentProps {
|
||||
component: any;
|
||||
isDesignMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
isInteractive?: boolean;
|
||||
config?: CustomerItemMappingConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
export const CustomerItemMappingComponent: React.FC<CustomerItemMappingComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}) => {
|
||||
const finalConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as CustomerItemMappingConfig;
|
||||
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [isAllSelected, setIsAllSelected] = useState(false);
|
||||
|
||||
// 데이터 로드 (실제 구현 시 API 호출)
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && finalConfig.selectedTable) {
|
||||
// TODO: API 호출로 데이터 로드
|
||||
setData([]);
|
||||
}
|
||||
}, [finalConfig.selectedTable, isDesignMode]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = data.map((_, index) => `row-${index}`);
|
||||
setSelectedRows(new Set(allIds));
|
||||
setIsAllSelected(true);
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
setIsAllSelected(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowSelection = (rowId: string, checked: boolean) => {
|
||||
const newSelected = new Set(selectedRows);
|
||||
if (checked) {
|
||||
newSelected.add(rowId);
|
||||
} else {
|
||||
newSelected.delete(rowId);
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
setIsAllSelected(newSelected.size === data.length && data.length > 0);
|
||||
};
|
||||
|
||||
const columns = finalConfig.columns || [];
|
||||
const showCheckbox = finalConfig.checkbox?.enabled !== false;
|
||||
|
||||
// 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
overflow: "hidden",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full h-full", className)}
|
||||
style={componentStyle}
|
||||
onClick={handleClick}
|
||||
onDragStart={isDesignMode ? onDragStart : undefined}
|
||||
onDragEnd={isDesignMode ? onDragEnd : undefined}
|
||||
draggable={isDesignMode}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="w-full border-border bg-muted flex h-12 flex-shrink-0 items-center justify-between border-b px-4 sm:h-14 sm:px-6">
|
||||
<h3 className="text-sm font-semibold sm:text-base">
|
||||
품목 추가 - {finalConfig.selectedTable || "[테이블 선택]"}
|
||||
{finalConfig.showCompanyName && finalConfig.companyNameColumn && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal sm:text-sm">
|
||||
| {finalConfig.companyNameColumn}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button className="hover:bg-muted-foreground/10 rounded p-1">
|
||||
<X className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색/카테고리 영역 */}
|
||||
{finalConfig.showSearchArea && (
|
||||
<div className="w-full border-border bg-background flex-shrink-0 border-b p-3 sm:p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={finalConfig.searchPlaceholder || "품목코드, 품목명, 규격 검색"}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||||
disabled={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
{finalConfig.enableCategoryFilter && (
|
||||
<div className="w-full sm:w-auto sm:min-w-[160px]">
|
||||
<select
|
||||
className="border-input bg-background ring-offset-background focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||||
disabled={isDesignMode}
|
||||
>
|
||||
{(finalConfig.categories || ["전체"]).map((category, idx) => (
|
||||
<option key={idx} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목록 헤더 */}
|
||||
<div className="w-full border-border bg-muted/50 flex h-10 flex-shrink-0 items-center justify-between border-b px-4 sm:h-12 sm:px-6">
|
||||
<span className="text-xs font-semibold sm:text-sm">판매품목 목록</span>
|
||||
<div className="flex items-center gap-3 sm:gap-6">
|
||||
{showCheckbox && finalConfig.checkbox?.selectAll && (
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} />
|
||||
<span className="text-xs sm:text-sm">전체 선택</span>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs font-medium sm:text-sm">
|
||||
선택: {selectedRows.size}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 컨테이너 */}
|
||||
<div className="flex w-full flex-1 flex-col overflow-hidden">
|
||||
{/* 테이블 헤더 */}
|
||||
{columns.length > 0 && (
|
||||
<div className="border-border flex-shrink-0 border-b">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: "100%" }}>
|
||||
<thead>
|
||||
<tr className="bg-muted/30 h-10 sm:h-12">
|
||||
{showCheckbox && (
|
||||
<th className="border-border w-12 border-r px-2 text-center sm:w-16 sm:px-3"></th>
|
||||
)}
|
||||
{columns.map((col, index) => (
|
||||
<th
|
||||
key={col}
|
||||
className={cn(
|
||||
"border-border text-foreground px-3 text-left text-xs font-semibold sm:px-6 sm:text-sm",
|
||||
index < columns.length - 1 && "border-r"
|
||||
)}
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{data.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center sm:gap-4 sm:p-12">
|
||||
<div className="bg-muted/50 flex h-16 w-16 items-center justify-center rounded-full sm:h-20 sm:w-20">
|
||||
<svg
|
||||
className="text-muted-foreground h-8 w-8 sm:h-10 sm:w-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<p className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{finalConfig.emptyMessage || "데이터가 없습니다"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{finalConfig.emptyDescription || "품목 데이터가 추가되면 여기에 표시됩니다"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: "100%" }}>
|
||||
<tbody>
|
||||
{data.map((row, index) => (
|
||||
<tr key={index} className="hover:bg-muted/50 border-b transition-colors">
|
||||
{showCheckbox && (
|
||||
<td className="border-border w-12 border-r px-2 text-center sm:w-16 sm:px-3">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(`row-${index}`)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRowSelection(`row-${index}`, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col, colIndex) => (
|
||||
<td
|
||||
key={col}
|
||||
className={cn(
|
||||
"border-border px-3 py-2 text-xs sm:px-6 sm:py-3 sm:text-sm",
|
||||
colIndex < columns.length - 1 && "border-r"
|
||||
)}
|
||||
>
|
||||
{row[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CustomerItemMappingConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, X } from "lucide-react";
|
||||
|
||||
export interface CustomerItemMappingConfigPanelProps {
|
||||
config: CustomerItemMappingConfig;
|
||||
onChange: (config: CustomerItemMappingConfig) => void;
|
||||
onConfigChange?: (config: CustomerItemMappingConfig) => void;
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
tables?: any[];
|
||||
allTables?: any[];
|
||||
onTableChange?: (tableName: string) => void;
|
||||
menuObjid?: number;
|
||||
}
|
||||
|
||||
export const CustomerItemMappingConfigPanel: React.FC<
|
||||
CustomerItemMappingConfigPanelProps
|
||||
> = ({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
screenTableName,
|
||||
tableColumns: propTableColumns,
|
||||
tables: propTables,
|
||||
allTables,
|
||||
onTableChange: propOnTableChange,
|
||||
menuObjid,
|
||||
}) => {
|
||||
// onChange와 onConfigChange를 통합
|
||||
const handleChange = (newConfig: CustomerItemMappingConfig) => {
|
||||
onChange?.(newConfig);
|
||||
onConfigChange?.(newConfig);
|
||||
};
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
setTables(tableList);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (config.selectedTable) {
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(config.selectedTable!);
|
||||
setAvailableColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}
|
||||
}, [config.selectedTable]);
|
||||
|
||||
const handleTableChange = (tableName: string) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
selectedTable: tableName,
|
||||
columns: [], // 테이블 변경 시 컬럼 초기화
|
||||
};
|
||||
handleChange(newConfig);
|
||||
propOnTableChange?.(tableName);
|
||||
};
|
||||
|
||||
const handleAddColumn = (columnName: string) => {
|
||||
if (!config.columns.includes(columnName)) {
|
||||
handleChange({
|
||||
...config,
|
||||
columns: [...config.columns, columnName],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveColumn = (columnName: string) => {
|
||||
handleChange({
|
||||
...config,
|
||||
columns: config.columns.filter((col) => col !== columnName),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select value={config.selectedTable} onValueChange={handleTableChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label>표시할 컬럼</Label>
|
||||
<div className="space-y-2">
|
||||
{/* 선택된 컬럼 목록 */}
|
||||
{config.columns.length > 0 && (
|
||||
<div className="border-border space-y-1 rounded border p-2">
|
||||
{config.columns.map((col, index) => (
|
||||
<div
|
||||
key={col}
|
||||
className="bg-muted flex items-center justify-between rounded px-2 py-1"
|
||||
>
|
||||
<span className="text-sm">{col}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveColumn(col)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 추가 */}
|
||||
{availableColumns.length > 0 && (
|
||||
<Select onValueChange={handleAddColumn}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns
|
||||
.filter((col) => !config.columns.includes(col.columnName))
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크박스 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label>체크박스 옵션</Label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.checkbox?.enabled !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
checkbox: {
|
||||
...config.checkbox,
|
||||
enabled: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">체크박스 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.checkbox?.selectAll !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
checkbox: {
|
||||
...config.checkbox,
|
||||
selectAll: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">전체 선택 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.checkbox?.multiple !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
checkbox: {
|
||||
...config.checkbox,
|
||||
multiple: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">다중 선택 허용</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 헤더 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label>헤더 설정</Label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.showCompanyName === true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
showCompanyName: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium">회사명 표시</span>
|
||||
</label>
|
||||
|
||||
{config.showCompanyName && availableColumns.length > 0 && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<Label className="text-xs">회사명 컬럼</Label>
|
||||
<Select
|
||||
value={config.companyNameColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
...config,
|
||||
companyNameColumn: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
헤더에 표시할 회사명 데이터가 있는 컬럼을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label>검색/필터 영역</Label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.showSearchArea === true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
showSearchArea: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
검색/필터 영역 표시
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{config.showSearchArea && (
|
||||
<div className="space-y-3 pl-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">검색 플레이스홀더</Label>
|
||||
<Input
|
||||
value={config.searchPlaceholder || ""}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
...config,
|
||||
searchPlaceholder: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="품목코드, 품목명, 규격 검색"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.enableCategoryFilter === true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
enableCategoryFilter: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs font-medium">카테고리 필터 표시</span>
|
||||
</label>
|
||||
|
||||
{config.enableCategoryFilter && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<Label className="text-xs">카테고리 목록 (쉼표로 구분)</Label>
|
||||
<Input
|
||||
value={(config.categories || []).join(", ")}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
...config,
|
||||
categories: e.target.value.split(",").map((c) => c.trim()).filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="전체, 원자재, 반도체, 완제품"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
예: 전체, 원자재, 반도체, 완제품
|
||||
</p>
|
||||
|
||||
{availableColumns.length > 0 && (
|
||||
<>
|
||||
<Label className="text-xs">카테고리 데이터 컬럼</Label>
|
||||
<Select
|
||||
value={config.categoryColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
...config,
|
||||
categoryColumn: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 빈 데이터 메시지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>빈 데이터 메시지</Label>
|
||||
<Input
|
||||
value={config.emptyMessage || ""}
|
||||
onChange={(e) =>
|
||||
handleChange({ ...config, emptyMessage: e.target.value })
|
||||
}
|
||||
placeholder="데이터가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>빈 데이터 설명</Label>
|
||||
<Input
|
||||
value={config.emptyDescription || ""}
|
||||
onChange={(e) =>
|
||||
handleChange({ ...config, emptyDescription: e.target.value })
|
||||
}
|
||||
placeholder="품목 데이터가 추가되면 여기에 표시됩니다"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { CustomerItemMappingDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
ComponentRegistry.registerComponent(CustomerItemMappingDefinition);
|
||||
|
||||
console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
||||
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { CustomerItemMappingComponent } from "./CustomerItemMappingComponent";
|
||||
import { CustomerItemMappingConfigPanel } from "./CustomerItemMappingConfigPanel";
|
||||
import { CustomerItemMappingConfig } from "./types";
|
||||
|
||||
export const CustomerItemMappingDefinition = createComponentDefinition({
|
||||
id: "customer-item-mapping",
|
||||
name: "거래처별 품목정보",
|
||||
nameEng: "Customer Item Mapping",
|
||||
description: "거래처별 품목 정보를 표시하고 선택하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: CustomerItemMappingComponent,
|
||||
defaultConfig: {
|
||||
selectedTable: undefined,
|
||||
columns: [],
|
||||
checkbox: {
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
selectAll: true,
|
||||
},
|
||||
showSearchArea: true, // 기본적으로 검색 영역 표시
|
||||
searchAreaHeight: 80,
|
||||
searchPlaceholder: "품목코드, 품목명, 규격 검색",
|
||||
enableCategoryFilter: true, // 기본적으로 카테고리 필터 표시
|
||||
categoryColumn: undefined,
|
||||
categories: ["전체", "원자재", "반도체", "완제품"],
|
||||
showCompanyName: false,
|
||||
companyNameColumn: undefined,
|
||||
emptyMessage: "데이터가 없습니다",
|
||||
emptyDescription: "품목 데이터가 추가되면 여기에 표시됩니다",
|
||||
} as CustomerItemMappingConfig,
|
||||
defaultSize: { width: 800, height: 600 },
|
||||
configPanel: CustomerItemMappingConfigPanel,
|
||||
icon: "Package",
|
||||
tags: ["거래처", "품목", "매핑", "목록"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
export type { CustomerItemMappingConfig } from "./types";
|
||||
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
export interface CustomerItemMappingConfig {
|
||||
// 테이블 설정
|
||||
selectedTable?: string;
|
||||
|
||||
// 컬럼 설정
|
||||
columns: string[]; // 표시할 컬럼 목록
|
||||
|
||||
// 체크박스 설정
|
||||
checkbox: {
|
||||
enabled: boolean;
|
||||
multiple: boolean;
|
||||
selectAll: boolean;
|
||||
};
|
||||
|
||||
// 검색/필터 영역
|
||||
showSearchArea?: boolean;
|
||||
searchAreaHeight?: number;
|
||||
searchPlaceholder?: string; // 검색 플레이스홀더
|
||||
|
||||
// 카테고리 필터
|
||||
enableCategoryFilter?: boolean; // 카테고리 필터 활성화
|
||||
categoryColumn?: string; // 카테고리 데이터 컬럼명
|
||||
categories?: string[]; // 카테고리 목록 (예: ["전체", "원자재", "반도체", "완제품"])
|
||||
|
||||
// 헤더 설정
|
||||
showCompanyName?: boolean; // 회사명 표시 여부
|
||||
companyNameColumn?: string; // 회사명을 가져올 컬럼명
|
||||
|
||||
// 빈 데이터 메시지
|
||||
emptyMessage?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +43,7 @@ import "./flow-widget/FlowWidgetRenderer";
|
|||
import "./numbering-rule/NumberingRuleRenderer";
|
||||
import "./category-manager/CategoryManagerRenderer";
|
||||
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
|
||||
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
|
||||
|
||||
// 🆕 수주 등록 관련 컴포넌트들
|
||||
import { AutocompleteSearchInputRenderer } from "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
: componentConfig.textAlign === "right"
|
||||
? "flex-end"
|
||||
: "flex-start",
|
||||
wordBreak: "break-word",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap", // ← 한 줄로 유지 // ← 넘치는 부분 숨김
|
||||
textOverflow: "ellipsis", // ← 넘치면 ... 표시 (선택사항)
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "none",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,12 +27,15 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
|||
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
|
||||
"flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"),
|
||||
// 🆕 수주 등록 관련 컴포넌트들
|
||||
"autocomplete-search-input": () => import("@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel"),
|
||||
"autocomplete-search-input": () =>
|
||||
import("@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel"),
|
||||
"entity-search-input": () => import("@/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel"),
|
||||
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
|
||||
"order-registration-modal": () => import("@/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel"),
|
||||
"order-registration-modal": () =>
|
||||
import("@/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel"),
|
||||
// 🆕 조건부 컨테이너
|
||||
"conditional-container": () => import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
||||
"conditional-container": () =>
|
||||
import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
|
|
@ -62,6 +65,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
|||
module[`${toPascalCase(componentId)}ConfigPanel`] ||
|
||||
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
||||
module.FlowWidgetConfigPanel || // flow-widget의 export명
|
||||
module.CustomerItemMappingConfigPanel || // customer-item-mapping의 export명
|
||||
module.default;
|
||||
|
||||
if (!ConfigPanelComponent) {
|
||||
|
|
@ -261,19 +265,14 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
|
||||
const isSimpleConfigPanel = [
|
||||
"autocomplete-search-input",
|
||||
"entity-search-input",
|
||||
"entity-search-input",
|
||||
"modal-repeater-table",
|
||||
"order-registration-modal",
|
||||
"conditional-container"
|
||||
"conditional-container",
|
||||
].includes(componentId);
|
||||
|
||||
if (isSimpleConfigPanel) {
|
||||
return (
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onConfigChange={onChange}
|
||||
/>
|
||||
);
|
||||
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in New Issue