diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index a3887ab8..2e3d033b 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -311,13 +311,14 @@ router.post( async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; - const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) + const { formData, manualInputValue } = req.body; try { const previewCode = await numberingRuleService.previewCode( ruleId, companyCode, - formData + formData, + manualInputValue ); return res.json({ success: true, data: { generatedCode: previewCode } }); } catch (error: any) { diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index f4175b9d..80a96cb3 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -1086,22 +1086,30 @@ class NumberingRuleService { * @param ruleId 채번 규칙 ID * @param companyCode 회사 코드 * @param formData 폼 데이터 (카테고리 기반 채번 시 사용) + * @param manualInputValue 수동 입력 값 (접두어별 순번 조회용) */ async previewCode( ruleId: string, companyCode: string, - formData?: Record + formData?: Record, + manualInputValue?: string ): Promise { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - // prefix_key 기반 순번 조회 - const prefixKey = await this.buildPrefixKey(rule, formData); + // 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀 + const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual"); + const skipSequenceLookup = hasManualPart && !manualInputValue; + + const manualValues = manualInputValue ? [manualInputValue] : undefined; + const prefixKey = await this.buildPrefixKey(rule, formData, manualValues); const pool = getPool(); - const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); + const currentSeq = skipSequenceLookup + ? 0 + : await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); logger.info("미리보기: prefix_key 기반 순번 조회", { - ruleId, prefixKey, currentSeq, + ruleId, prefixKey, currentSeq, skipSequenceLookup, }); const parts = await Promise.all(rule.parts @@ -1116,7 +1124,8 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; - const nextSequence = currentSeq + 1; + const startFrom = autoConfig.startFrom || 1; + const nextSequence = currentSeq + startFrom; return String(nextSequence).padStart(length, "0"); } @@ -1158,110 +1167,8 @@ class NumberingRuleService { return autoConfig.textValue || "TEXT"; } - case "category": { - // 카테고리 기반 코드 생성 - const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" - const categoryMappings = autoConfig.categoryMappings || []; - - if (!categoryKey || !formData) { - logger.warn("카테고리 키 또는 폼 데이터 없음", { - categoryKey, - hasFormData: !!formData, - }); - return ""; - } - - // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] - : categoryKey; - - // 폼 데이터에서 해당 컬럼의 값 가져오기 - const selectedValue = formData[columnName]; - - logger.info("카테고리 파트 처리", { - categoryKey, - columnName, - selectedValue, - formDataKeys: Object.keys(formData), - mappingsCount: categoryMappings.length, - }); - - if (!selectedValue) { - logger.warn("카테고리 값이 선택되지 않음", { - columnName, - formDataKeys: Object.keys(formData), - }); - return ""; - } - - // 카테고리 매핑에서 해당 값에 대한 형식 찾기 - // selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용) - const selectedValueStr = String(selectedValue); - let mapping = categoryMappings.find((m: any) => { - // ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우) - if (m.categoryValueId?.toString() === selectedValueStr) - return true; - // valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우) - if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) - return true; - // 라벨로 매칭 (폴백) - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - }); - - // 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도 - if (!mapping) { - try { - const pool = getPool(); - const [catTableName, catColumnName] = categoryKey.includes(".") - ? categoryKey.split(".") - : [categoryKey, categoryKey]; - const cvResult = await pool.query( - `SELECT value_id, value_code, value_label FROM category_values - WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, - [catTableName, catColumnName, selectedValueStr] - ); - if (cvResult.rows.length > 0) { - const resolvedId = cvResult.rows[0].value_id; - const resolvedLabel = cvResult.rows[0].value_label; - mapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === String(resolvedId)) return true; - if (m.categoryValueLabel === resolvedLabel) return true; - return false; - }); - if (mapping) { - logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", { - valueCode: selectedValueStr, - resolvedId, - resolvedLabel, - format: mapping.format, - }); - } - } - } catch (lookupError: any) { - logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message }); - } - } - - if (mapping) { - logger.info("카테고리 매핑 적용", { - selectedValue, - format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel, - }); - return mapping.format || ""; - } - - logger.warn("카테고리 매핑을 찾을 수 없음", { - selectedValue, - availableMappings: categoryMappings.map((m: any) => ({ - id: m.categoryValueId, - label: m.categoryValueLabel, - })), - }); - return ""; - } + case "category": + return this.resolveCategoryFormat(autoConfig, formData); case "reference": { const refColumn = autoConfig.referenceColumnName; @@ -1364,7 +1271,9 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; - return String(allocatedSequence).padStart(length, "0"); + const startFrom = autoConfig.startFrom || 1; + const actualSequence = allocatedSequence + startFrom - 1; + return String(actualSequence).padStart(length, "0"); } case "number": { @@ -1401,65 +1310,14 @@ class NumberingRuleService { return autoConfig.textValue || "TEXT"; } - case "category": { - const categoryKey = autoConfig.categoryKey; - const categoryMappings = autoConfig.categoryMappings || []; - - if (!categoryKey || !formData) { - return ""; - } - - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] - : categoryKey; - - const selectedValue = formData[columnName]; - - if (!selectedValue) { - return ""; - } - - const selectedValueStr = String(selectedValue); - let allocMapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === selectedValueStr) return true; - if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - }); - - if (!allocMapping) { - try { - const pool3 = getPool(); - const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; - const cvr3 = await pool3.query( - `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, - [ct3, cc3, selectedValueStr] - ); - if (cvr3.rows.length > 0) { - const rid3 = cvr3.rows[0].value_id; - const rlabel3 = cvr3.rows[0].value_label; - allocMapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === String(rid3)) return true; - if (m.categoryValueLabel === rlabel3) return true; - return false; - }); - } - } catch { /* ignore */ } - } - - if (allocMapping) { - return allocMapping.format || ""; - } - - return ""; - } + case "category": + return this.resolveCategoryFormat(autoConfig, formData); case "reference": { const refColumn = autoConfig.referenceColumnName; if (refColumn && formData && formData[refColumn]) { return String(formData[refColumn]); } - logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] }); return ""; } @@ -1525,57 +1383,12 @@ class NumberingRuleService { return autoConfig.textValue || ""; case "date": return "DATEPART"; - case "category": { - const catKey2 = autoConfig.categoryKey; - const catMappings2 = autoConfig.categoryMappings || []; - - if (!catKey2 || !formData) { - return ""; - } - - const colName2 = catKey2.includes(".") - ? catKey2.split(".")[1] - : catKey2; - const selVal2 = formData[colName2]; - - if (!selVal2) { - return ""; - } - - const selValStr2 = String(selVal2); - let catMapping2 = catMappings2.find((m: any) => { - if (m.categoryValueId?.toString() === selValStr2) return true; - if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true; - if (m.categoryValueLabel === selValStr2) return true; - return false; - }); - - if (!catMapping2) { - try { - const pool2 = getPool(); - const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2]; - const cvr2 = await pool2.query( - `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, - [ct2, cc2, selValStr2] - ); - if (cvr2.rows.length > 0) { - const rid2 = cvr2.rows[0].value_id; - const rlabel2 = cvr2.rows[0].value_label; - catMapping2 = catMappings2.find((m: any) => { - if (m.categoryValueId?.toString() === String(rid2)) return true; - if (m.categoryValueLabel === rlabel2) return true; - return false; - }); - } - } catch { /* ignore */ } - } - - return catMapping2?.format || ""; - } + case "category": + return this.resolveCategoryFormat(autoConfig, formData); case "reference": { - const refCol2 = autoConfig.referenceColumnName; - if (refCol2 && formData && formData[refCol2]) { - return String(formData[refCol2]); + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); } return ""; } @@ -1622,6 +1435,60 @@ class NumberingRuleService { return extractedValues; } + /** + * 카테고리 매핑에서 format 값을 해석 + * categoryKey + formData로 선택된 값을 찾고, 매핑 테이블에서 format 반환 + */ + private async resolveCategoryFormat( + autoConfig: Record, + formData?: Record + ): Promise { + const categoryKey = autoConfig.categoryKey; + const categoryMappings = autoConfig.categoryMappings || []; + + if (!categoryKey || !formData) return ""; + + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] + : categoryKey; + const selectedValue = formData[columnName]; + + if (!selectedValue) return ""; + + const selectedValueStr = String(selectedValue); + let mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) return true; + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + + // 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환 + if (!mapping) { + try { + const pool = getPool(); + const [tableName, colName] = categoryKey.includes(".") + ? categoryKey.split(".") + : [categoryKey, categoryKey]; + const result = await pool.query( + `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [tableName, colName, selectedValueStr] + ); + if (result.rows.length > 0) { + const resolvedId = result.rows[0].value_id; + const resolvedLabel = result.rows[0].value_label; + mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(resolvedId)) return true; + if (m.categoryValueLabel === resolvedLabel) return true; + return false; + }); + } + } catch { /* ignore */ } + } + + return mapping?.format || ""; + } + private formatDate(date: Date, format: string): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); diff --git a/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md index b3337f9e..0cac81c2 100644 --- a/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md +++ b/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md @@ -194,17 +194,17 @@ sequenceDiagram | 파일 | 변경 내용 | 규모 | |------|----------|------| -| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey`에 `manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경 | ~60줄 | +| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey`에 `manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode`에 `manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 | +| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 | +| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode`에 `manualInputValue` 파라미터 추가 | ~3줄 | +| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 | | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 | -> `TextInputComponent.tsx` 변경 불필요. `_numberingRuleId`가 유지되고 있으며, 수동 값 추출도 정상 동작 확인됨. -> 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요. - ### buildPrefixKey 호출부 영향 분석 | 호출부 | 위치 | `manualValues` 전달 | 영향 | |--------|------|---------------------|------| -| `previewCode` | L1091 | 미전달 (undefined) | 변화 없음 | +| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 | | `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 | ### 멀티테넌시 체크 @@ -367,3 +367,54 @@ WHERE generation_method = 'manual' - 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요 - `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지 - 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상 + +--- + +## 실시간 순번 미리보기 (추가 기능) + +### 배경 + +품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함. + +### 목표 동작 + +``` +모달 열림 : -[입력하시오]-005 (startFrom=5 기반 기본 순번) +"ㅇㅇ" 입력 : -[ㅇㅇ]-005 (기존 "ㅇㅇ" 등록 0건) +저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006 (기존 "ㅇㅇ" 등록 1건) +``` + +### 아키텍처 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant V2 as V2Input + participant API as previewNumberingCode + participant BE as numberingRuleService.previewCode + participant DB as numbering_rule_sequences + + User->>V2: 수동 입력 "ㅇㅇ" + Note over V2: 디바운스 300ms + V2->>API: preview(ruleId, formData, "ㅇㅇ") + API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ") + BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"]) + Note over BE: prefix_key = "카테고리|ㅇㅇ" + BE->>DB: getSequenceForPrefix(prefix_key) + DB-->>BE: currentSeq = 0 + Note over BE: nextSequence = 0 + startFrom(5) = 5 + BE-->>API: "-____-005" + API-->>V2: generatedCode + V2->>V2: suffix = "-005" 갱신 + Note over V2: 화면 표시: -[ㅇㅇ]-005 +``` + +### 변경 내용 + +1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신 +2. **백엔드 서비스**: `previewCode`가 `manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회 +3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시 +4. **프론트엔드 API**: `previewNumberingCode`에 `manualInputValue` 파라미터 추가 +5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트 +6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영 +7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소) diff --git a/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md index 9ff76513..1d895989 100644 --- a/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md +++ b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md @@ -120,6 +120,37 @@ 변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공 ``` +### 10. 실시간 순번 미리보기 구현 방식 + +- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신 +- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함 +- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합) +- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일 + +### 11. previewCode에 manualInputValue 전달 + +- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey`에 `[manualInputValue]`로 전달 +- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함 +- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음 + +### 12. 초기 상태에서 레거시 시퀀스 조회 방지 + +- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용 +- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함 +- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5` → `-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회 + +### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회 + +- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달 +- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨 +- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동 + +### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합 + +- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출 +- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험 +- **원칙**: 구조적 변경만 수행 (로직 변경 없음) + ### BULK1이 DB에 남아있는 이유 ``` diff --git a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md index 803c679e..b74eed58 100644 --- a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md +++ b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md @@ -6,7 +6,7 @@ ## 공정 상태 -- 전체 진행률: **85%** (코드 구현 + DB 마이그레이션 완료, 검증 대기) +- 전체 진행률: **95%** (코드 구현 + DB 마이그레이션 + 실시간 미리보기 + 코드 정리 완료, 검증 대기) - 현재 단계: 검증 대기 --- @@ -53,9 +53,24 @@ - [ ] previewCode (미리보기) 동작 영향 없음 확인 - [ ] BULK1이 더 이상 생성되지 않음 확인 -### 7단계: 정리 +### 7단계: 실시간 순번 미리보기 +- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 +- [x] 백엔드 서비스: `previewCode`에 `manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달 +- [x] 프론트엔드 API: `previewNumberingCode`에 `manualInputValue` 파라미터 추가 +- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신 +- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시 +- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영 - [x] 린트 에러 없음 확인 + +### 8단계: 코드 정리 + +- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소) +- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거) +- [x] 린트 에러 없음 확인 + +### 9단계: 정리 + - [x] 계획서/맥락노트/체크리스트 최신화 --- @@ -77,3 +92,8 @@ | 2026-03-11 | 1-4단계 구현 완료 | | 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) | | 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 | +| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) | +| 2026-03-12 | 계맥체 최신화 완료 | +| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 | +| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 | +| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 | diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 2d7c3246..94bd3ea8 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -676,7 +676,7 @@ export const V2Input = forwardRef((props, ref) => // 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달) const currentFormData = formDataRef.current; - const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData); + const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined); if (previewResponse.success && previewResponse.data?.generatedCode) { const generatedCode = previewResponse.data.generatedCode; @@ -764,6 +764,49 @@ export const V2Input = forwardRef((props, ref) => }; }, [columnName, manualInputValue, propsInputType, config.inputType, config.type]); + // 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신 + useEffect(() => { + const inputType = propsInputType || config.inputType || config.type || "text"; + if (inputType !== "numbering") return; + if (!numberingTemplateRef.current?.includes("____")) return; + + const ruleId = numberingRuleIdRef.current; + if (!ruleId) return; + + // 사용자가 한 번도 입력하지 않은 초기 상태면 스킵 + if (!userEditedNumberingRef.current) return; + + const debounceTimer = setTimeout(async () => { + try { + const currentFormData = formDataRef.current; + const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined); + + if (resp.success && resp.data?.generatedCode) { + const newTemplate = resp.data.generatedCode; + if (newTemplate.includes("____")) { + numberingTemplateRef.current = newTemplate; + + const parts = newTemplate.split("____"); + const prefix = parts[0] || ""; + const suffix = parts.length > 1 ? parts.slice(1).join("") : ""; + const combined = prefix + manualInputValue + suffix; + + setAutoGeneratedValue(combined); + onChange?.(combined); + if (onFormDataChange && columnName) { + onFormDataChange(columnName, combined); + } + } + } + } catch { + /* 미리보기 실패 시 기존 suffix 유지 */ + } + }, 300); + + return () => clearTimeout(debounceTimer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualInputValue]); + // 실제 표시할 값 (자동생성 값 또는 props value) const displayValue = autoGeneratedValue ?? value; diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index b0ec38e2..01f0a321 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise, + manualInputValue?: string, ): Promise> { // ruleId 유효성 검사 if (!ruleId || ruleId === "undefined" || ruleId === "null") { @@ -114,6 +115,7 @@ export async function previewNumberingCode( try { const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, { formData: formData || {}, + manualInputValue, }); if (!response.data) { return { success: false, error: "서버 응답이 비어있습니다" };