From 955da6ae87d7ab7d442182a865709cb795c01055 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 15:59:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20=EC=9D=BC=EA=B4=84=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88=20+=20=EB=AA=A8=EB=8B=AC=20distinct=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20+=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=EC=97=90=EC=84=9C=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=ED=92=88=EB=AA=A9=EC=9D=84=20=ED=95=9C=EA=BA=BC=EB=B2=88?= =?UTF-8?q?=EC=97=90=20=EC=9E=85=EA=B3=A0=20=ED=99=95=EC=A0=95=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=EC=9E=85=EA=B3=A0?= =?UTF-8?q?=EB=B2=88=ED=98=B8=EB=A5=BC=20=EA=B3=B5=EC=9C=A0=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=9D=BC=EA=B4=84=20=EC=B1=84=EB=B2=88(sh?= =?UTF-8?q?areAcrossItems)=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EA=B3=A0,=20=EC=9E=85=EA=B3=A0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EB=8A=94=20distinct=20=EC=98=B5=EC=85=98=EA=B3=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EB=90=9C=20=ED=95=84=ED=84=B0=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EB=8A=94=20X=20=EB=B2=84=ED=8A=BC=EC=9D=84?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20[=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EC=B1=84=EB=B2=88]=20-=20pop-field=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=84=A4=EC=A0=95=EC=97=90=20shareAcrossI?= =?UTF-8?q?tems=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20data-save=20/=20inbound-confirm:?= =?UTF-8?q?=20shareAcrossItems=3Dtrue=20=EB=A7=A4=ED=95=91=EC=9D=80=20=20?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=A3=A8=ED=94=84=20=EC=A0=84?= =?UTF-8?q?=201=ED=9A=8C=EB=A7=8C=20allocateCode=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EA=B3=B5=EC=9C=A0=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20-=20PopFieldComponent=EC=97=90=EC=84=9C=20?= =?UTF-8?q?shareAcrossItems=20=EA=B0=92=EC=9D=84=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EC=A0=84=EB=8B=AC=20[=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20distinct]=20-=20ModalSelectConfig=EC=97=90=20distinct=3F:=20?= =?UTF-8?q?boolean=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=83=AD=20=EC=98=81=EC=97=AD=EC=97=90=20"=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0"=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20-=20ModalDialog=20fetchData=EC=97=90?= =?UTF-8?q?=EC=84=9C=20displayField=20=EA=B8=B0=EC=A4=80=20Set=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20[=EC=84=A0=ED=83=9D=20=ED=95=B4=EC=A0=9C]?= =?UTF-8?q?=20-=20ModalSearchInput:=20=EA=B0=92=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=8B=9C=20>=20=EC=95=84=EC=9D=B4=EC=BD=98=20->=20X=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20-?= =?UTF-8?q?=20X=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20modalDisplayText=20+=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EA=B0=92=20=EC=B4=88=EA=B8=B0=ED=99=94=20(st?= =?UTF-8?q?opPropagation)=20-=20handleModalClear=20=EC=BD=9C=EB=B0=B1=20+?= =?UTF-8?q?=20onModalClear=20prop=20=EC=B2=B4=EC=9D=B8=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 118 ++++++++++++------ .../pop-field/PopFieldComponent.tsx | 1 + .../pop-field/PopFieldConfig.tsx | 12 ++ .../pop-components/pop-field/types.ts | 1 + .../pop-search/PopSearchComponent.tsx | 49 ++++++-- .../pop-search/PopSearchConfig.tsx | 15 +++ .../pop-components/pop-search/types.ts | 3 + 7 files changed, 156 insertions(+), 43 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 730572d8..b36bc39e 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -17,6 +17,7 @@ interface AutoGenMappingInfo { numberingRuleId: string; targetColumn: string; showResultModal?: boolean; + shareAcrossItems?: boolean; } interface HiddenMappingInfo { @@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -225,23 +251,25 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } @@ -448,6 +476,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -467,7 +520,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } - // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) const allHidden = [ ...(fieldMapping?.hiddenMappings ?? []), ...(cardMapping?.hiddenMappings ?? []), @@ -494,34 +546,28 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, - companyCode, - { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - logger.info("[pop/execute-action] 채번 완료", { - ruleId: ag.numberingRuleId, - targetColumn: ag.targetColumn, - generatedCode, - }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { - ruleId: ag.numberingRuleId, - error: err.message, - }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index dace22f6..7ad256ff 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -228,6 +228,7 @@ export function PopFieldComponent({ numberingRuleId: m.numberingRuleId!, targetColumn: m.targetColumn, showResultModal: m.showResultModal, + shareAcrossItems: m.shareAcrossItems ?? false, })), hiddenMappings: (cfg.saveConfig.hiddenMappings || []) .filter((m) => m.targetColumn) diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx index 8b5beb84..20fccca3 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -1337,7 +1337,19 @@ function SaveTabContent({ /> +
+ updateAutoGenMapping(m.id, { shareAcrossItems: v })} + /> + +
+ {m.shareAcrossItems && ( +

+ 저장되는 모든 행에 동일한 번호를 부여합니다 +

+ )} ); })} diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts index 7118d0a6..f0813e6c 100644 --- a/frontend/lib/registry/pop-components/pop-field/types.ts +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping { numberingRuleId?: string; showInForm: boolean; showResultModal: boolean; + shareAcrossItems?: boolean; } export interface PopFieldSaveConfig { diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 0cb0d1a9..44fc6dcc 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -140,6 +140,11 @@ export function PopSearchComponent({ [config.modalConfig, emitFilterChanged] ); + const handleModalClear = useCallback(() => { + setModalDisplayText(""); + emitFilterChanged(""); + }, [emitFilterChanged]); + const showLabel = config.labelVisible !== false && !!config.labelText; return ( @@ -158,6 +163,7 @@ export function PopSearchComponent({ onChange={emitFilterChanged} modalDisplayText={modalDisplayText} onModalOpen={handleModalOpen} + onModalClear={handleModalClear} /> @@ -184,9 +190,10 @@ interface InputRendererProps { onChange: (v: unknown) => void; modalDisplayText?: string; onModalOpen?: () => void; + onModalClear?: () => void; } -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -205,7 +212,7 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa case "toggle": return ; case "modal": - return ; + return ; default: return ; } @@ -589,7 +596,9 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: // modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기 // ======================================== -function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) { +function ModalSearchInput({ config, displayText, onClick, onClear }: { config: PopSearchConfig; displayText: string; onClick?: () => void; onClear?: () => void }) { + const hasValue = !!displayText; + return (
{ if (e.key === "Enter" || e.key === " ") onClick?.(); }} > - {displayText || config.placeholder || "선택..."} - + + {displayText || config.placeholder || "선택..."} + + {hasValue && onClear ? ( + + ) : ( + + )}
); } @@ -678,6 +700,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal columnLabels, displayStyle = "table", displayField, + distinct, } = modalConfig; const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : []; @@ -689,13 +712,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal setLoading(true); try { const result = await dataApi.getTableData(tableName, { page: 1, size: 200 }); - setAllRows(result.data || []); + let rows = result.data || []; + + if (distinct && displayField) { + const seen = new Set(); + rows = rows.filter((row) => { + const val = String(row[displayField] ?? ""); + if (seen.has(val)) return false; + seen.add(val); + return true; + }); + } + + setAllRows(rows); } catch { setAllRows([]); } finally { setLoading(false); } - }, [tableName]); + }, [tableName, distinct, displayField]); useEffect(() => { if (open) { diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index e91ce7d9..4c52961b 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -997,6 +997,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {

+ {/* 중복 제거 (Distinct) */} +
+
+ updateModal({ distinct: !!checked })} + /> + +
+

+ 표시 필드 기준으로 동일한 값이 여러 건이면 하나만 표시 +

+
+ {/* 검색창에 보일 값 */}
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 220d5ff9..6da0ae32 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -73,6 +73,9 @@ export interface ModalSelectConfig { displayField: string; valueField: string; + + /** displayField 기준 중복 제거 */ + distinct?: boolean; } /** pop-search 전체 설정 */