feat: 수동 입력 코드 처리 개선 및 사용자 입력 코드 전달 기능 추가

- allocateCode 함수에 사용자가 편집한 최종 코드를 전달하여 수동 입력 부분을 추출할 수 있도록 수정하였습니다.
- 여러 컴포넌트에서 사용자 입력 값을 처리할 수 있는 이벤트 리스너를 추가하여, 채번 생성 시 수동 입력 값을 반영하도록 개선하였습니다.
- V2Input 및 관련 컴포넌트에서 formData에 수동 입력 값을 주입하는 로직을 추가하여 사용자 경험을 향상시켰습니다.
- 코드 할당 요청 시 사용자 입력 코드와 폼 데이터를 함께 전달하여, 보다 유연한 코드 할당이 가능하도록 하였습니다.
This commit is contained in:
kjs 2026-02-04 14:12:24 +09:00
parent cf5e233726
commit 52fd370460
12 changed files with 211 additions and 27 deletions

View File

@ -225,12 +225,12 @@ router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequ
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData, userInputCode });
try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) {

View File

@ -886,8 +886,9 @@ class NumberingRuleService {
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력)
return part.manualConfig?.placeholder || "____";
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
// placeholder 텍스트는 프론트엔드에서 별도로 표시
return "____";
}
const autoConfig = part.autoConfig || {};
@ -1014,11 +1015,13 @@ class NumberingRuleService {
* @param ruleId ID
* @param companyCode
* @param formData ( )
* @param userInputCode ( )
*/
async allocateCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
formData?: Record<string, any>,
userInputCode?: string
): Promise<string> {
const pool = getPool();
const client = await pool.connect();
@ -1029,11 +1032,77 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual");
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
// 프리뷰 코드를 생성해서 ____ 위치 파악
const previewParts = rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length); // 순번 자리 표시
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART"; // 날짜 자리 표시
default:
return "";
}
});
const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator);
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
// prefix 이후 부분 추출
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
// suffix 이전까지가 수동 입력 값
if (suffix) {
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(remainingCode.slice(0, manualEndIndex));
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
}
logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`);
}
let manualPartIndex = 0;
const parts = rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "";
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || "";
manualPartIndex++;
return manualValue;
}
const autoConfig = part.autoConfig || {};

View File

@ -127,6 +127,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0);
// 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너
useEffect(() => {
const handleNumberingValueChanged = (event: CustomEvent) => {
const { columnName, value } = event.detail;
if (columnName && modalState.isOpen) {
setFormData((prev) => ({
...prev,
[columnName]: value,
}));
}
};
window.addEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
return () => {
window.removeEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
};
}, [modalState.isOpen]);
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
@ -140,6 +158,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
} = event.detail;
// 🆕 모달 열린 시간 기록
@ -163,7 +182,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
if (editData) {
// 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능
if (editData && !isCreateMode) {
// 🆕 배열인 경우 두 가지 데이터를 설정:
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
@ -177,6 +197,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
} else if (editData && isCreateMode) {
// 🆕 복사 모드: formData만 설정하고 originalData는 null로 유지 (채번 생성 가능)
if (Array.isArray(editData)) {
const firstRecord = editData[0] || {};
setFormData(firstRecord);
setSelectedData(editData);
} else {
setFormData(editData);
setSelectedData([editData]);
}
setOriginalData(null); // 🔧 복사 모드에서는 originalData를 null로 설정
} else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함

View File

@ -772,12 +772,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = dataToSave[fieldName] as string;
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}, 사용자입력: ${userInputCode}`);
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]}${newCode}`);
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${userInputCode}${newCode}`);
dataToSave[fieldName] = newCode;
} else {
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);

View File

@ -700,9 +700,10 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
);
// 🆕 채번 API 호출 (비동기)
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId);
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
@ -831,7 +832,8 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
if (match) {
const ruleId = match[1];
try {
const result = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const result = await allocateNumberingCode(ruleId, undefined, newRow);
if (result.success && result.data?.generatedCode) {
newRow[key] = result.data.generatedCode;
} else {

View File

@ -625,6 +625,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, columnName, isEditMode, categoryValuesForNumbering]);
// 🆕 beforeFormSave 이벤트 리스너 - 저장 직전에 현재 조합된 값을 formData에 주입
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering" || !columnName) return;
const handleBeforeFormSave = (event: CustomEvent) => {
const template = numberingTemplateRef.current;
if (!template || !template.includes("____")) return;
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 현재 조합된 값 생성
const currentValue = templatePrefix + manualInputValue + templateSuffix;
// formData에 직접 주입
if (event.detail?.formData && columnName) {
event.detail.formData[columnName] = currentValue;
console.log("🔧 [V2Input] beforeFormSave에서 채번 값 주입:", {
columnName,
manualInputValue,
currentValue,
});
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;
@ -769,7 +803,19 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const newValue = templatePrefix + newUserInput + templateSuffix;
userEditedNumberingRef.current = true;
setAutoGeneratedValue(newValue);
// 모든 방법으로 formData 업데이트 시도
onChange?.(newValue);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newValue);
}
// 커스텀 이벤트로도 전달 (최후의 보루)
if (typeof window !== "undefined" && columnName) {
window.dispatchEvent(new CustomEvent("numberingValueChanged", {
detail: { columnName, value: newValue }
}));
}
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"

View File

@ -567,9 +567,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
);
// 🆕 채번 API 호출 (비동기)
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId);
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
@ -690,7 +691,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (match) {
const ruleId = match[1];
try {
const result = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const result = await allocateNumberingCode(ruleId, undefined, newRow);
if (result.success && result.data?.generatedCode) {
newRow[key] = result.data.generatedCode;
} else {

View File

@ -139,12 +139,20 @@ export async function previewNumberingCode(
/**
* ( )
*
* @param ruleId ID
* @param userInputCode ( )
* @param formData (/ )
*/
export async function allocateNumberingCode(
ruleId: string
ruleId: string,
userInputCode?: string,
formData?: Record<string, any>
): Promise<ApiResponse<{ generatedCode: string }>> {
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`);
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, {
userInputCode,
formData,
});
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "코드 할당 실패" };

View File

@ -856,8 +856,10 @@ export function RepeatScreenModalComponent({
});
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
const userInputCode = newRowData[rowNumbering.targetColumn] as string;
const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData);
if (response.success && response.data) {
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;

View File

@ -1443,8 +1443,9 @@ export function UniversalFormModalComponent({
if (isNewRecord || hasNoValue) {
try {
// allocateNumberingCode로 실제 순번 증가
const response = await allocateNumberingCode(field.numberingRule.ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = mainData[field.columnName] as string;
const response = await allocateNumberingCode(field.numberingRule.ruleId, userInputCode, mainData);
if (response.success && response.data?.generatedCode) {
mainData[field.columnName] = response.data.generatedCode;
}

View File

@ -25,8 +25,20 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
// 값 변경 핸들러
const handleChange = (value: any) => {
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
columnName,
value,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
} else {
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
columnName,
});
}
};

View File

@ -737,7 +737,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
try {
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = context.formData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, context.formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
@ -1030,7 +1032,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = formData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
@ -2054,7 +2058,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = commonFieldsData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
@ -3485,10 +3491,13 @@ export class ButtonActionExecutor {
const screenModalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
title: isCreateMode ? config.editModalTitle || "데이터 복사" : config.editModalTitle || "데이터 수정",
description: description,
size: config.modalSize || "lg",
editData: rowData, // 🆕 수정 데이터 전달
// 🔧 복사 모드에서는 editData 대신 splitPanelParentData로 전달하여 채번이 생성되도록 함
editData: isCreateMode ? undefined : rowData,
splitPanelParentData: isCreateMode ? rowData : undefined,
isCreateMode: isCreateMode, // 🆕 복사 모드 플래그 전달
},
});
window.dispatchEvent(screenModalEvent);