From 52fd37046098ad8a18f2bf1e908b7671f6d2e1c9 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 14:12:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EB=8F=99=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=84=EB=8B=AC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allocateCode 함수에 사용자가 편집한 최종 코드를 전달하여 수동 입력 부분을 추출할 수 있도록 수정하였습니다. - 여러 컴포넌트에서 사용자 입력 값을 처리할 수 있는 이벤트 리스너를 추가하여, 채번 생성 시 수동 입력 값을 반영하도록 개선하였습니다. - V2Input 및 관련 컴포넌트에서 formData에 수동 입력 값을 주입하는 로직을 추가하여 사용자 경험을 향상시켰습니다. - 코드 할당 요청 시 사용자 입력 코드와 폼 데이터를 함께 전달하여, 보다 유연한 코드 할당이 가능하도록 하였습니다. --- .../controllers/numberingRuleController.ts | 6 +- .../src/services/numberingRuleService.ts | 77 ++++++++++++++++++- frontend/components/common/ScreenModal.tsx | 33 +++++++- frontend/components/screen/EditModal.tsx | 8 +- .../components/unified/UnifiedRepeater.tsx | 8 +- frontend/components/v2/V2Input.tsx | 46 +++++++++++ frontend/components/v2/V2Repeater.tsx | 8 +- frontend/lib/api/numberingRule.ts | 12 ++- .../RepeatScreenModalComponent.tsx | 4 +- .../UniversalFormModalComponent.tsx | 5 +- .../components/v2-input/V2InputRenderer.tsx | 12 +++ frontend/lib/utils/buttonActions.ts | 19 +++-- 12 files changed, 211 insertions(+), 27 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index f5cbc91a..a8f99b36 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -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) { diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index abdfd739..0bdec037 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -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 + formData?: Record, + userInputCode?: string ): Promise { 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 || {}; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index dbb1e923..3a8958c8 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -127,6 +127,24 @@ export const ScreenModal: React.FC = ({ className }) => { // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) const modalOpenedAtRef = React.useRef(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 = ({ className }) => { splitPanelParentData, selectedData: eventSelectedData, selectedIds, + isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) } = event.detail; // 🆕 모달 열린 시간 기록 @@ -163,7 +182,8 @@ export const ScreenModal: React.FC = ({ 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 = ({ 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 등)만 전달해야 함 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index db722991..5856df0e 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -772,12 +772,14 @@ export const EditModal: React.FC = ({ 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); diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index 606d1730..d802baa7 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -700,9 +700,10 @@ export const UnifiedRepeater: React.FC = ({ ); // 🆕 채번 API 호출 (비동기) - const generateNumberingCode = useCallback(async (ruleId: string): Promise => { + // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 + const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { 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 = ({ 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 { diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index c19b3820..03a58c78 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -625,6 +625,40 @@ export const V2Input = forwardRef((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((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" diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index ee80d0d7..5c66ba00 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -567,9 +567,10 @@ export const V2Repeater: React.FC = ({ ); // 🆕 채번 API 호출 (비동기) - const generateNumberingCode = useCallback(async (ruleId: string): Promise => { + // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 + const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { 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 = ({ 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 { diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 3a9b7930..0800e752 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -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 ): Promise> { 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 || "코드 할당 실패" }; diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 16cf7dfc..0cfdd542 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -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; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index ca4d57d0..0f5c851b 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -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; } diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 52a230fa..83a2f761 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -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, + }); } }; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index b1d66eea..869bdd0a 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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);