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;
|
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,14 +251,15 @@ 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;
|
||||||
|
|
||||||
|
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||||
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
|
values.push(sharedCodes[ag.targetColumn]);
|
||||||
|
} else if (!ag.shareAcrossItems) {
|
||||||
try {
|
try {
|
||||||
const generatedCode = await numberingRuleService.allocateCode(
|
const generatedCode = await numberingRuleService.allocateCode(
|
||||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||||
|
|
@ -244,6 +271,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||||
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
|
values.push(sharedCodes[ag.targetColumn]);
|
||||||
|
} else if (!ag.shareAcrossItems) {
|
||||||
try {
|
try {
|
||||||
const generatedCode = await numberingRuleService.allocateCode(
|
const generatedCode = await numberingRuleService.allocateCode(
|
||||||
ag.numberingRuleId,
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||||
companyCode,
|
|
||||||
{ ...fieldValues, ...item },
|
|
||||||
);
|
);
|
||||||
columns.push(`"${ag.targetColumn}"`);
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
values.push(generatedCode);
|
values.push(generatedCode);
|
||||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||||
logger.info("[pop/execute-action] 채번 완료", {
|
logger.info("[pop/execute-action] 채번 완료", {
|
||||||
ruleId: ag.numberingRuleId,
|
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
|
||||||
targetColumn: ag.targetColumn,
|
|
||||||
generatedCode,
|
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error("[pop/execute-action] 채번 실패", {
|
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
ruleId: ag.numberingRuleId,
|
}
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -1337,8 +1337,20 @@ 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>
|
</div>
|
||||||
|
{m.shareAcrossItems && (
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
저장되는 모든 행에 동일한 번호를 부여합니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"}`}>
|
||||||
|
{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" />
|
<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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ export interface ModalSelectConfig {
|
||||||
|
|
||||||
displayField: string;
|
displayField: string;
|
||||||
valueField: string;
|
valueField: string;
|
||||||
|
|
||||||
|
/** displayField 기준 중복 제거 */
|
||||||
|
distinct?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pop-search 전체 설정 */
|
/** pop-search 전체 설정 */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue