Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
ad76bfe3b0
|
|
@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 소스 테이블 조회
|
||||||
|
* GET /api/dataflow/node-flows/:flowId/source-table
|
||||||
|
* 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
|
||||||
|
*/
|
||||||
|
router.get("/:flowId/source-table", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
|
||||||
|
const flow = await queryOne<{ flow_data: any }>(
|
||||||
|
`SELECT flow_data FROM node_flows WHERE flow_id = $1`,
|
||||||
|
[flowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flow) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowData =
|
||||||
|
typeof flow.flow_data === "string"
|
||||||
|
? JSON.parse(flow.flow_data)
|
||||||
|
: flow.flow_data;
|
||||||
|
|
||||||
|
const nodes = flowData.nodes || [];
|
||||||
|
|
||||||
|
// 소스 노드 찾기 (tableSource, externalDBSource 타입)
|
||||||
|
const sourceNode = nodes.find(
|
||||||
|
(node: any) =>
|
||||||
|
node.type === "tableSource" || node.type === "externalDBSource"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sourceNode || !sourceNode.data?.tableName) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceTable: null,
|
||||||
|
sourceNodeType: null,
|
||||||
|
message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceTable: sourceNode.data.tableName,
|
||||||
|
sourceNodeType: sourceNode.type,
|
||||||
|
sourceNodeId: sourceNode.id,
|
||||||
|
displayName: sourceNode.data.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 소스 테이블 조회 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우 소스 테이블을 조회하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 실행
|
* 플로우 실행
|
||||||
* POST /api/dataflow/node-flows/:flowId/execute
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
|
|
|
||||||
|
|
@ -51,17 +51,17 @@ export default function DataFlowPage() {
|
||||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||||
if (isEditorMode) {
|
if (isEditorMode) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
<div className="bg-background fixed inset-0 z-50">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 에디터 헤더 */}
|
{/* 에디터 헤더 */}
|
||||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
<div className="bg-background flex items-center gap-4 border-b p-4">
|
||||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
목록으로
|
목록으로
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,12 +77,12 @@ export default function DataFlowPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">제어 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">제어 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
<p className="text-muted-foreground text-sm">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 목록 */}
|
{/* 플로우 목록 */}
|
||||||
|
|
|
||||||
|
|
@ -370,32 +370,13 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모드 전환 핸들러
|
// 모드 전환 핸들러
|
||||||
const handleModeSwitch = async () => {
|
const handleModeSwitch = () => {
|
||||||
if (isAdminMode) {
|
if (isAdminMode) {
|
||||||
// 관리자 → 사용자 모드: 선택한 회사 유지
|
// 관리자 → 사용자 모드: 선택한 회사 유지
|
||||||
router.push("/main");
|
router.push("/main");
|
||||||
} else {
|
} else {
|
||||||
// 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만)
|
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
|
||||||
if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
|
router.push("/admin");
|
||||||
const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode;
|
|
||||||
|
|
||||||
// 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동
|
|
||||||
if (currentCompanyCode !== "*") {
|
|
||||||
const result = await switchCompany("*");
|
|
||||||
if (result.success) {
|
|
||||||
// 페이지 새로고침 (관리자 페이지로 이동)
|
|
||||||
window.location.href = "/admin";
|
|
||||||
} else {
|
|
||||||
toast.error("WACE로 전환 실패");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 이미 WACE면 바로 관리자 페이지로 이동
|
|
||||||
router.push("/admin");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 일반 관리자는 바로 관리자 페이지로 이동
|
|
||||||
router.push("/admin");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,3 +120,41 @@ export interface NodeExecutionSummary {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 소스 테이블 정보 인터페이스
|
||||||
|
*/
|
||||||
|
export interface FlowSourceTableInfo {
|
||||||
|
sourceTable: string | null;
|
||||||
|
sourceNodeType: string | null;
|
||||||
|
sourceNodeId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 소스 테이블 조회
|
||||||
|
* 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
|
||||||
|
*/
|
||||||
|
export async function getFlowSourceTable(flowId: number): Promise<FlowSourceTableInfo> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<FlowSourceTableInfo>>(
|
||||||
|
`/dataflow/node-flows/${flowId}/source-table`,
|
||||||
|
);
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceTable: null,
|
||||||
|
sourceNodeType: null,
|
||||||
|
message: response.data.message || "소스 테이블 정보를 가져올 수 없습니다.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("플로우 소스 테이블 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
sourceTable: null,
|
||||||
|
sourceNodeType: null,
|
||||||
|
message: "API 호출 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
@ -84,6 +84,9 @@ export function RepeaterTable({
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
equalizeWidthsTrigger,
|
equalizeWidthsTrigger,
|
||||||
}: RepeaterTableProps) {
|
}: RepeaterTableProps) {
|
||||||
|
// 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
|
||||||
|
const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
|
||||||
|
|
||||||
// 컨테이너 ref - 실제 너비 측정용
|
// 컨테이너 ref - 실제 너비 측정용
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -145,7 +148,7 @@ export function RepeaterTable({
|
||||||
// 컬럼 너비 상태 관리
|
// 컬럼 너비 상태 관리
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
columns.filter((col) => !col.hidden).forEach((col) => {
|
||||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||||
});
|
});
|
||||||
return widths;
|
return widths;
|
||||||
|
|
@ -154,11 +157,11 @@ export function RepeaterTable({
|
||||||
// 기본 너비 저장 (리셋용)
|
// 기본 너비 저장 (리셋용)
|
||||||
const defaultWidths = React.useMemo(() => {
|
const defaultWidths = React.useMemo(() => {
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||||
});
|
});
|
||||||
return widths;
|
return widths;
|
||||||
}, [columns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// 리사이즈 상태
|
// 리사이즈 상태
|
||||||
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
||||||
|
|
@ -206,7 +209,7 @@ export function RepeaterTable({
|
||||||
// 해당 컬럼의 가장 긴 글자 너비 계산
|
// 해당 컬럼의 가장 긴 글자 너비 계산
|
||||||
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
|
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
|
||||||
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
|
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
|
||||||
const column = columns.find((col) => col.field === field);
|
const column = visibleColumns.find((col) => col.field === field);
|
||||||
if (!column) return equalWidth;
|
if (!column) return equalWidth;
|
||||||
|
|
||||||
// 날짜 필드는 110px (yyyy-MM-dd)
|
// 날짜 필드는 110px (yyyy-MM-dd)
|
||||||
|
|
@ -257,7 +260,7 @@ export function RepeaterTable({
|
||||||
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
|
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
|
||||||
const handleDoubleClick = (field: string) => {
|
const handleDoubleClick = (field: string) => {
|
||||||
const availableWidth = getAvailableWidth();
|
const availableWidth = getAvailableWidth();
|
||||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
||||||
const contentWidth = calculateColumnContentWidth(field, equalWidth);
|
const contentWidth = calculateColumnContentWidth(field, equalWidth);
|
||||||
setColumnWidths((prev) => ({
|
setColumnWidths((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -268,10 +271,10 @@ export function RepeaterTable({
|
||||||
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
|
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
|
||||||
const applyEqualizeWidths = () => {
|
const applyEqualizeWidths = () => {
|
||||||
const availableWidth = getAvailableWidth();
|
const availableWidth = getAvailableWidth();
|
||||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
||||||
|
|
||||||
const newWidths: Record<string, number> = {};
|
const newWidths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
newWidths[col.field] = equalWidth;
|
newWidths[col.field] = equalWidth;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -280,15 +283,15 @@ export function RepeaterTable({
|
||||||
|
|
||||||
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
|
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
|
||||||
const applyAutoFitWidths = () => {
|
const applyAutoFitWidths = () => {
|
||||||
if (columns.length === 0) return;
|
if (visibleColumns.length === 0) return;
|
||||||
|
|
||||||
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
|
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
|
||||||
const availableWidth = getAvailableWidth();
|
const availableWidth = getAvailableWidth();
|
||||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
||||||
|
|
||||||
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
|
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
|
||||||
const newWidths: Record<string, number> = {};
|
const newWidths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
|
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -298,8 +301,8 @@ export function RepeaterTable({
|
||||||
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
|
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
|
||||||
if (totalContentWidth < availableWidth) {
|
if (totalContentWidth < availableWidth) {
|
||||||
const extraSpace = availableWidth - totalContentWidth;
|
const extraSpace = availableWidth - totalContentWidth;
|
||||||
const extraPerColumn = Math.floor(extraSpace / columns.length);
|
const extraPerColumn = Math.floor(extraSpace / visibleColumns.length);
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
newWidths[col.field] += extraPerColumn;
|
newWidths[col.field] += extraPerColumn;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +314,7 @@ export function RepeaterTable({
|
||||||
// 초기 마운트 시 균등 분배 적용
|
// 초기 마운트 시 균등 분배 적용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initializedRef.current) return;
|
if (initializedRef.current) return;
|
||||||
if (!containerRef.current || columns.length === 0) return;
|
if (!containerRef.current || visibleColumns.length === 0) return;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
applyEqualizeWidths();
|
applyEqualizeWidths();
|
||||||
|
|
@ -319,7 +322,7 @@ export function RepeaterTable({
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [columns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// 트리거 감지: 1=균등분배, 2=자동맞춤
|
// 트리거 감지: 1=균등분배, 2=자동맞춤
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,7 +360,7 @@ export function RepeaterTable({
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [resizing, columns, data]);
|
}, [resizing, visibleColumns, data]);
|
||||||
|
|
||||||
// 데이터 변경 감지 (필요시 활성화)
|
// 데이터 변경 감지 (필요시 활성화)
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
|
@ -531,7 +534,7 @@ export function RepeaterTable({
|
||||||
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
|
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
{columns.map((col) => {
|
{visibleColumns.map((col) => {
|
||||||
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||||
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
||||||
const activeOption = hasDynamicSource
|
const activeOption = hasDynamicSource
|
||||||
|
|
@ -631,7 +634,7 @@ export function RepeaterTable({
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length + 2}
|
colSpan={visibleColumns.length + 2}
|
||||||
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
|
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
추가된 항목이 없습니다
|
추가된 항목이 없습니다
|
||||||
|
|
@ -672,7 +675,7 @@ export function RepeaterTable({
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{/* 데이터 컬럼들 */}
|
{/* 데이터 컬럼들 */}
|
||||||
{columns.map((col) => (
|
{visibleColumns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col.field}
|
key={col.field}
|
||||||
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
|
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export interface RepeaterColumnConfig {
|
||||||
calculated?: boolean; // 계산 필드 여부
|
calculated?: boolean; // 계산 필드 여부
|
||||||
width?: string; // 컬럼 너비
|
width?: string; // 컬럼 너비
|
||||||
required?: boolean; // 필수 입력 여부
|
required?: boolean; // 필수 입력 여부
|
||||||
|
hidden?: boolean; // 히든 필드 여부 (UI에서 숨기지만 데이터는 유지)
|
||||||
defaultValue?: string | number | boolean; // 기본값
|
defaultValue?: string | number | boolean; // 기본값
|
||||||
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,13 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
|
||||||
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
|
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
|
||||||
|
|
||||||
// 타입 정의
|
// 타입 정의
|
||||||
import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types";
|
import {
|
||||||
|
TableSectionConfig,
|
||||||
|
TableColumnConfig,
|
||||||
|
TableJoinCondition,
|
||||||
|
FormDataState,
|
||||||
|
TableCalculationRule,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
interface TableSectionRendererProps {
|
interface TableSectionRendererProps {
|
||||||
sectionId: string;
|
sectionId: string;
|
||||||
|
|
@ -47,6 +53,7 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
|
||||||
calculated: col.calculated ?? false,
|
calculated: col.calculated ?? false,
|
||||||
width: col.width || "150px",
|
width: col.width || "150px",
|
||||||
required: col.required,
|
required: col.required,
|
||||||
|
hidden: col.hidden ?? false,
|
||||||
defaultValue: col.defaultValue,
|
defaultValue: col.defaultValue,
|
||||||
selectOptions: col.selectOptions,
|
selectOptions: col.selectOptions,
|
||||||
// valueMapping은 별도로 처리
|
// valueMapping은 별도로 처리
|
||||||
|
|
@ -811,39 +818,69 @@ export function TableSectionRenderer({
|
||||||
});
|
});
|
||||||
}, [tableConfig.columns, dynamicSelectOptionsMap]);
|
}, [tableConfig.columns, dynamicSelectOptionsMap]);
|
||||||
|
|
||||||
// 계산 규칙 변환
|
// 원본 계산 규칙 (조건부 계산 포함)
|
||||||
const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
|
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
||||||
|
() => tableConfig.calculations || [],
|
||||||
|
[tableConfig.calculations],
|
||||||
|
);
|
||||||
|
|
||||||
// 계산 로직
|
// 기본 계산 규칙 변환 (RepeaterTable용 - 조건부 계산이 없는 경우에 사용)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const calculationRules: CalculationRule[] = originalCalculationRules.map(convertToCalculationRule);
|
||||||
|
|
||||||
|
// 조건부 계산 로직: 행의 조건 필드 값에 따라 적절한 계산식 선택
|
||||||
|
const getFormulaForRow = useCallback((rule: TableCalculationRule, row: Record<string, unknown>): string => {
|
||||||
|
// 조건부 계산이 활성화된 경우
|
||||||
|
if (rule.conditionalCalculation?.enabled && rule.conditionalCalculation.conditionField) {
|
||||||
|
const conditionValue = row[rule.conditionalCalculation.conditionField];
|
||||||
|
// 조건값과 일치하는 규칙 찾기
|
||||||
|
const matchedRule = rule.conditionalCalculation.rules?.find((r) => r.conditionValue === conditionValue);
|
||||||
|
if (matchedRule) {
|
||||||
|
return matchedRule.formula;
|
||||||
|
}
|
||||||
|
// 일치하는 규칙이 없으면 기본 계산식 사용
|
||||||
|
if (rule.conditionalCalculation.defaultFormula) {
|
||||||
|
return rule.conditionalCalculation.defaultFormula;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 조건부 계산이 비활성화되었거나 기본값이 없으면 원래 계산식 사용
|
||||||
|
return rule.formula;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 계산 로직 (조건부 계산 지원)
|
||||||
const calculateRow = useCallback(
|
const calculateRow = useCallback(
|
||||||
(row: any): any => {
|
(row: any): any => {
|
||||||
if (calculationRules.length === 0) return row;
|
if (originalCalculationRules.length === 0) return row;
|
||||||
|
|
||||||
const updatedRow = { ...row };
|
const updatedRow = { ...row };
|
||||||
|
|
||||||
for (const rule of calculationRules) {
|
for (const rule of originalCalculationRules) {
|
||||||
try {
|
try {
|
||||||
let formula = rule.formula;
|
// 조건부 계산에 따라 적절한 계산식 선택
|
||||||
|
let formula = getFormulaForRow(rule, row);
|
||||||
|
|
||||||
|
if (!formula) continue;
|
||||||
|
|
||||||
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
||||||
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
|
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
|
||||||
|
|
||||||
for (const dep of dependencies) {
|
for (const dep of dependencies) {
|
||||||
if (dep === rule.result) continue;
|
if (dep === rule.resultField) continue;
|
||||||
const value = parseFloat(row[dep]) || 0;
|
const value = parseFloat(row[dep]) || 0;
|
||||||
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = new Function(`return ${formula}`)();
|
const result = new Function(`return ${formula}`)();
|
||||||
updatedRow[rule.result] = result;
|
updatedRow[rule.resultField] = result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`계산 오류 (${rule.formula}):`, error);
|
console.error(`계산 오류 (${rule.formula}):`, error);
|
||||||
updatedRow[rule.result] = 0;
|
updatedRow[rule.resultField] = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedRow;
|
return updatedRow;
|
||||||
},
|
},
|
||||||
[calculationRules],
|
[originalCalculationRules, getFormulaForRow],
|
||||||
);
|
);
|
||||||
|
|
||||||
const calculateAll = useCallback(
|
const calculateAll = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
TablePreFilter,
|
TablePreFilter,
|
||||||
TableModalFilter,
|
TableModalFilter,
|
||||||
TableCalculationRule,
|
TableCalculationRule,
|
||||||
|
ConditionalCalculationRule,
|
||||||
|
ConditionalCalculationConfig,
|
||||||
LookupOption,
|
LookupOption,
|
||||||
LookupCondition,
|
LookupCondition,
|
||||||
ConditionalTableOption,
|
ConditionalTableOption,
|
||||||
|
|
@ -52,6 +54,414 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 계산 규칙 편집 컴포넌트 (조건부 계산 지원)
|
||||||
|
interface CalculationRuleEditorProps {
|
||||||
|
calc: TableCalculationRule;
|
||||||
|
index: number;
|
||||||
|
columns: TableColumnConfig[];
|
||||||
|
sourceTableName?: string; // 소스 테이블명 추가
|
||||||
|
onUpdate: (updates: Partial<TableCalculationRule>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalculationRuleEditor: React.FC<CalculationRuleEditorProps> = ({
|
||||||
|
calc,
|
||||||
|
index,
|
||||||
|
columns,
|
||||||
|
sourceTableName,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
}) => {
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
|
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||||
|
const [categoryColumns, setCategoryColumns] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 조건부 계산 활성화 여부
|
||||||
|
const isConditionalEnabled = calc.conditionalCalculation?.enabled ?? false;
|
||||||
|
|
||||||
|
// 소스 테이블의 카테고리 컬럼 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCategoryColumns = async () => {
|
||||||
|
if (!sourceTableName) {
|
||||||
|
setCategoryColumns({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue");
|
||||||
|
const result = await getCategoryColumns(sourceTableName);
|
||||||
|
|
||||||
|
if (result && result.success && Array.isArray(result.data)) {
|
||||||
|
const categoryMap: Record<string, boolean> = {};
|
||||||
|
result.data.forEach((col: any) => {
|
||||||
|
// API 응답은 camelCase (columnName)
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
if (colName) {
|
||||||
|
categoryMap[colName] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setCategoryColumns(categoryMap);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 컬럼 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCategoryColumns();
|
||||||
|
}, [sourceTableName]);
|
||||||
|
|
||||||
|
// 조건 필드가 선택되었을 때 옵션 로드 (테이블 타입 관리의 카테고리 기준)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConditionOptions = async () => {
|
||||||
|
if (!isConditionalEnabled || !calc.conditionalCalculation?.conditionField) {
|
||||||
|
setCategoryOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionField = calc.conditionalCalculation.conditionField;
|
||||||
|
|
||||||
|
// 소스 필드(sourceField)가 있으면 해당 필드명 사용, 없으면 field명 사용
|
||||||
|
const selectedColumn = columns.find((col) => col.field === conditionField);
|
||||||
|
const actualFieldName = selectedColumn?.sourceField || conditionField;
|
||||||
|
|
||||||
|
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
|
||||||
|
if (sourceTableName && categoryColumns[actualFieldName]) {
|
||||||
|
try {
|
||||||
|
setLoadingOptions(true);
|
||||||
|
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||||
|
const result = await getCategoryValues(sourceTableName, actualFieldName, false);
|
||||||
|
if (result && result.success && Array.isArray(result.data)) {
|
||||||
|
const options = result.data.map((item: any) => ({
|
||||||
|
// API 응답은 camelCase (valueCode, valueLabel)
|
||||||
|
value: item.valueCode || item.value_code || item.value,
|
||||||
|
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
|
||||||
|
}));
|
||||||
|
setCategoryOptions(options);
|
||||||
|
} else {
|
||||||
|
setCategoryOptions([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 값 로드 실패:", error);
|
||||||
|
setCategoryOptions([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingOptions(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 키가 직접 설정된 경우 (저장된 값)
|
||||||
|
const categoryKey = calc.conditionalCalculation?.conditionFieldCategoryKey;
|
||||||
|
if (categoryKey) {
|
||||||
|
try {
|
||||||
|
setLoadingOptions(true);
|
||||||
|
const [tableName, columnName] = categoryKey.split(".");
|
||||||
|
if (tableName && columnName) {
|
||||||
|
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||||
|
const result = await getCategoryValues(tableName, columnName, false);
|
||||||
|
if (result && result.success && Array.isArray(result.data)) {
|
||||||
|
setCategoryOptions(
|
||||||
|
result.data.map((item: any) => ({
|
||||||
|
// API 응답은 camelCase (valueCode, valueLabel)
|
||||||
|
value: item.valueCode || item.value_code || item.value,
|
||||||
|
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 옵션 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingOptions(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외 타입은 옵션 없음 (직접 입력)
|
||||||
|
setCategoryOptions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadConditionOptions();
|
||||||
|
}, [isConditionalEnabled, calc.conditionalCalculation?.conditionField, calc.conditionalCalculation?.conditionFieldCategoryKey, columns, sourceTableName, categoryColumns]);
|
||||||
|
|
||||||
|
// 조건부 계산 토글
|
||||||
|
const toggleConditionalCalculation = (enabled: boolean) => {
|
||||||
|
onUpdate({
|
||||||
|
conditionalCalculation: enabled
|
||||||
|
? {
|
||||||
|
enabled: true,
|
||||||
|
conditionField: "",
|
||||||
|
conditionFieldType: "static",
|
||||||
|
rules: [],
|
||||||
|
defaultFormula: calc.formula || "",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조건 필드 변경
|
||||||
|
const updateConditionField = (field: string) => {
|
||||||
|
const selectedColumn = columns.find((col) => col.field === field);
|
||||||
|
const actualFieldName = selectedColumn?.sourceField || field;
|
||||||
|
|
||||||
|
// 컬럼의 타입과 옵션 확인 (테이블 타입 관리의 카테고리 기준)
|
||||||
|
let conditionFieldType: "static" | "code" | "table" = "static";
|
||||||
|
let conditionFieldCategoryKey = "";
|
||||||
|
|
||||||
|
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
|
||||||
|
if (sourceTableName && categoryColumns[actualFieldName]) {
|
||||||
|
conditionFieldType = "code";
|
||||||
|
conditionFieldCategoryKey = `${sourceTableName}.${actualFieldName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate({
|
||||||
|
conditionalCalculation: {
|
||||||
|
...calc.conditionalCalculation!,
|
||||||
|
conditionField: field,
|
||||||
|
conditionFieldType,
|
||||||
|
conditionFieldCategoryKey,
|
||||||
|
rules: [], // 필드 변경 시 규칙 초기화
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조건 규칙 추가
|
||||||
|
const addConditionRule = () => {
|
||||||
|
const newRule: ConditionalCalculationRule = {
|
||||||
|
conditionValue: "",
|
||||||
|
formula: calc.formula || "",
|
||||||
|
};
|
||||||
|
onUpdate({
|
||||||
|
conditionalCalculation: {
|
||||||
|
...calc.conditionalCalculation!,
|
||||||
|
rules: [...(calc.conditionalCalculation?.rules || []), newRule],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조건 규칙 업데이트
|
||||||
|
const updateConditionRule = (ruleIndex: number, updates: Partial<ConditionalCalculationRule>) => {
|
||||||
|
const newRules = [...(calc.conditionalCalculation?.rules || [])];
|
||||||
|
newRules[ruleIndex] = { ...newRules[ruleIndex], ...updates };
|
||||||
|
onUpdate({
|
||||||
|
conditionalCalculation: {
|
||||||
|
...calc.conditionalCalculation!,
|
||||||
|
rules: newRules,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조건 규칙 삭제
|
||||||
|
const removeConditionRule = (ruleIndex: number) => {
|
||||||
|
onUpdate({
|
||||||
|
conditionalCalculation: {
|
||||||
|
...calc.conditionalCalculation!,
|
||||||
|
rules: (calc.conditionalCalculation?.rules || []).filter((_, i) => i !== ruleIndex),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 계산식 업데이트
|
||||||
|
const updateDefaultFormula = (formula: string) => {
|
||||||
|
onUpdate({
|
||||||
|
conditionalCalculation: {
|
||||||
|
...calc.conditionalCalculation!,
|
||||||
|
defaultFormula: formula,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조건 필드로 사용 가능한 컬럼 (모든 컬럼)
|
||||||
|
const availableColumns = columns.filter((col) => col.field);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-3 bg-muted/30 space-y-3">
|
||||||
|
{/* 기본 계산 규칙 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={calc.resultField || ""}
|
||||||
|
onValueChange={(value) => onUpdate({ resultField: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-[150px]">
|
||||||
|
<SelectValue placeholder="결과 필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.length === 0 ? (
|
||||||
|
<SelectItem value="__no_columns__" disabled>
|
||||||
|
컬럼 설정에서 먼저 컬럼을 추가하세요
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
columns
|
||||||
|
.filter((col) => col.field)
|
||||||
|
.map((col, idx) => (
|
||||||
|
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
|
||||||
|
{col.label || col.field}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-muted-foreground">=</span>
|
||||||
|
<Input
|
||||||
|
value={calc.formula}
|
||||||
|
onChange={(e) => onUpdate({ formula: e.target.value })}
|
||||||
|
placeholder="수식 (예: qty * unit_price)"
|
||||||
|
className="h-8 text-xs flex-1"
|
||||||
|
disabled={isConditionalEnabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건부 계산 토글 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id={`conditional-calc-${index}`}
|
||||||
|
checked={isConditionalEnabled}
|
||||||
|
onCheckedChange={toggleConditionalCalculation}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`conditional-calc-${index}`} className="text-xs cursor-pointer">
|
||||||
|
조건부 계산 활성화
|
||||||
|
</Label>
|
||||||
|
{availableColumns.length === 0 && !isConditionalEnabled && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-2">
|
||||||
|
(컬럼 설정에서 먼저 컬럼을 추가하세요)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건부 계산 설정 */}
|
||||||
|
{isConditionalEnabled && (
|
||||||
|
<div className="border-t pt-3 space-y-3">
|
||||||
|
{/* 조건 필드 선택 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs w-[80px] shrink-0">조건 필드:</Label>
|
||||||
|
<Select
|
||||||
|
value={calc.conditionalCalculation?.conditionField || ""}
|
||||||
|
onValueChange={updateConditionField}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs flex-1">
|
||||||
|
<SelectValue placeholder="조건 기준 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.length === 0 ? (
|
||||||
|
<SelectItem value="__no_columns__" disabled>
|
||||||
|
컬럼이 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
availableColumns.map((col, idx) => {
|
||||||
|
// 소스 필드명으로 카테고리 여부 확인
|
||||||
|
const actualFieldName = col.sourceField || col.field;
|
||||||
|
const isCategoryColumn = categoryColumns[actualFieldName];
|
||||||
|
return (
|
||||||
|
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
|
||||||
|
{col.label || col.field} {isCategoryColumn ? "(카테고리)" : `(${col.type})`}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건별 계산식 목록 */}
|
||||||
|
{calc.conditionalCalculation?.conditionField && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">조건별 계산식:</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addConditionRule}
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
조건 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(calc.conditionalCalculation?.rules || []).map((rule, ruleIndex) => (
|
||||||
|
<div key={ruleIndex} className="flex items-center gap-2 bg-background rounded p-2">
|
||||||
|
{/* 조건값 선택 */}
|
||||||
|
{categoryOptions.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={rule.conditionValue}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateConditionRule(ruleIndex, { conditionValue: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs w-[120px]">
|
||||||
|
<SelectValue placeholder="조건값" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categoryOptions.map((opt, optIdx) => (
|
||||||
|
<SelectItem key={`${opt.value}_${optIdx}`} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={rule.conditionValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConditionRule(ruleIndex, { conditionValue: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="조건값"
|
||||||
|
className="h-7 text-xs w-[120px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<Input
|
||||||
|
value={rule.formula}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConditionRule(ruleIndex, { formula: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="계산식"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeConditionRule(ruleIndex)}
|
||||||
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 기본 계산식 */}
|
||||||
|
<div className="flex items-center gap-2 bg-background/50 rounded p-2 border-dashed border">
|
||||||
|
<span className="text-xs text-muted-foreground w-[120px] text-center">
|
||||||
|
(기본값)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<Input
|
||||||
|
value={calc.conditionalCalculation?.defaultFormula || ""}
|
||||||
|
onChange={(e) => updateDefaultFormula(e.target.value)}
|
||||||
|
placeholder="기본 계산식 (조건 미해당 시)"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingOptions && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">옵션 로딩 중...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
|
// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
|
||||||
interface OptionSourceConfigProps {
|
interface OptionSourceConfigProps {
|
||||||
optionSource: {
|
optionSource: {
|
||||||
|
|
@ -669,6 +1079,14 @@ function ColumnSettingItem({
|
||||||
/>
|
/>
|
||||||
<span>필수</span>
|
<span>필수</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer" title="UI에서 숨기지만 데이터는 유지됩니다">
|
||||||
|
<Switch
|
||||||
|
checked={col.hidden ?? false}
|
||||||
|
onCheckedChange={(checked) => onUpdate({ hidden: checked })}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
<span>히든</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer" title="부모 화면에서 전달받은 값을 모든 행에 적용">
|
<label className="flex items-center gap-2 text-xs cursor-pointer" title="부모 화면에서 전달받은 값을 모든 행에 적용">
|
||||||
<Switch
|
<Switch
|
||||||
checked={col.receiveFromParent ?? false}
|
checked={col.receiveFromParent ?? false}
|
||||||
|
|
@ -3034,46 +3452,15 @@ export function TableSectionSettingsModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(tableConfig.calculations || []).map((calc, index) => (
|
{(tableConfig.calculations || []).map((calc, index) => (
|
||||||
<div key={index} className="flex items-center gap-2 border rounded-lg p-2 bg-muted/30">
|
<CalculationRuleEditor
|
||||||
<Select
|
key={index}
|
||||||
value={calc.resultField || ""}
|
calc={calc}
|
||||||
onValueChange={(value) => updateCalculation(index, { resultField: value })}
|
index={index}
|
||||||
>
|
columns={tableConfig.columns || []}
|
||||||
<SelectTrigger className="h-8 text-xs w-[150px]">
|
sourceTableName={tableConfig.source?.tableName}
|
||||||
<SelectValue placeholder="결과 필드 선택" />
|
onUpdate={(updates) => updateCalculation(index, updates)}
|
||||||
</SelectTrigger>
|
onRemove={() => removeCalculation(index)}
|
||||||
<SelectContent>
|
/>
|
||||||
{(tableConfig.columns || []).length === 0 ? (
|
|
||||||
<SelectItem value="__no_columns__" disabled>
|
|
||||||
컬럼 설정에서 먼저 컬럼을 추가하세요
|
|
||||||
</SelectItem>
|
|
||||||
) : (
|
|
||||||
(tableConfig.columns || [])
|
|
||||||
.filter((col) => col.field) // 빈 필드명 제외
|
|
||||||
.map((col, idx) => (
|
|
||||||
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
|
|
||||||
{col.label || col.field}
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<span className="text-xs text-muted-foreground">=</span>
|
|
||||||
<Input
|
|
||||||
value={calc.formula}
|
|
||||||
onChange={(e) => updateCalculation(index, { formula: e.target.value })}
|
|
||||||
placeholder="수식 (예: quantity * unit_price)"
|
|
||||||
className="h-8 text-xs flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => removeCalculation(index)}
|
|
||||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,7 @@ export interface TableColumnConfig {
|
||||||
editable?: boolean; // 편집 가능 여부 (기본: true)
|
editable?: boolean; // 편집 가능 여부 (기본: true)
|
||||||
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
|
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
|
||||||
required?: boolean; // 필수 입력 여부
|
required?: boolean; // 필수 입력 여부
|
||||||
|
hidden?: boolean; // 히든 필드 여부 (UI에서 숨기지만 데이터는 유지)
|
||||||
|
|
||||||
// 너비 설정
|
// 너비 설정
|
||||||
width?: string; // 기본 너비 (예: "150px")
|
width?: string; // 기본 너비 (예: "150px")
|
||||||
|
|
@ -604,6 +605,27 @@ export interface ColumnModeConfig {
|
||||||
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
|
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 계산 규칙
|
||||||
|
* 특정 필드 값에 따라 다른 계산식 적용
|
||||||
|
*/
|
||||||
|
export interface ConditionalCalculationRule {
|
||||||
|
conditionValue: string; // 조건 값 (예: "국내", "해외")
|
||||||
|
formula: string; // 해당 조건일 때 사용할 계산식
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 계산 설정
|
||||||
|
*/
|
||||||
|
export interface ConditionalCalculationConfig {
|
||||||
|
enabled: boolean; // 조건부 계산 활성화 여부
|
||||||
|
conditionField: string; // 조건 기준 필드 (예: "sales_type")
|
||||||
|
conditionFieldType?: "static" | "code" | "table"; // 조건 필드의 옵션 타입
|
||||||
|
conditionFieldCategoryKey?: string; // 카테고리 키 (예: "sales_order_mng.sales_type")
|
||||||
|
rules: ConditionalCalculationRule[]; // 조건별 계산 규칙
|
||||||
|
defaultFormula?: string; // 조건에 해당하지 않을 때 기본 계산식
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 계산 규칙
|
* 테이블 계산 규칙
|
||||||
* 다른 컬럼 값을 기반으로 자동 계산
|
* 다른 컬럼 값을 기반으로 자동 계산
|
||||||
|
|
@ -612,6 +634,9 @@ export interface TableCalculationRule {
|
||||||
resultField: string; // 결과를 저장할 필드
|
resultField: string; // 결과를 저장할 필드
|
||||||
formula: string; // 계산 공식 (예: "quantity * unit_price")
|
formula: string; // 계산 공식 (예: "quantity * unit_price")
|
||||||
dependencies: string[]; // 의존하는 필드들
|
dependencies: string[]; // 의존하는 필드들
|
||||||
|
|
||||||
|
// 조건부 계산 (선택사항)
|
||||||
|
conditionalCalculation?: ConditionalCalculationConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다중 행 저장 설정
|
// 다중 행 저장 설정
|
||||||
|
|
|
||||||
|
|
@ -1608,6 +1608,66 @@ export class ButtonActionExecutor {
|
||||||
return { handled: false, success: false };
|
return { handled: false, success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||||
|
console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작");
|
||||||
|
|
||||||
|
const fieldsWithNumbering: Record<string, string> = {};
|
||||||
|
|
||||||
|
// commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기
|
||||||
|
for (const [key, value] of Object.entries(modalData)) {
|
||||||
|
if (key.endsWith("_numberingRuleId") && value) {
|
||||||
|
const fieldName = key.replace("_numberingRuleId", "");
|
||||||
|
fieldsWithNumbering[fieldName] = value as string;
|
||||||
|
console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formData에서도 확인 (모달 외부에 있을 수 있음)
|
||||||
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
|
if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) {
|
||||||
|
const fieldName = key.replace("_numberingRuleId", "");
|
||||||
|
fieldsWithNumbering[fieldName] = value as string;
|
||||||
|
console.log(
|
||||||
|
`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
|
||||||
|
|
||||||
|
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
|
||||||
|
if (Object.keys(fieldsWithNumbering).length > 0) {
|
||||||
|
console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)");
|
||||||
|
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||||
|
|
||||||
|
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`,
|
||||||
|
);
|
||||||
|
const allocateResult = await allocateNumberingCode(ruleId);
|
||||||
|
|
||||||
|
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||||
|
const newCode = allocateResult.data.generatedCode;
|
||||||
|
console.log(
|
||||||
|
`✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]} → ${newCode}`,
|
||||||
|
);
|
||||||
|
commonFieldsData[fieldName] = newCode;
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`,
|
||||||
|
allocateResult.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (allocateError) {
|
||||||
|
console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||||
|
// 오류 시 기존 값 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 사용자 정보 추가
|
// 사용자 정보 추가
|
||||||
if (!context.userId) {
|
if (!context.userId) {
|
||||||
|
|
@ -1804,6 +1864,84 @@ export class ButtonActionExecutor {
|
||||||
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
|
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
|
||||||
toast.success(`저장 완료: ${resultMessage}`);
|
toast.success(`저장 완료: ${resultMessage}`);
|
||||||
|
|
||||||
|
// 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행)
|
||||||
|
if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) {
|
||||||
|
const flowId = config.dataflowConfig.flowConfig.flowId;
|
||||||
|
console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 플로우 소스 테이블 조회
|
||||||
|
const { getFlowSourceTable } = await import("@/lib/api/nodeFlows");
|
||||||
|
const flowSourceInfo = await getFlowSourceTable(flowId);
|
||||||
|
|
||||||
|
console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo);
|
||||||
|
|
||||||
|
if (flowSourceInfo.sourceTable) {
|
||||||
|
// 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기
|
||||||
|
let controlExecuted = false;
|
||||||
|
|
||||||
|
for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) {
|
||||||
|
const sectionConfig = sections.find((s: any) => s.id === sectionId);
|
||||||
|
const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName;
|
||||||
|
|
||||||
|
console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, {
|
||||||
|
sectionTargetTable,
|
||||||
|
flowSourceTable: flowSourceInfo.sourceTable,
|
||||||
|
isMatch: sectionTargetTable === flowSourceInfo.sourceTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 소스 테이블과 일치하는 섹션만 제어 실행
|
||||||
|
if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성
|
||||||
|
const sourceData = sectionItems.map((item: any) => ({
|
||||||
|
...commonFieldsData,
|
||||||
|
...item,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}건`,
|
||||||
|
sourceData[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 제어 관리용 컨텍스트 생성
|
||||||
|
const controlContext: ButtonActionContext = {
|
||||||
|
...context,
|
||||||
|
selectedRowsData: sourceData,
|
||||||
|
formData: commonFieldsData,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 제어 관리 실행
|
||||||
|
await this.executeAfterSaveControl(config, controlContext);
|
||||||
|
controlExecuted = true;
|
||||||
|
break; // 첫 번째 매칭 섹션만 실행
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매칭되는 섹션이 없으면 메인 테이블 확인
|
||||||
|
if (!controlExecuted && tableName === flowSourceInfo.sourceTable) {
|
||||||
|
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행");
|
||||||
|
|
||||||
|
const controlContext: ButtonActionContext = {
|
||||||
|
...context,
|
||||||
|
selectedRowsData: [commonFieldsData],
|
||||||
|
formData: commonFieldsData,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.executeAfterSaveControl(config, controlContext);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵");
|
||||||
|
}
|
||||||
|
} catch (controlError) {
|
||||||
|
console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError);
|
||||||
|
// 제어 관리 실패는 저장 성공에 영향주지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 저장 성공 이벤트 발생
|
// 저장 성공 이벤트 발생
|
||||||
window.dispatchEvent(new CustomEvent("saveSuccess"));
|
window.dispatchEvent(new CustomEvent("saveSuccess"));
|
||||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue