feat(pop): 입고 확정 시 자동 채번 실행 + 결과 모달 UX + 셀렉트 높이 통일

입고 확정(inbound-confirm) 실행 시 채번 규칙이 설정되어 있어도
inbound_number가 null로 저장되던 문제를 해결한다.
[채번 실행 (FIX-1)]
- types.ts: SaveMapping에 autoGenMappings 필드 추가 (numberingRuleId,
  targetColumn, showResultModal)
- PopFieldComponent: collect_data 응답에 autoGenMappings 포함하여
  백엔드에 채번 규칙 정보 전달
- popActionRoutes: INSERT 전 numberingRuleService.allocateCode() 호출,
  생성된 코드를 generatedCodes 배열로 응답에 포함
[결과 모달 UX]
- pop-button: showResultModal 토글에 따라 채번 결과 모달 표시 분기
- 모달이 열려 있는 동안 followUpActions(refresh/navigate) 지연하여
  사용자가 확인 버튼을 눌러야 후속 액션 실행
[셀렉트 높이 일관성]
- SelectTrigger hasCustomHeight에 /\bh-\d/ 패턴 추가하여
  className의 h-9 등이 기본 data-size="xs"(h-6)와 충돌하지 않도록 수정
[기타 수정]
- SelectFieldInput: Set 기반 dedup으로 React key 중복 방지
- PopFieldConfig: AutoNumberEditor 제거, 채번 규칙을 저장 탭에서 관리
- PopFieldConfig: 전체 채번 규칙 보기 토글 추가
- PopCardListComponent: 장바구니 목록 모드에서 수량 자동 초기화 방지
- PopCardListConfig: 수식 필드 매핑 노출 + 누락 필드 자동 추가
This commit is contained in:
SeongHyun Kim 2026-03-04 19:12:22 +09:00
parent e5abd93600
commit a6c0ab5664
8 changed files with 230 additions and 152 deletions

View File

