diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index f71c4495..94b7c5dd 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -19,10 +19,20 @@ interface AutoGenMappingInfo { showResultModal?: boolean; } +interface HiddenMappingInfo { + valueSource: "json_extract" | "db_column" | "static"; + targetColumn: string; + staticValue?: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; +} + interface MappingInfo { targetTable: string; columnMapping: Record; autoGenMappings?: AutoGenMappingInfo[]; + hiddenMappings?: HiddenMappingInfo[]; } interface StatusConditionRule { @@ -122,7 +132,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp let processedCount = 0; let insertedCount = 0; - const generatedCodes: Array<{ targetColumn: string; code: string }> = []; + const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = []; if (action === "inbound-confirm") { // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) @@ -153,6 +163,33 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) + const allHidden = [ + ...(fieldMapping?.hiddenMappings ?? []), + ...(cardMapping?.hiddenMappings ?? []), + ]; + for (const hm of allHidden) { + if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue; + if (columns.includes(`"${hm.targetColumn}"`)) continue; + + let value: unknown = null; + if (hm.valueSource === "static") { + value = hm.staticValue ?? null; + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const jsonCol = item[hm.sourceJsonColumn]; + if (typeof jsonCol === "object" && jsonCol !== null) { + value = (jsonCol as Record)[hm.sourceJsonKey] ?? null; + } else if (typeof jsonCol === "string") { + try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ } + } + } else if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null; + } + + columns.push(`"${hm.targetColumn}"`); + values.push(value); + } + // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 const allAutoGen = [ ...(fieldMapping?.autoGenMappings ?? []), diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index 0fbcf6f1..dace22f6 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -20,6 +20,7 @@ import type { FieldSectionStyle, PopFieldReadSource, PopFieldAutoGenMapping, + SelectLinkedFilter, } from "./types"; import type { CollectDataRequest, CollectedDataResponse } from "../types"; import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types"; @@ -60,6 +61,16 @@ export function PopFieldComponent({ const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm); + const fieldIdToName = useMemo(() => { + const map: Record = {}; + for (const section of cfg.sections) { + for (const f of section.fields ?? []) { + map[f.id] = f.fieldName || f.id; + } + } + return map; + }, [cfg.sections]); + // ResizeObserver로 컨테이너 너비 감시 useEffect(() => { if (typeof window === "undefined" || !containerRef.current) return; @@ -218,6 +229,16 @@ export function PopFieldComponent({ targetColumn: m.targetColumn, showResultModal: m.showResultModal, })), + hiddenMappings: (cfg.saveConfig.hiddenMappings || []) + .filter((m) => m.targetColumn) + .map((m) => ({ + valueSource: m.valueSource, + targetColumn: m.targetColumn, + staticValue: m.staticValue, + sourceJsonColumn: m.sourceJsonColumn, + sourceJsonKey: m.sourceJsonKey, + sourceDbColumn: m.sourceDbColumn, + })), } : null, }; @@ -367,6 +388,8 @@ export function PopFieldComponent({ error={errors[fKey]} onChange={handleFieldChange} sectionStyle={section.style} + allValues={allValues} + fieldIdToName={fieldIdToName} /> ); })} @@ -401,6 +424,8 @@ interface FieldRendererProps { error?: string; onChange: (fieldName: string, value: unknown) => void; sectionStyle: FieldSectionStyle; + allValues?: Record; + fieldIdToName?: Record; } function FieldRenderer({ @@ -410,6 +435,8 @@ function FieldRenderer({ error, onChange, sectionStyle, + allValues, + fieldIdToName, }: FieldRendererProps) { const handleChange = useCallback( (v: unknown) => onChange(field.fieldName, v), @@ -436,7 +463,7 @@ function FieldRenderer({ )} )} - {renderByType(field, value, handleChange, inputClassName)} + {renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)} {error &&

{error}

} ); @@ -450,7 +477,9 @@ function renderByType( field: PopFieldItem, value: unknown, onChange: (v: unknown) => void, - className: string + className: string, + allValues?: Record, + fieldIdToName?: Record, ) { switch (field.inputType) { case "text": @@ -489,6 +518,8 @@ function renderByType( value={value} onChange={onChange} className={className} + allValues={allValues} + fieldIdToName={fieldIdToName} /> ); case "auto": @@ -561,11 +592,15 @@ function SelectFieldInput({ value, onChange, className, + allValues, + fieldIdToName, }: { field: PopFieldItem; value: unknown; onChange: (v: unknown) => void; className: string; + allValues?: Record; + fieldIdToName?: Record; }) { const [options, setOptions] = useState<{ value: string; label: string }[]>( [] @@ -573,6 +608,30 @@ function SelectFieldInput({ const [loading, setLoading] = useState(false); const source = field.selectSource; + const linkedFilters = source?.linkedFilters; + const hasLinkedFilters = !!linkedFilters?.length; + + // 연동 필터에서 참조하는 필드의 현재 값들을 안정적인 문자열로 직렬화 + const linkedFilterKey = useMemo(() => { + if (!hasLinkedFilters || !allValues || !fieldIdToName) return ""; + return linkedFilters! + .map((lf) => { + const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId; + const val = allValues[fieldName] ?? ""; + return `${lf.filterColumn}=${String(val)}`; + }) + .join("&"); + }, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]); + + // 연동 필터의 소스 값이 모두 채워졌는지 확인 + const linkedFiltersFilled = useMemo(() => { + if (!hasLinkedFilters || !allValues || !fieldIdToName) return true; + return linkedFilters!.every((lf) => { + const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId; + const val = allValues[fieldName]; + return val != null && val !== ""; + }); + }, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]); useEffect(() => { if (!source) return; @@ -588,6 +647,24 @@ function SelectFieldInput({ source.valueColumn && source.labelColumn ) { + // 연동 필터가 있는데 소스 값이 비어있으면 빈 옵션 표시 + if (hasLinkedFilters && !linkedFiltersFilled) { + setOptions([]); + return; + } + + // 동적 필터 구성 + const dynamicFilters: Record = {}; + if (hasLinkedFilters && allValues && fieldIdToName) { + for (const lf of linkedFilters!) { + const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId; + const val = allValues[fieldName]; + if (val != null && val !== "" && lf.filterColumn) { + dynamicFilters[lf.filterColumn] = String(val); + } + } + } + setLoading(true); dataApi .getTableData(source.tableName, { @@ -595,6 +672,7 @@ function SelectFieldInput({ size: 500, sortBy: source.labelColumn, sortOrder: "asc", + ...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}), }) .then((res) => { if (Array.isArray(res.data)) { @@ -614,7 +692,16 @@ function SelectFieldInput({ }) .finally(() => setLoading(false)); } - }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]); + }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions, linkedFilterKey, linkedFiltersFilled]); + + // W3: 옵션이 바뀌었을 때 현재 선택값이 유효하지 않으면 자동 초기화 + useEffect(() => { + if (!hasLinkedFilters || !value || loading) return; + const currentStr = String(value); + if (options.length > 0 && !options.some((o) => o.value === currentStr)) { + onChange(""); + } + }, [options, hasLinkedFilters]); if (loading) { return ( @@ -641,6 +728,11 @@ function SelectFieldInput({ ); } + // W2: 연동 필터의 소스 값이 비어있으면 안내 메시지 + const emptyMessage = hasLinkedFilters && !linkedFiltersFilled + ? "상위 필드를 먼저 선택하세요" + : "옵션이 없습니다"; + return ( - {!isJson && ( - <> - - + {isDbColumn && ( + + )} + {isStatic && ( + updateHiddenMapping(m.id, { staticValue: e.target.value })} + placeholder="고정값 입력" + className="h-7 flex-1 text-xs" + /> )} {isJson && ( @@ -1365,6 +1377,7 @@ interface SectionEditorProps { onUpdate: (partial: Partial) => void; onRemove: () => void; onMoveUp: () => void; + allSections: PopFieldSection[]; } function migrateStyle(style: string): FieldSectionStyle { @@ -1381,6 +1394,7 @@ function SectionEditor({ onUpdate, onRemove, onMoveUp, + allSections, }: SectionEditorProps) { const [collapsed, setCollapsed] = useState(false); const resolvedStyle = migrateStyle(section.style); @@ -1562,6 +1576,7 @@ function SectionEditor({ sectionStyle={resolvedStyle} onUpdate={(partial) => updateField(field.id, partial)} onRemove={() => removeField(field.id)} + allSections={allSections} /> ))} + )} + + ))} + + + ); +} + // ======================================== // AppearanceEditor: 섹션 외관 설정 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts index 6d9e1734..7118d0a6 100644 --- a/frontend/lib/registry/pop-components/pop-field/types.ts +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -44,6 +44,11 @@ export const DEFAULT_SECTION_APPEARANCES: Record; + hiddenMappings?: Array<{ + valueSource: "json_extract" | "db_column" | "static"; + targetColumn: string; + staticValue?: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; + }>; } export interface StatusChangeRule {