From 91c9dda6ae837568901619d5b434cd5bc4c1a7ec Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 5 Mar 2026 12:13:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-field):=20=EC=88=A8=EC=9D=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EA=B3=A0=EC=A0=95=EA=B0=92=20+=20Select?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99(linkedFilte?= =?UTF-8?q?rs)=20=EA=B5=AC=ED=98=84=20=EC=9E=85=EA=B3=A0=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20status/inbound=5Fstatus=EA=B0=80=20?= =?UTF-8?q?=EB=B9=88=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C(FIX-3)=EC=99=80=20?= =?UTF-8?q?=EC=B0=BD=EA=B3=A0=EB=82=B4=20=EC=9C=84=EC=B9=98=20=EC=85=80?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=EA=B0=80=20=EC=A0=84=EC=B2=B4=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=EB=B3=B4=EC=97=AC=EC=A3=BC=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=ED=95=B4=EA=B2=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[FIX-3:=20=EC=88=A8=EC=9D=80=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=EA=B0=92]=20-=20types.ts:=20HiddenValueSo?= =?UTF-8?q?urce=EC=97=90=20"static"=20=EC=B6=94=EA=B0=80,=20staticValue=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20-=20PopFieldConfig:=20=EC=88=A8=EC=9D=80?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=84=A4=EC=A0=95=20UI=EC=97=90=20"?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=EA=B0=92"=20=EB=AA=A8=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20PopFieldComponent:=20collected=5Fdata=EC=97=90?= =?UTF-8?q?=20hiddenMappings=20=ED=8F=AC=ED=95=A8=20-=20popActionRoutes:?= =?UTF-8?q?=20INSERT=20=EC=8B=9C=20hiddenMappings=20=EA=B0=92=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=20[Select=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20-=20BLOCK=20L]=20-=20types.ts:=20SelectLinkedFilter?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20+=20FieldSel?= =?UTF-8?q?ectSource.linkedFilters=20-=20PopFieldConfig:=20"=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99"=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=20+=20LinkedFiltersEditor=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=20=20(=EC=84=B9=EC=85=98=20=EB=82=B4=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=84=A0=ED=83=9D=20=E2=86=92=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EB=A7=A4=ED=95=91)=20-=20PopFieldCompo?= =?UTF-8?q?nent:=20fieldIdToName=20=EB=A7=B5=EC=9C=BC=EB=A1=9C=20id-name?= =?UTF-8?q?=20=EB=B3=80=ED=99=98,=20=20=20SelectFieldInput=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=B0=EB=8F=99=20=ED=95=84=EB=93=9C=20=EA=B0=92?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=9E=AC=EC=A1=B0=ED=9A=8C,=20=20=20?= =?UTF-8?q?=EC=83=81=EC=9C=84=20=EB=AF=B8=EC=84=A0=ED=83=9D=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80,=20=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=ED=95=98=EC=9C=84?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 39 +++- .../pop-field/PopFieldComponent.tsx | 100 +++++++- .../pop-field/PopFieldConfig.tsx | 216 ++++++++++++++++-- .../pop-components/pop-field/types.ts | 9 +- frontend/lib/registry/pop-components/types.ts | 8 + 5 files changed, 343 insertions(+), 29 deletions(-) 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 {