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; numberingRuleId: string;
targetColumn: string; targetColumn: string;
showResultModal?: boolean; showResultModal?: boolean;
shareAcrossItems?: boolean;
} }
interface HiddenMappingInfo { interface HiddenMappingInfo {
@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); 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) { for (const item of items) {
const columns: string[] = ["company_code"]; const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode]; const values: unknown[] = [companyCode];
@ -225,23 +251,25 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value); values.push(value);
} }
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) { for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue; if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode( if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`); columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode); values.push(sharedCodes[ag.targetColumn]);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); } else if (!ag.shareAcrossItems) {
} catch (err: any) { try {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); 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}`); 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) { for (const item of items) {
const columns: string[] = ["company_code"]; const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode]; const values: unknown[] = [companyCode];
@ -467,7 +520,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
} }
} }
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [ const allHidden = [
...(fieldMapping?.hiddenMappings ?? []), ...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []), ...(cardMapping?.hiddenMappings ?? []),
@ -494,34 +546,28 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value); values.push(value);
} }
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) { for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue; if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode( if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
ag.numberingRuleId,
companyCode,
{ ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`); columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode); values.push(sharedCodes[ag.targetColumn]);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); } else if (!ag.shareAcrossItems) {
logger.info("[pop/execute-action] 채번 완료", { try {
ruleId: ag.numberingRuleId, const generatedCode = await numberingRuleService.allocateCode(
targetColumn: ag.targetColumn, ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
generatedCode, );
}); columns.push(`"${ag.targetColumn}"`);
} catch (err: any) { values.push(generatedCode);
logger.error("[pop/execute-action] 채번 실패", { generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
ruleId: ag.numberingRuleId, logger.info("[pop/execute-action] 채번 완료", {
error: err.message, 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!, numberingRuleId: m.numberingRuleId!,
targetColumn: m.targetColumn, targetColumn: m.targetColumn,
showResultModal: m.showResultModal, showResultModal: m.showResultModal,
shareAcrossItems: m.shareAcrossItems ?? false,
})), })),
hiddenMappings: (cfg.saveConfig.hiddenMappings || []) hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
.filter((m) => m.targetColumn) .filter((m) => m.targetColumn)

View File

@ -1337,7 +1337,19 @@ function SaveTabContent({
/> />
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> </Label>
</div> </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> </div>
{m.shareAcrossItems && (
<p className="text-[9px] text-muted-foreground">
</p>
)}
</div> </div>
); );
})} })}

View File

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

View File

@ -140,6 +140,11 @@ export function PopSearchComponent({
[config.modalConfig, emitFilterChanged] [config.modalConfig, emitFilterChanged]
); );
const handleModalClear = useCallback(() => {
setModalDisplayText("");
emitFilterChanged("");
}, [emitFilterChanged]);
const showLabel = config.labelVisible !== false && !!config.labelText; const showLabel = config.labelVisible !== false && !!config.labelText;
return ( return (
@ -158,6 +163,7 @@ export function PopSearchComponent({
onChange={emitFilterChanged} onChange={emitFilterChanged}
modalDisplayText={modalDisplayText} modalDisplayText={modalDisplayText}
onModalOpen={handleModalOpen} onModalOpen={handleModalOpen}
onModalClear={handleModalClear}
/> />
</div> </div>
@ -184,9 +190,10 @@ interface InputRendererProps {
onChange: (v: unknown) => void; onChange: (v: unknown) => void;
modalDisplayText?: string; modalDisplayText?: string;
onModalOpen?: () => void; 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); const normalized = normalizeInputType(config.inputType as string);
switch (normalized) { switch (normalized) {
case "text": case "text":
@ -205,7 +212,7 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
case "toggle": case "toggle":
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />; return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal": case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />; return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
default: default:
return <PlaceholderInput inputType={config.inputType} />; return <PlaceholderInput inputType={config.inputType} />;
} }
@ -589,7 +596,9 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v:
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기 // 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 ( return (
<div <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" 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} onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
> >
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span> <span className={`flex-1 truncate text-xs ${hasValue ? "" : "text-muted-foreground"}`}>
<ChevronRight className="h-3.5 w-3.5 shrink-0 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> </div>
); );
} }
@ -678,6 +700,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
columnLabels, columnLabels,
displayStyle = "table", displayStyle = "table",
displayField, displayField,
distinct,
} = modalConfig; } = modalConfig;
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : []; const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
@ -689,13 +712,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
setLoading(true); setLoading(true);
try { try {
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 }); 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 { } catch {
setAllRows([]); setAllRows([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [tableName]); }, [tableName, distinct, displayField]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {

View File

@ -997,6 +997,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
</p> </p>
</div> </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"> <div className="space-y-1">
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> </Label>

View File

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