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:
parent
516517eb34
commit
955da6ae87
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping {
|
|||
numberingRuleId?: string;
|
||||
showInForm: boolean;
|
||||
showResultModal: boolean;
|
||||
shareAcrossItems?: boolean;
|
||||
}
|
||||
|
||||
export interface PopFieldSaveConfig {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ export interface ModalSelectConfig {
|
|||
|
||||
displayField: string;
|
||||
valueField: string;
|
||||
|
||||
/** displayField 기준 중복 제거 */
|
||||
distinct?: boolean;
|
||||
}
|
||||
|
||||
/** pop-search 전체 설정 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue