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