Compare commits

...

10 Commits

Author SHA1 Message Date
kjs e308fd0ccc 테이블에 존재하는지 확인하는 제어 추가 2026-01-07 09:55:19 +09:00
SeongHyun Kim c365f06ed7 Merge remote-tracking branch 'origin/main' into ksh 2026-01-07 09:06:29 +09:00
hjlee 563081fa1c Merge pull request 'lhj' (#333) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/333
2026-01-06 17:57:31 +09:00
leeheejin 24331687d4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 17:57:05 +09:00
leeheejin ea848b97ee 검색필터 업데이트 2026-01-06 17:56:31 +09:00
hjlee 15fc166683 Merge pull request 'lhj' (#332) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/332
2026-01-06 17:40:25 +09:00
leeheejin 26fdab5b4e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 17:39:53 +09:00
leeheejin 12d3419b7f 구분 필터링 업데이트 2026-01-06 17:39:36 +09:00
SeongHyun Kim a2b701a4bf feat: 조건부 컨테이너 initialData 전달 체계 구현
InteractiveScreenViewerDynamic: originalData를 initialData로 추가 전달
DynamicComponentRenderer: initialData 우선순위 로직 추가
ConditionalContainerComponent: initialData props 추가 및 하위 전달
ConditionalSectionViewer: initialData props 추가 및 하위 전달
types.ts: initialData 타입 정의 추가
수정 모드에서 조건부 컨테이너 내부 컴포넌트 초기값 표시 지원
2026-01-06 17:29:41 +09:00
kjs 2213ad51b2 Merge pull request '수정' (#331) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/331
2026-01-06 17:02:54 +09:00
11 changed files with 834 additions and 133 deletions

View File

@ -2707,28 +2707,48 @@ export class NodeFlowExecutionService {
const trueData: any[] = [];
const falseData: any[] = [];
inputData.forEach((item: any) => {
const results = conditions.map((condition: any) => {
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
for (const item of inputData) {
const results: boolean[] = [];
for (const condition of conditions) {
const fieldValue = item[condition.field];
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = item[condition.value];
// EXISTS 계열 연산자 처리
if (
condition.operator === "EXISTS_IN" ||
condition.operator === "NOT_EXISTS_IN"
) {
const existsResult = await this.evaluateExistsCondition(
fieldValue,
condition.operator,
condition.lookupTable,
condition.lookupField,
context.companyCode
);
results.push(existsResult);
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
// 일반 연산자 처리
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = item[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
);
}
results.push(
this.evaluateCondition(fieldValue, condition.operator, compareValue)
);
}
return this.evaluateCondition(
fieldValue,
condition.operator,
compareValue
);
});
}
const result =
logic === "OR"
@ -2740,7 +2760,7 @@ export class NodeFlowExecutionService {
} else {
falseData.push(item);
}
});
}
logger.info(
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
@ -2755,27 +2775,46 @@ export class NodeFlowExecutionService {
}
// 단일 객체인 경우
const results = conditions.map((condition: any) => {
const results: boolean[] = [];
for (const condition of conditions) {
const fieldValue = inputData[condition.field];
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = inputData[condition.value];
// EXISTS 계열 연산자 처리
if (
condition.operator === "EXISTS_IN" ||
condition.operator === "NOT_EXISTS_IN"
) {
const existsResult = await this.evaluateExistsCondition(
fieldValue,
condition.operator,
condition.lookupTable,
condition.lookupField,
context.companyCode
);
results.push(existsResult);
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
// 일반 연산자 처리
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = inputData[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
);
}
results.push(
this.evaluateCondition(fieldValue, condition.operator, compareValue)
);
}
return this.evaluateCondition(
fieldValue,
condition.operator,
compareValue
);
});
}
const result =
logic === "OR"
@ -2784,7 +2823,7 @@ export class NodeFlowExecutionService {
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
// 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
return {
@ -2795,6 +2834,68 @@ export class NodeFlowExecutionService {
};
}
/**
* EXISTS_IN / NOT_EXISTS_IN
*
*/
private static async evaluateExistsCondition(
fieldValue: any,
operator: string,
lookupTable: string,
lookupField: string,
companyCode?: string
): Promise<boolean> {
if (!lookupTable || !lookupField) {
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
return false;
}
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
logger.info(
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
);
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
return operator === "NOT_EXISTS_IN";
}
try {
// 멀티테넌시: company_code 필터 적용 여부 확인
// company_mng 테이블은 제외
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
let sql: string;
let params: any[];
if (hasCompanyCode) {
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
params = [fieldValue, companyCode];
} else {
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
params = [fieldValue];
}
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
const result = await query(sql, params);
const existsInTable = result[0]?.exists_result === true;
logger.info(
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}${existsInTable ? "존재함" : "존재하지 않음"}`
);
// EXISTS_IN: 존재하면 true
// NOT_EXISTS_IN: 존재하지 않으면 true
if (operator === "EXISTS_IN") {
return existsInTable;
} else {
return !existsInTable;
}
} catch (error: any) {
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
return false;
}
}
/**
* WHERE
*/

View File

@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record<string, string> = {
NOT_IN: "NOT IN",
IS_NULL: "NULL",
IS_NOT_NULL: "NOT NULL",
EXISTS_IN: "EXISTS IN",
NOT_EXISTS_IN: "NOT EXISTS IN",
};
// EXISTS 계열 연산자인지 확인
const isExistsOperator = (operator: string): boolean => {
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
};
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeDa
{idx > 0 && (
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
)}
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center gap-1">
<span className="font-mono text-gray-700">{condition.field}</span>
<span className="rounded bg-yellow-200 px-1 py-0.5 text-yellow-800">
<span
className={`rounded px-1 py-0.5 ${
isExistsOperator(condition.operator)
? "bg-purple-200 text-purple-800"
: "bg-yellow-200 text-yellow-800"
}`}
>
{OPERATOR_LABELS[condition.operator] || condition.operator}
</span>
{condition.value !== null && condition.value !== undefined && (
<span className="text-gray-600">
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
{/* EXISTS 연산자인 경우 테이블.필드 표시 */}
{isExistsOperator(condition.operator) ? (
<span className="text-purple-600">
{(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
{(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
</span>
) : (
// 일반 연산자인 경우 값 표시
condition.value !== null &&
condition.value !== undefined && (
<span className="text-gray-600">
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
</span>
)
)}
</div>
</div>

View File

@ -4,14 +4,18 @@
*
*/
import { useEffect, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState, useCallback } from "react";
import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { ConditionNodeData } from "@/types/node-editor";
import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
// 필드 정의
interface FieldDefinition {
@ -20,6 +24,19 @@ interface FieldDefinition {
type?: string;
}
// 테이블 정보
interface TableInfo {
tableName: string;
tableLabel: string;
}
// 테이블 컬럼 정보
interface ColumnInfo {
columnName: string;
columnLabel: string;
dataType: string;
}
interface ConditionPropertiesProps {
nodeId: string;
data: ConditionNodeData;
@ -38,8 +55,194 @@ const OPERATORS = [
{ value: "NOT_IN", label: "NOT IN" },
{ value: "IS_NULL", label: "NULL" },
{ value: "IS_NOT_NULL", label: "NOT NULL" },
{ value: "EXISTS_IN", label: "다른 테이블에 존재함" },
{ value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" },
] as const;
// EXISTS 계열 연산자인지 확인
const isExistsOperator = (operator: string): boolean => {
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
};
// 테이블 선택용 검색 가능한 Combobox
function TableCombobox({
tables,
value,
onSelect,
placeholder = "테이블 검색...",
}: {
tables: TableInfo[];
value: string;
onSelect: (value: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const selectedTable = tables.find((t) => t.tableName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{selectedTable ? (
<span className="truncate">
{selectedTable.tableLabel}
<span className="ml-1 text-gray-400">({selectedTable.tableName})</span>
</span>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder={placeholder} className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-y-auto">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableLabel} ${table.tableName}`}
onSelect={() => {
onSelect(table.tableName);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", value === table.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.tableLabel}</span>
<span className="text-[10px] text-gray-500">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 컬럼 선택용 검색 가능한 Combobox
function ColumnCombobox({
columns,
value,
onSelect,
placeholder = "컬럼 검색...",
}: {
columns: ColumnInfo[];
value: string;
onSelect: (value: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const selectedColumn = columns.find((c) => c.columnName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{selectedColumn ? (
<span className="truncate">
{selectedColumn.columnLabel}
<span className="ml-1 text-gray-400">({selectedColumn.columnName})</span>
</span>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder={placeholder} className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-y-auto">
{columns.map((col) => (
<CommandItem
key={col.columnName}
value={`${col.columnLabel} ${col.columnName}`}
onSelect={() => {
onSelect(col.columnName);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", value === col.columnName ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{col.columnLabel}</span>
<span className="ml-1 text-[10px] text-gray-400">({col.columnName})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 컬럼 선택 섹션 (자동 로드 포함)
function ColumnSelectSection({
lookupTable,
lookupField,
tableColumnsCache,
loadingColumns,
loadTableColumns,
onSelect,
}: {
lookupTable: string;
lookupField: string;
tableColumnsCache: Record<string, ColumnInfo[]>;
loadingColumns: Record<string, boolean>;
loadTableColumns: (tableName: string) => Promise<ColumnInfo[]>;
onSelect: (value: string) => void;
}) {
// 캐시에 없고 로딩 중이 아니면 자동으로 로드
useEffect(() => {
if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) {
loadTableColumns(lookupTable);
}
}, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]);
const isLoading = loadingColumns[lookupTable];
const columns = tableColumnsCache[lookupTable];
return (
<div>
<Label className="text-xs text-gray-600">
<Search className="mr-1 inline h-3 w-3" />
</Label>
{isLoading ? (
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
...
</div>
) : columns && columns.length > 0 ? (
<ColumnCombobox columns={columns} value={lookupField} onSelect={onSelect} placeholder="컬럼 검색..." />
) : (
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
</div>
)}
</div>
);
}
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
// EXISTS 연산자용 상태
const [allTables, setAllTables] = useState<TableInfo[]>([]);
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || "조건 분기");
@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
setLogic(data.logic || "AND");
}, [data]);
// 전체 테이블 목록 로드 (EXISTS 연산자용)
useEffect(() => {
const loadAllTables = async () => {
// 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵
if (allTables.length > 0) return;
// EXISTS 연산자가 하나라도 있으면 테이블 목록 로드
const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator));
if (!hasExistsOperator) return;
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(
response.data.map((t: any) => ({
tableName: t.tableName,
tableLabel: t.tableLabel || t.tableName,
}))
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadAllTables();
}, [conditions, allTables.length]);
// 테이블 컬럼 로드 함수
const loadTableColumns = useCallback(
async (tableName: string): Promise<ColumnInfo[]> => {
// 캐시에 있으면 반환
if (tableColumnsCache[tableName]) {
return tableColumnsCache[tableName];
}
// 이미 로딩 중이면 스킵
if (loadingColumns[tableName]) {
return [];
}
// 로딩 상태 설정
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
try {
// getColumnList 반환: { success, data: { columns, total, ... } }
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data && response.data.columns) {
const columns = response.data.columns.map((c: any) => ({
columnName: c.columnName,
columnLabel: c.columnLabel || c.columnName,
dataType: c.dataType,
}));
setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개");
return columns;
} else {
console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response);
}
} catch (error) {
console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error);
} finally {
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
}
return [];
},
[tableColumnsCache, loadingColumns]
);
// EXISTS 연산자 선택 시 테이블 목록 강제 로드
const ensureTablesLoaded = useCallback(async () => {
if (allTables.length > 0) return;
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(
response.data.map((t: any) => ({
tableName: t.tableName,
tableLabel: t.tableLabel || t.tableName,
}))
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
}, [allTables.length]);
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
useEffect(() => {
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
}, [nodeId, nodes, edges]);
const handleAddCondition = () => {
setConditions([
...conditions,
{
field: "",
operator: "EQUALS",
value: "",
valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
},
]);
const newCondition = {
field: "",
operator: "EQUALS" as ConditionOperator,
value: "",
valueType: "static" as "static" | "field",
// EXISTS 연산자용 필드는 초기값 없음
lookupTable: undefined,
lookupTableLabel: undefined,
lookupField: undefined,
lookupFieldLabel: undefined,
};
setConditions([...conditions, newCondition]);
};
const handleRemoveCondition = (index: number) => {
@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
});
};
const handleConditionChange = (index: number, field: string, value: any) => {
const handleConditionChange = async (index: number, field: string, value: any) => {
const newConditions = [...conditions];
newConditions[index] = { ...newConditions[index], [field]: value };
// EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화
if (field === "operator" && isExistsOperator(value)) {
await ensureTablesLoaded();
// EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화
newConditions[index].value = "";
newConditions[index].valueType = undefined;
}
// EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화
if (field === "operator" && !isExistsOperator(value)) {
newConditions[index].lookupTable = undefined;
newConditions[index].lookupTableLabel = undefined;
newConditions[index].lookupField = undefined;
newConditions[index].lookupFieldLabel = undefined;
}
// lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정
if (field === "lookupTable" && value) {
const tableInfo = allTables.find((t) => t.tableName === value);
if (tableInfo) {
newConditions[index].lookupTableLabel = tableInfo.tableLabel;
}
// 테이블 변경 시 필드 초기화
newConditions[index].lookupField = undefined;
newConditions[index].lookupFieldLabel = undefined;
// 컬럼 목록 미리 로드
await loadTableColumns(value);
}
// lookupField 변경 시 라벨 설정
if (field === "lookupField" && value) {
const tableName = newConditions[index].lookupTable;
if (tableName && tableColumnsCache[tableName]) {
const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value);
if (columnInfo) {
newConditions[index].lookupFieldLabel = columnInfo.columnLabel;
}
}
}
setConditions(newConditions);
updateNode(nodeId, {
conditions: newConditions,
@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
</Select>
</div>
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
{/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */}
{isExistsOperator(condition.operator) && (
<>
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={(condition as any).valueType || "static"}
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"></SelectItem>
<SelectItem value="field"> </SelectItem>
</SelectContent>
</Select>
<Label className="text-xs text-gray-600">
<Database className="mr-1 inline h-3 w-3" />
</Label>
{loadingTables ? (
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
...
</div>
) : allTables.length > 0 ? (
<TableCombobox
tables={allTables}
value={(condition as any).lookupTable || ""}
onSelect={(value) => handleConditionChange(index, "lookupTable", value)}
placeholder="테이블 검색..."
/>
) : (
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
</div>
)}
</div>
<div>
<Label className="text-xs text-gray-600">
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
</Label>
{(condition as any).valueType === "field" ? (
// 필드 참조: 드롭다운으로 선택
availableFields.length > 0 ? (
<Select
value={condition.value as string}
onValueChange={(value) => handleConditionChange(index, "value", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="비교할 필드 선택" />
</SelectTrigger>
<SelectContent>
{availableFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
</div>
)
) : (
// 고정값: 직접 입력
<Input
value={condition.value as string}
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
placeholder="비교할 값"
className="mt-1 h-8 text-xs"
/>
)}
{(condition as any).lookupTable && (
<ColumnSelectSection
lookupTable={(condition as any).lookupTable}
lookupField={(condition as any).lookupField || ""}
tableColumnsCache={tableColumnsCache}
loadingColumns={loadingColumns}
loadTableColumns={loadTableColumns}
onSelect={(value) => handleConditionChange(index, "lookupField", value)}
/>
)}
<div className="rounded bg-purple-50 p-2 text-xs text-purple-700">
{condition.operator === "EXISTS_IN"
? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE`
: `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
</div>
</>
)}
{/* 일반 연산자인 경우: 기존 비교값 UI */}
{condition.operator !== "IS_NULL" &&
condition.operator !== "IS_NOT_NULL" &&
!isExistsOperator(condition.operator) && (
<>
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={(condition as any).valueType || "static"}
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"></SelectItem>
<SelectItem value="field"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600">
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
</Label>
{(condition as any).valueType === "field" ? (
// 필드 참조: 드롭다운으로 선택
availableFields.length > 0 ? (
<Select
value={condition.value as string}
onValueChange={(value) => handleConditionChange(index, "value", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="비교할 필드 선택" />
</SelectTrigger>
<SelectContent>
{availableFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
{field.type && (
<span className="ml-2 text-xs text-gray-400">({field.type})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
</div>
)
) : (
// 고정값: 직접 입력
<Input
value={condition.value as string}
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
placeholder="비교할 값"
className="mt-1 h-8 text-xs"
/>
)}
</div>
</>
)}
</div>
</div>
))}
@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
{/* 안내 */}
<div className="space-y-2">
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
🔌 <strong> </strong>: /DB .
<strong> </strong>: /DB .
</div>
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
🔄 <strong> </strong>:<br /> <strong></strong>: (: age &gt; 30)
<br /> <strong> </strong>: (: 주문수량 &gt; )
<strong> </strong>:<br />
- <strong></strong>: (: age &gt; 30)
<br />- <strong> </strong>: (: 주문수량 &gt; )
</div>
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
<strong> </strong>:<br />
- <strong> </strong>: TRUE
<br />- <strong> </strong>: TRUE
<br />
(: 품명이 )
</div>
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
💡 <strong>AND</strong>: TRUE
<strong>AND</strong>: TRUE
</div>
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
💡 <strong>OR</strong>: TRUE
<strong>OR</strong>: TRUE
</div>
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
TRUE , FALSE .
TRUE , FALSE .
</div>
</div>
</div>

View File

@ -365,6 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
isInteractive={true}
formData={formData}
originalData={originalData || undefined}
initialData={originalData || undefined} // 🆕 조건부 컨테이너 등에서 initialData로 전달
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}

View File

@ -413,10 +413,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달
_groupedData: props.groupedData, // 하위 호환성 유지
// 🆕 UniversalFormModal용 initialData 전달
// originalData가 비어있지 않으면 originalData 사용, 아니면 formData 사용
// 생성 모드에서는 originalData가 빈 객체이므로 formData를 사용해야 함
_initialData: (originalData && Object.keys(originalData).length > 0) ? originalData : formData,
// 우선순위: props.initialData > originalData > formData
// 조건부 컨테이너에서 전달된 initialData가 있으면 그것을 사용
_initialData: props.initialData || ((originalData && Object.keys(originalData).length > 0) ? originalData : formData),
_originalData: originalData,
// 🆕 initialData도 직접 전달 (조건부 컨테이너 → 내부 컴포넌트)
initialData: props.initialData,
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
parentTabId: props.parentTabId,
parentTabsComponentId: props.parentTabsComponentId,

View File

@ -42,7 +42,16 @@ export function ConditionalContainerComponent({
className,
groupedData, // 🆕 그룹 데이터
onSave, // 🆕 EditModal의 handleSave 콜백
initialData, // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등)
}: ConditionalContainerProps) {
// 🔍 디버그: initialData 수신 확인
React.useEffect(() => {
console.log("[ConditionalContainer] initialData 수신:", {
hasInitialData: !!initialData,
initialDataKeys: initialData ? Object.keys(initialData) : [],
initialData,
});
}, [initialData]);
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
@ -221,6 +230,7 @@ export function ConditionalContainerComponent({
onSave={onSave}
controlField={controlField}
selectedCondition={selectedValue}
initialData={initialData}
/>
))}
</div>
@ -244,6 +254,7 @@ export function ConditionalContainerComponent({
onSave={onSave}
controlField={controlField}
selectedCondition={selectedValue}
initialData={initialData}
/>
) : null
)

View File

@ -29,7 +29,17 @@ export function ConditionalSectionViewer({
onSave, // 🆕 EditModal의 handleSave 콜백
controlField, // 🆕 조건부 컨테이너의 제어 필드명
selectedCondition, // 🆕 현재 선택된 조건 값
initialData, // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등)
}: ConditionalSectionViewerProps) {
// 🔍 디버그: initialData 수신 확인
React.useEffect(() => {
console.log("[ConditionalSectionViewer] initialData 수신:", {
sectionId,
hasInitialData: !!initialData,
initialDataKeys: initialData ? Object.keys(initialData) : [],
initialData,
});
}, [initialData, sectionId]);
const { userId, userName, user } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [components, setComponents] = useState<ComponentData[]>([]);
@ -191,6 +201,7 @@ export function ConditionalSectionViewer({
onFormDataChange={onFormDataChange}
groupedData={groupedData}
onSave={hasUniversalFormModal ? undefined : onSave}
initialData={initialData}
/>
</div>
);

View File

@ -47,6 +47,7 @@ export interface ConditionalContainerProps {
onFormDataChange?: (fieldName: string, value: any) => void;
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
initialData?: Record<string, any>; // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등)
// 화면 편집기 관련
isDesignMode?: boolean; // 디자인 모드 여부
@ -82,5 +83,7 @@ export interface ConditionalSectionViewerProps {
// 🆕 조건부 컨테이너 정보 (자식 화면에 전달)
controlField?: string; // 제어 필드명 (예: "inbound_type")
selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN")
// 🆕 수정 모드: 초기 데이터 전달 (발주일, 담당자, 메모 등)
initialData?: Record<string, any>;
}

View File

@ -17,6 +17,7 @@ import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { ItemSelectionModalProps, ModalFilterConfig } from "./types";
import { apiClient } from "@/lib/api/client";
import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
export function ItemSelectionModal({
open,
@ -99,13 +100,44 @@ export function ItemSelectionModal({
}
}
// 정렬 후 옵션으로 변환
// 🆕 CATEGORY_ 코드가 있는지 확인하고 라벨 조회
const allCodes = new Set<string>();
for (const val of uniqueValues) {
// 콤마로 구분된 다중 값도 처리
const codes = val.split(",").map(c => c.trim());
codes.forEach(code => {
if (code.startsWith("CATEGORY_")) {
allCodes.add(code);
}
});
}
// CATEGORY_ 코드가 있으면 라벨 조회
let labelMap: Record<string, string> = {};
if (allCodes.size > 0) {
try {
const labelResponse = await getCategoryLabelsByCodes(Array.from(allCodes));
if (labelResponse.success && labelResponse.data) {
labelMap = labelResponse.data;
}
} catch (labelError) {
console.error("카테고리 라벨 조회 실패:", labelError);
}
}
// 정렬 후 옵션으로 변환 (라벨 적용)
const options = Array.from(uniqueValues)
.sort()
.map((val) => ({
value: val,
label: val,
}));
.map((val) => {
// 콤마로 구분된 다중 값 처리
if (val.includes(",")) {
const codes = val.split(",").map(c => c.trim());
const labels = codes.map(code => labelMap[code] || code);
return { value: val, label: labels.join(", ") };
}
// 단일 값
return { value: val, label: labelMap[val] || val };
});
setCategoryOptions((prev) => ({
...prev,

View File

@ -6,6 +6,7 @@ import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
@ -471,6 +472,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용)
// 🆕 다중 값 지원: 셀 값이 "A,B,C" 형태일 때, 필터에서 "A"를 선택하면 해당 행도 표시
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
@ -480,7 +482,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
return values.has(cellStr);
// 정확히 일치하는 경우
if (values.has(cellStr)) return true;
// 다중 값인 경우: 콤마로 분리해서 하나라도 포함되면 true
if (cellStr.includes(",")) {
const cellValues = cellStr.split(",").map(v => v.trim());
return cellValues.some(v => values.has(v));
}
return false;
});
});
}
@ -2248,12 +2259,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
const startEditingRef = useRef<() => void>(() => {});
// 🆕 카테고리 라벨 매핑 (API에서 가져온 것)
const [categoryLabelCache, setCategoryLabelCache] = useState<Record<string, string>>({});
// 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함)
const columnUniqueValues = useMemo(() => {
const result: Record<string, Array<{ value: string; label: string }>> = {};
if (data.length === 0) return result;
// 🆕 전체 데이터에서 개별 값 -> 라벨 매핑 테이블 구축 (다중 값 처리용)
const globalLabelMap: Record<string, Map<string, string>> = {};
(tableConfig.columns || []).forEach((column: { columnName: string }) => {
if (column.columnName === "__checkbox__") return;
@ -2265,23 +2282,70 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
`${column.columnName}_value_label`, // 예: division_value_label
];
const valuesMap = new Map<string, string>(); // value -> label
const singleValueLabelMap = new Map<string, string>(); // 개별 값 -> 라벨 (다중값 처리용)
// 1차: 모든 데이터에서 개별 값 -> 라벨 매핑 수집 (단일값 + 다중값 모두)
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
const valueStr = String(val);
// 라벨 컬럼 후보들 중 값이 있는 것 사용, 없으면 원본 값 사용
let label = valueStr;
// 라벨 컬럼에서 라벨 찾기
let labelStr = "";
for (const labelCol of labelColumnCandidates) {
if (row[labelCol] && row[labelCol] !== "") {
label = String(row[labelCol]);
labelStr = String(row[labelCol]);
break;
}
}
valuesMap.set(valueStr, label);
// 단일 값인 경우
if (!valueStr.includes(",")) {
if (labelStr) {
singleValueLabelMap.set(valueStr, labelStr);
}
} else {
// 다중 값인 경우: 값과 라벨을 각각 분리해서 매핑
const individualValues = valueStr.split(",").map(v => v.trim());
const individualLabels = labelStr ? labelStr.split(",").map(l => l.trim()) : [];
// 값과 라벨 개수가 같으면 1:1 매핑
if (individualValues.length === individualLabels.length) {
individualValues.forEach((v, idx) => {
if (individualLabels[idx] && !singleValueLabelMap.has(v)) {
singleValueLabelMap.set(v, individualLabels[idx]);
}
});
}
}
}
});
// 2차: 모든 값 처리 (다중 값 포함) - 필터 목록용
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
const valueStr = String(val);
// 콤마로 구분된 다중 값인지 확인
if (valueStr.includes(",")) {
// 다중 값: 각각 분리해서 개별 라벨 찾기
const individualValues = valueStr.split(",").map(v => v.trim());
// 🆕 singleValueLabelMap → categoryLabelCache 순으로 라벨 찾기
const individualLabels = individualValues.map(v =>
singleValueLabelMap.get(v) || categoryLabelCache[v] || v
);
valuesMap.set(valueStr, individualLabels.join(", "));
} else {
// 단일 값: 매핑에서 찾거나 캐시에서 찾거나 원본 사용
const label = singleValueLabelMap.get(valueStr) || categoryLabelCache[valueStr] || valueStr;
valuesMap.set(valueStr, label);
}
}
});
globalLabelMap[column.columnName] = singleValueLabelMap;
// value-label 쌍으로 저장하고 라벨 기준 정렬
result[column.columnName] = Array.from(valuesMap.entries())
.map(([value, label]) => ({ value, label }))
@ -2289,7 +2353,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
return result;
}, [data, tableConfig.columns, joinColumnMapping]);
}, [data, tableConfig.columns, joinColumnMapping, categoryLabelCache]);
// 🆕 라벨을 못 찾은 CATEGORY_ 코드들을 API로 조회
useEffect(() => {
const unlabeledCodes = new Set<string>();
// columnUniqueValues에서 라벨이 코드 그대로인 항목 찾기
Object.values(columnUniqueValues).forEach(items => {
items.forEach(item => {
// 라벨에 CATEGORY_가 포함되어 있으면 라벨을 못 찾은 것
if (item.label.includes("CATEGORY_")) {
// 콤마로 분리해서 개별 코드 추출
const codes = item.label.split(",").map(c => c.trim());
codes.forEach(code => {
if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
unlabeledCodes.add(code);
}
});
}
});
});
if (unlabeledCodes.size === 0) return;
// API로 라벨 조회
const fetchLabels = async () => {
try {
const response = await getCategoryLabelsByCodes(Array.from(unlabeledCodes));
if (response.success && response.data) {
setCategoryLabelCache(prev => ({ ...prev, ...response.data }));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
};
fetchLabels();
}, [columnUniqueValues, categoryLabelCache]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {

View File

@ -95,24 +95,35 @@ export interface RestAPISourceNodeData {
displayName?: string;
}
// 조건 연산자 타입
export type ConditionOperator =
| "EQUALS"
| "NOT_EQUALS"
| "GREATER_THAN"
| "LESS_THAN"
| "GREATER_THAN_OR_EQUAL"
| "LESS_THAN_OR_EQUAL"
| "LIKE"
| "NOT_LIKE"
| "IN"
| "NOT_IN"
| "IS_NULL"
| "IS_NOT_NULL"
| "EXISTS_IN" // 다른 테이블에 존재함
| "NOT_EXISTS_IN"; // 다른 테이블에 존재하지 않음
// 조건 분기 노드
export interface ConditionNodeData {
conditions: Array<{
field: string;
operator:
| "EQUALS"
| "NOT_EQUALS"
| "GREATER_THAN"
| "LESS_THAN"
| "GREATER_THAN_OR_EQUAL"
| "LESS_THAN_OR_EQUAL"
| "LIKE"
| "NOT_LIKE"
| "IN"
| "NOT_IN"
| "IS_NULL"
| "IS_NOT_NULL";
operator: ConditionOperator;
value: any;
valueType?: "static" | "field"; // 비교 값 타입
// EXISTS_IN / NOT_EXISTS_IN 전용 필드
lookupTable?: string; // 조회할 테이블명
lookupTableLabel?: string; // 조회할 테이블 라벨
lookupField?: string; // 조회할 테이블의 비교 필드
lookupFieldLabel?: string; // 조회할 테이블의 비교 필드 라벨
}>;
logic: "AND" | "OR";
displayName?: string;