feat(pop): 일괄 채번 + 모달 distinct 중복 제거 + 선택 해제 기능

장바구니에서 여러 품목을 한꺼번에 입고 확정할 때 동일한 입고번호를
공유하도록 일괄 채번(shareAcrossItems) 기능을 추가하고, 입고 목록
화면에서 모달 검색 시 중복 항목을 제거하는 distinct 옵션과 선택된
필터를 해제하는 X 버튼을 구현한다.
[일괄 채번]
- pop-field 자동생성 설정에 shareAcrossItems 스위치 추가
- 백엔드 data-save / inbound-confirm: shareAcrossItems=true 매핑은
  아이템 루프 전 1회만 allocateCode 호출하여 공유 코드 발급
- PopFieldComponent에서 shareAcrossItems 값을 백엔드로 전달
[모달 distinct]
- ModalSelectConfig에 distinct?: boolean 필드 추가
- 설정 패널 필터 탭 영역에 "중복 제거" 체크박스 배치
- ModalDialog fetchData에서 displayField 기준 Set 필터링
[선택 해제]
- ModalSearchInput: 값 선택 시 > 아이콘 -> X 버튼으로 전환
- X 클릭 시 modalDisplayText + 필터값 초기화 (stopPropagation)
- handleModalClear 콜백 + onModalClear prop 체인 연결
This commit is contained in:
SeongHyun Kim 2026-03-06 15:59:40 +09:00
parent 516517eb34
commit 955da6ae87
7 changed files with 156 additions and 43 deletions

View File

@ -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<string, string> = {};
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<string, string> = {};
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 });
}
}
}

View File

@ -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)

View File

@ -1337,7 +1337,19 @@ function SaveTabContent({
/>
<Label className="text-[10px]"> </Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={m.shareAcrossItems ?? false}
onCheckedChange={(v) => updateAutoGenMapping(m.id, { shareAcrossItems: v })}
/>
<Label className="text-[10px]"> </Label>
</div>
</div>
{m.shareAcrossItems && (
<p className="text-[9px] text-muted-foreground">
</p>
)}
</div>
);
})}

View File

@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping {
numberingRuleId?: string;
showInForm: boolean;
showResultModal: boolean;
shareAcrossItems?: boolean;
}
export interface PopFieldSaveConfig {

View File

@ -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}
/>
</div>
@ -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 <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
default:
return <PlaceholderInput inputType={config.inputType} />;
}
@ -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 (
<div
className="flex h-full min-h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
@ -598,8 +607,21 @@ function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchC
onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
>
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className={`flex-1 truncate text-xs ${hasValue ? "" : "text-muted-foreground"}`}>
{displayText || config.placeholder || "선택..."}
</span>
{hasValue && onClear ? (
<button
type="button"
className="ml-1 shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onClear(); }}
aria-label="선택 해제"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</div>
);
}
@ -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<string>();
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) {

View File

@ -997,6 +997,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
</p>
</div>
{/* 중복 제거 (Distinct) */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Checkbox
id="modal_distinct"
checked={mc.distinct ?? false}
onCheckedChange={(checked) => updateModal({ distinct: !!checked })}
/>
<Label htmlFor="modal_distinct" className="text-[10px]"> (Distinct)</Label>
</div>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
{/* 검색창에 보일 값 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>

View File

@ -73,6 +73,9 @@ export interface ModalSelectConfig {
displayField: string;
valueField: string;
/** displayField 기준 중복 제거 */
distinct?: boolean;
}
/** pop-search 전체 설정 */