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 전체 설정 */