@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import logger from "../utils/logger"; import logger from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { numberingRuleService } from "../services/numberingRuleService";
const router = Router(); const router = Router();
@ -12,9 +13,16 @@ function isSafeIdentifier(name: string): boolean {
return SAFE_IDENTIFIER.test(name); return SAFE_IDENTIFIER.test(name);
} }
interface AutoGenMappingInfo {
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
}
interface MappingInfo { interface MappingInfo {
targetTable: string; targetTable: string;
columnMapping: Record<string, string>; columnMapping: Record<string, string>;
autoGenMappings?: AutoGenMappingInfo[];
} }
interface StatusConditionRule { interface StatusConditionRule {
@ -114,6 +122,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
let processedCount = 0; let processedCount = 0;
let insertedCount = 0; let insertedCount = 0;
const generatedCodes: Array<{ targetColumn: string; code: string }> = [];
if (action === "inbound-confirm") { if (action === "inbound-confirm") {
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
@ -144,6 +153,37 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
} }
} }
// 채번 규칙 실행: 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 },
);
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,
});
}
}
if (columns.length > 1) { if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@ -263,7 +303,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
return res.json({ return res.json({
success: true, success: true,
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`, message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
data: { processedCount, insertedCount }, data: { processedCount, insertedCount, generatedCodes },
}); });
} catch (error: any) { } catch (error: any) {
await client.query("ROLLBACK"); await client.query("ROLLBACK");

View File

@ -28,7 +28,7 @@ function SelectTrigger({
size?: "xs" | "sm" | "default"; size?: "xs" | "sm" | "default";
}) { }) {
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시 // className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height; const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || /\bh-\d/.test(className ?? "") || !!style?.height;
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger

View File

@ -421,6 +421,32 @@ export function PopButtonComponent({
const [confirmProcessing, setConfirmProcessing] = useState(false); const [confirmProcessing, setConfirmProcessing] = useState(false);
const [showInboundConfirm, setShowInboundConfirm] = useState(false); const [showInboundConfirm, setShowInboundConfirm] = useState(false);
const [inboundSelectedCount, setInboundSelectedCount] = useState(0); const [inboundSelectedCount, setInboundSelectedCount] = useState(0);
const [generatedCodesResult, setGeneratedCodesResult] = useState<Array<{ targetColumn: string; code: string; showResultModal?: boolean }>>([]);
const handleCloseGeneratedCodesModal = useCallback(() => {
setGeneratedCodesResult([]);
toast.success("입고 확정 완료");
publish(`__comp_output__${componentId}__action_completed`, {
action: "inbound-confirm",
success: true,
});
const followUps = config?.followUpActions ?? [];
for (const fa of followUps) {
switch (fa.type) {
case "navigate":
if (fa.targetScreenId) {
publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params });
}
break;
case "refresh":
publish("__pop_refresh__");
break;
case "event":
if (fa.eventName) publish(fa.eventName, fa.eventPayload);
break;
}
}
}, [componentId, publish, config?.followUpActions]);
// 입고 확정 모드: 선택 항목 수 수신 // 입고 확정 모드: 선택 항목 수 수신
useEffect(() => { useEffect(() => {
@ -576,28 +602,32 @@ export function PopButtonComponent({
}); });
if (result.data?.success) { if (result.data?.success) {
toast.success(`${selectedItems.length}건 입고 확정 완료`); const codes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = result.data.data?.generatedCodes ?? [];
publish(`__comp_output__${componentId}__action_completed`, { const modalCodes = codes.filter((c) => c.showResultModal);
action: "inbound-confirm", if (modalCodes.length > 0) {
success: true, setGeneratedCodesResult(modalCodes);
count: selectedItems.length, } else {
}); toast.success(`${selectedItems.length}건 입고 확정 완료`);
publish(`__comp_output__${componentId}__action_completed`, {
// 후속 액션 실행 (navigate, refresh 등) action: "inbound-confirm",
const followUps = config?.followUpActions ?? []; success: true,
for (const fa of followUps) { count: selectedItems.length,
switch (fa.type) { });
case "navigate": const followUps = config?.followUpActions ?? [];
if (fa.targetScreenId) { for (const fa of followUps) {
publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params }); switch (fa.type) {
} case "navigate":
break; if (fa.targetScreenId) {
case "refresh": publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params });
publish("__pop_refresh__"); }
break; break;
case "event": case "refresh":
if (fa.eventName) publish(fa.eventName, fa.eventPayload); publish("__pop_refresh__");
break; break;
case "event":
if (fa.eventName) publish(fa.eventName, fa.eventPayload);
break;
}
} }
} }
} else { } else {
@ -811,6 +841,36 @@ export function PopButtonComponent({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* 채번 결과 다이얼로그 - 사용자가 확인 누를 때까지 유지 */}
<AlertDialog open={generatedCodesResult.length > 0} onOpenChange={(open) => { if (!open) handleCloseGeneratedCodesModal(); }}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-xs sm:text-sm">
<p> .</p>
{generatedCodesResult.map((c, i) => (
<div key={i} className="flex items-center justify-between rounded-md border p-2">
<span className="text-muted-foreground">{c.targetColumn}</span>
<span className="font-mono font-semibold">{c.code}</span>
</div>
))}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction
onClick={handleCloseGeneratedCodesModal}
className="h-8 w-full text-xs sm:h-10 sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일반 확인 다이얼로그 */} {/* 일반 확인 다이얼로그 */}
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}> <AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]"> <AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">

View File

@ -998,12 +998,13 @@ function Card({
return 999999; return 999999;
}, [limitCol, row]); }, [limitCol, row]);
// 제한 컬럼이 있으면 최대값으로 자동 초기화 // 제한 컬럼이 있으면 최대값으로 자동 초기화 (장바구니 목록 모드에서는 cart 수량 유지)
useEffect(() => { useEffect(() => {
if (isCartListMode) return;
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
setInputValue(effectiveMax); setInputValue(effectiveMax);
} }
}, [effectiveMax, inputField?.enabled, limitCol]); }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]);
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
height: `${scaled.cardHeight}px`, height: `${scaled.cardHeight}px`,

View File

@ -2784,6 +2784,13 @@ function SaveMappingSection({
label: f.label || f.columnName, label: f.label || f.columnName,
badge: "본문", badge: "본문",
}); });
} else if (f.valueType === "formula" && f.label) {
const formulaKey = `__formula_${f.id || f.label}`;
displayed.push({
sourceField: formulaKey,
label: f.label,
badge: "수식",
});
} }
} }
if (inputFieldConfig?.enabled) { if (inputFieldConfig?.enabled) {
@ -2855,6 +2862,21 @@ function SaveMappingSection({
[mapping.mappings] [mapping.mappings]
); );
// 카드에 표시된 필드가 로드되면 매핑에 누락된 필드를 자동 추가 (매핑 안함으로)
useEffect(() => {
if (!mapping.targetTable || cardDisplayedFields.length === 0) return;
const existing = new Set(mapping.mappings.map((m) => m.sourceField));
const missing = cardDisplayedFields.filter((f) => !existing.has(f.sourceField));
if (missing.length === 0) return;
onUpdate({
...mapping,
mappings: [
...mapping.mappings,
...missing.map((f) => ({ sourceField: f.sourceField, targetColumn: "" })),
],
});
}, [cardDisplayedFields]); // eslint-disable-line react-hooks/exhaustive-deps
// 카드에 표시된 필드 중 아직 매핑되지 않은 것 // 카드에 표시된 필드 중 아직 매핑되지 않은 것
const unmappedCardFields = useMemo( const unmappedCardFields = useMemo(
() => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)), () => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)),
@ -2937,7 +2959,7 @@ function SaveMappingSection({
</div> </div>
{isCartMeta(entry.sourceField) ? ( {isCartMeta(entry.sourceField) ? (
!badge && <span className="text-[9px] text-muted-foreground"></span> !badge && <span className="text-[9px] text-muted-foreground"></span>
) : ( ) : entry.sourceField.startsWith("__formula_") ? null : (
<span className="truncate text-[9px] text-muted-foreground"> <span className="truncate text-[9px] text-muted-foreground">
{entry.sourceField} {entry.sourceField}
</span> </span>

View File

@ -211,6 +211,13 @@ export function PopFieldComponent({
columnMapping: Object.fromEntries( columnMapping: Object.fromEntries(
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn]) (cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
), ),
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
.filter((m) => m.numberingRuleId)
.map((m) => ({
numberingRuleId: m.numberingRuleId!,
targetColumn: m.targetColumn,
showResultModal: m.showResultModal,
})),
} }
: null, : null,
}; };
@ -585,18 +592,21 @@ function SelectFieldInput({
dataApi dataApi
.getTableData(source.tableName, { .getTableData(source.tableName, {
page: 1, page: 1,
pageSize: 500, size: 500,
sortColumn: source.labelColumn, sortBy: source.labelColumn,
sortDirection: "asc", sortOrder: "asc",
}) })
.then((res) => { .then((res) => {
if (res.data?.success && Array.isArray(res.data.data?.data)) { if (Array.isArray(res.data)) {
setOptions( const seen = new Set<string>();
res.data.data.data.map((row: Record<string, unknown>) => ({ const deduped: { value: string; label: string }[] = [];
value: String(row[source.valueColumn!] ?? ""), for (const row of res.data) {
label: String(row[source.labelColumn!] ?? ""), const v = String(row[source.valueColumn!] ?? "");
})) if (!v || seen.has(v)) continue;
); seen.add(v);
deduped.push({ value: v, label: String(row[source.labelColumn!] ?? "") });
}
setOptions(deduped);
} }
}) })
.catch(() => { .catch(() => {

View File

@ -74,7 +74,7 @@ import {
type ColumnInfo, type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher"; } from "../pop-dashboard/utils/dataFetcher";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; import { getAvailableNumberingRulesForScreen, getNumberingRules } from "@/lib/api/numberingRule";
// ======================================== // ========================================
// Props // Props
@ -462,6 +462,8 @@ function SaveTabContent({
// --- 자동생성 필드 로직 --- // --- 자동생성 필드 로직 ---
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]); const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]);
const [allNumberingRules, setAllNumberingRules] = useState<{ ruleId: string; ruleName: string; tableName: string }[]>([]);
const [showAllRules, setShowAllRules] = useState(false);
// 레이아웃 auto 필드 → autoGenMappings 자동 동기화 // 레이아웃 auto 필드 → autoGenMappings 자동 동기화
const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(","); const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(",");
@ -478,7 +480,7 @@ function SaveTabContent({
label: field.labelText || "", label: field.labelText || "",
targetColumn: "", targetColumn: "",
numberingRuleId: field.autoNumber?.numberingRuleId ?? "", numberingRuleId: field.autoNumber?.numberingRuleId ?? "",
showInForm: true, showInForm: false,
showResultModal: true, showResultModal: true,
}); });
} }
@ -513,6 +515,24 @@ function SaveTabContent({
} }
}, [saveTableName]); }, [saveTableName]);
useEffect(() => {
if (!showAllRules) return;
if (allNumberingRules.length > 0) return;
getNumberingRules()
.then((res) => {
if (res.success && Array.isArray(res.data)) {
setAllNumberingRules(
res.data.map((r: any) => ({
ruleId: String(r.ruleId ?? r.rule_id ?? ""),
ruleName: String(r.ruleName ?? r.rule_name ?? ""),
tableName: String(r.tableName ?? r.table_name ?? ""),
}))
);
}
})
.catch(() => setAllNumberingRules([]));
}, [showAllRules, allNumberingRules.length]);
const addAutoGenMapping = useCallback(() => { const addAutoGenMapping = useCallback(() => {
const newMapping: PopFieldAutoGenMapping = { const newMapping: PopFieldAutoGenMapping = {
id: `autogen_${Date.now()}`, id: `autogen_${Date.now()}`,
@ -1248,7 +1268,19 @@ function SaveTabContent({
</Select> </Select>
</div> </div>
<div> <div>
<Label className="text-[10px]"> </Label> <div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<div className="flex items-center gap-1">
<Switch
checked={showAllRules}
onCheckedChange={setShowAllRules}
className="h-3.5 w-7 data-[state=checked]:bg-primary [&>span]:h-2.5 [&>span]:w-2.5"
/>
<Label className="cursor-pointer text-[10px] text-muted-foreground" onClick={() => setShowAllRules(!showAllRules)}>
</Label>
</div>
</div>
<Select <Select
value={m.numberingRuleId || "__none__"} value={m.numberingRuleId || "__none__"}
onValueChange={(v) => onValueChange={(v) =>
@ -1262,11 +1294,19 @@ function SaveTabContent({
<SelectItem value="__none__" className="text-xs"> <SelectItem value="__none__" className="text-xs">
</SelectItem> </SelectItem>
{numberingRules.map((r) => ( {showAllRules
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs"> ? allNumberingRules.map((r) => (
{r.ruleName || r.ruleId} <SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
</SelectItem> {r.ruleName || r.ruleId}
))} <span className="ml-1 text-muted-foreground">({r.tableName || "-"})</span>
</SelectItem>
))
: numberingRules.map((r) => (
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
{r.ruleName || r.ruleId}
</SelectItem>
))
}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -1690,12 +1730,13 @@ function FieldItemEditor({
/> />
)} )}
{/* auto 전용: 채번 설정 */} {/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
{field.inputType === "auto" && ( {field.inputType === "auto" && (
<AutoNumberEditor <div className="rounded border bg-muted/30 p-2">
config={field.autoNumber} <p className="text-[10px] text-muted-foreground">
onUpdate={(autoNumber) => onUpdate({ autoNumber })} [] &gt; .
/> </p>
</div>
)} )}
</div> </div>
)} )}
@ -1948,108 +1989,7 @@ function TableSourceEditor({
); );
} }
// ======================================== // AutoNumberEditor 삭제됨: 채번 규칙은 저장 탭 > 자동생성 필드에서 관리
// AutoNumberEditor: 자동 채번 설정
// ========================================
function AutoNumberEditor({
config,
onUpdate,
}: {
config?: AutoNumberConfig;
onUpdate: (config: AutoNumberConfig) => void;
}) {
const current: AutoNumberConfig = config || {
prefix: "",
dateFormat: "YYYYMMDD",
separator: "-",
sequenceDigits: 3,
};
return (
<div className="space-y-2 rounded border bg-muted/30 p-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={current.prefix || ""}
onChange={(e) => onUpdate({ ...current, prefix: e.target.value })}
placeholder="IN-"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={current.dateFormat || "YYYYMMDD"}
onValueChange={(v) => onUpdate({ ...current, dateFormat: v })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYYMMDD" className="text-xs">
YYYYMMDD
</SelectItem>
<SelectItem value="YYMMDD" className="text-xs">
YYMMDD
</SelectItem>
<SelectItem value="YYMM" className="text-xs">
YYMM
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={current.separator || ""}
onChange={(e) => onUpdate({ ...current, separator: e.target.value })}
placeholder="-"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]">퀀 릿</Label>
<Input
type="number"
value={current.sequenceDigits || 3}
onChange={(e) =>
onUpdate({
...current,
sequenceDigits: Number(e.target.value) || 3,
})
}
min={1}
max={10}
className="mt-0.5 h-7 text-xs"
/>
</div>
</div>
{/* 미리보기 */}
<div className="text-[10px] text-muted-foreground">
:{" "}
<span className="font-mono">
{current.prefix || ""}
{current.separator || ""}
{current.dateFormat === "YYMM"
? "2602"
: current.dateFormat === "YYMMDD"
? "260226"
: "20260226"}
{current.separator || ""}
{"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1
</span>
</div>
</div>
);
}
// ======================================== // ========================================
// JsonKeySelect: JSON 키 드롭다운 (자동 추출) // JsonKeySelect: JSON 키 드롭다운 (자동 추출)

View File

@ -638,6 +638,11 @@ export interface CollectedDataResponse {
export interface SaveMapping { export interface SaveMapping {
targetTable: string; targetTable: string;
columnMapping: Record<string, string>; columnMapping: Record<string, string>;
autoGenMappings?: Array<{
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
}>;
} }
export interface StatusChangeRule { export interface StatusChangeRule {