From 8b7e31031dfccded90359f6b93b6715d42c96d34 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Thu, 12 Mar 2026 10:12:56 +0900 Subject: [PATCH 01/10] refactor: Improve numbering rule service for manual prefix handling and sequence allocation - Modified the `buildPrefixKey` function to include an optional `manualValues` parameter, allowing manual input values to be incorporated into the prefix key. - Adjusted the sequence allocation process in `allocateCode` to extract manual values before building the prefix key, ensuring accurate prefix generation. - Removed the fallback to the "BULK1" value in manual configurations, preventing unintended overwrites and ensuring user input is prioritized. - Enhanced the `joinPartsWithSeparators` function to prevent consecutive separators when handling empty parts, improving the output format. - Added a new migration script to clean up existing "BULK1" values from the database, ensuring data integrity. These changes address several issues related to manual input handling and improve the overall functionality of the numbering rule service. --- .../src/services/numberingRuleService.ts | 287 ++++++++------ .../MPN[계획]-품번-수동접두어채번.md | 369 ++++++++++++++++++ .../MPN[맥락]-품번-수동접두어채번.md | 130 ++++++ .../MPN[체크]-품번-수동접두어채번.md | 79 ++++ 4 files changed, 736 insertions(+), 129 deletions(-) create mode 100644 docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md create mode 100644 docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md create mode 100644 docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 91ae4cb5..f4175b9d 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa result += val; if (idx < partValues.length - 1) { const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; - result += sep; + if (val || !result.endsWith(sep)) { + result += sep; + } } }); return result; @@ -74,16 +76,22 @@ class NumberingRuleService { */ private async buildPrefixKey( rule: NumberingRuleConfig, - formData?: Record + formData?: Record, + manualValues?: string[] ): Promise { const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); const prefixParts: string[] = []; + let manualIndex = 0; for (const part of sortedParts) { if (part.partType === "sequence") continue; if (part.generationMethod === "manual") { - // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) + const manualValue = manualValues?.[manualIndex] || ""; + manualIndex++; + if (manualValue) { + prefixParts.push(manualValue); + } continue; } @@ -1302,11 +1310,29 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - // prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성 - const prefixKey = await this.buildPrefixKey(rule, formData); + // 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능) + const manualParts = rule.parts.filter( + (p: any) => p.generationMethod === "manual" + ); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + extractedManualValues = await this.extractManualValuesFromInput( + rule, userInputCode, formData + ); + + // 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만) + if (extractedManualValues.length === 0 && manualParts.length === 1) { + extractedManualValues = [userInputCode]; + logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode }); + } + } + + // 2단계: prefix_key 빌드 (수동 값 포함) + const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); - // 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 + // 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 let allocatedSequence = 0; if (hasSequence) { allocatedSequence = await this.incrementSequenceForPrefix( @@ -1320,136 +1346,15 @@ class NumberingRuleService { } logger.info("allocateCode: prefix_key 기반 순번 할당", { - ruleId, prefixKey, allocatedSequence, + ruleId, prefixKey, allocatedSequence, extractedManualValues, }); - // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 - const manualParts = rule.parts.filter( - (p: any) => p.generationMethod === "manual" - ); - let extractedManualValues: string[] = []; - - if (manualParts.length > 0 && userInputCode) { - const previewParts = await Promise.all(rule.parts - .sort((a: any, b: any) => a.order - b.order) - .map(async (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"; - case "category": { - const catKey2 = autoConfig.categoryKey; - const catMappings2 = autoConfig.categoryMappings || []; - - if (!catKey2 || !formData) { - return "CATEGORY"; - } - - const colName2 = catKey2.includes(".") - ? catKey2.split(".")[1] - : catKey2; - const selVal2 = formData[colName2]; - - if (!selVal2) { - return "CATEGORY"; - } - - 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 || "CATEGORY"; - } - case "reference": { - const refCol2 = autoConfig.referenceColumnName; - if (refCol2 && formData && formData[refCol2]) { - return String(formData[refCol2]); - } - return "REF"; - } - default: - return ""; - } - })); - - const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); - const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); - - 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]; - - if (prefix && remainingCode.startsWith(prefix)) { - remainingCode = remainingCode.slice(prefix.length); - } - - if (suffix) { - 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 = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { if (part.generationMethod === "manual") { - const manualValue = - extractedManualValues[manualPartIndex] || - part.manualConfig?.value || - ""; + const manualValue = extractedManualValues[manualPartIndex] || ""; manualPartIndex++; return manualValue; } @@ -1593,6 +1498,130 @@ class NumberingRuleService { return this.allocateCode(ruleId, companyCode); } + /** + * 사용자 입력 코드에서 수동 파트 값을 추출 + * 템플릿 기반 파싱으로 수동 입력 위치("____")에 해당하는 값을 분리 + */ + private async extractManualValuesFromInput( + rule: NumberingRuleConfig, + userInputCode: string, + formData?: Record + ): Promise { + const extractedValues: string[] = []; + + const previewParts = await Promise.all(rule.parts + .sort((a: any, b: any) => a.order - b.order) + .map(async (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"; + 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 "reference": { + const refCol2 = autoConfig.referenceColumnName; + if (refCol2 && formData && formData[refCol2]) { + return String(formData[refCol2]); + } + return ""; + } + default: + return ""; + } + })); + + const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); + + 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]; + + if (prefix && remainingCode.startsWith(prefix)) { + remainingCode = remainingCode.slice(prefix.length); + } + + if (suffix) { + const suffixStart = suffix.replace(/X+|DATEPART/g, ""); + const manualEndIndex = suffixStart + ? remainingCode.indexOf(suffixStart) + : remainingCode.length; + if (manualEndIndex > 0) { + extractedValues.push( + remainingCode.slice(0, manualEndIndex) + ); + remainingCode = remainingCode.slice(manualEndIndex); + } + } else { + extractedValues.push(remainingCode); + } + } + } + + logger.info( + `수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}` + ); + + return extractedValues; + } + 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 new file mode 100644 index 00000000..b3337f9e --- /dev/null +++ b/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md @@ -0,0 +1,369 @@ +# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md) + +## 개요 + +기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다. + +1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐 +2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함 +3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨 + +--- + +## 현재 동작 + +### 채번 규칙 구성 (옵션설정 > 코드설정) + +``` +규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5) +``` + +### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시) + +1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128) +2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"` +3. 저장 클릭 → `buttonActions.ts`가 `_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출 +4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치) +5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨** +6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용 +7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터) + +### 문제 1: 순번 공유 (buildPrefixKey) + +**위치**: `numberingRuleService.ts` L85-88 + +```typescript +if (part.generationMethod === "manual") { + // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) + continue; // ← 접두어별 순번 분리를 막는 원인 +} +``` + +이 `continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다. +"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다. + +### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백) + +**발생 흐름**: + +1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출 +2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442) +3. 템플릿 기반 수동 값 추출 시도 (L1411-1436): + ``` + 템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번) + 사용자 입력: "ㅁㅁㅁ" + ``` +4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패** → `extractedManualValues = []` +5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작: + ```typescript + const manualValue = + extractedManualValues[0] || // undefined (추출 실패) + part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐 + ""; + ``` +6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨) + +**DB 숨은 값 원인**: +- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨 +- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음** +- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨 + +### 문제 3: 연속 구분자(--) 문제 + +**발생 흐름**: + +1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열) +2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생 +3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`) +4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용) +5. 입력 `-제발-015`이 `CATEGORY-`로 시작하지 않음 → 추출 실패 +6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨 +7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003` + +### 정상 동작 확인된 부분 + +| 항목 | 상태 | 근거 | +|------|------|------| +| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 | +| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) | +| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 | + +### 비정상 확인된 부분 + +| 항목 | 상태 | 근거 | +|------|------|------| +| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 | +| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 | +| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 | + +--- + +## 변경 후 동작 + +### prefix_key에 수동 파트 값 포함 + +``` +현재: prefix_key = 카테고리값만 (수동 파트 무시) +변경: prefix_key = 카테고리값 + "|" + 수동입력값 +``` + +### allocateCode 실행 순서 변경 + +``` +현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합 +변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합 +``` + +### 순번 동작 + +``` +"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001 +"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002 +"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001 +"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003 +``` + +### BULK1 폴백 제거 (코드 + DB 이중 조치) + +``` +코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용 +DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리 +``` + +### 연속 구분자 방지 + 템플릿 정합성 복원 + +``` +joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음 +extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신) +→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지 +``` + +--- + +## 시각적 예시 + +| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 | +|------------|----------|------|-------------| +| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` | +| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` | +| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` | +| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) | +| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` | + +--- + +## 아키텍처 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant BA as buttonActions.ts + participant API as allocateNumberingCode API + participant NRS as numberingRuleService + participant DB as numbering_rule_sequences + + User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ") + BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData) + API->>NRS: allocateCode() + + Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행) + NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ") + Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용 + NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"] + + Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함) + NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"]) + Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ" + + Note over NRS: 3단계: 시퀀스 할당 + NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ") + DB-->>NRS: current_sequence = 1 + + Note over NRS: 4단계: 코드 조합 + NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001" + NRS-->>API: "카테고리값-ㅁㅁㅁ-001" + API-->>BA: generatedCode + BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001" +``` + +--- + +## 변경 대상 파일 + +| 파일 | 변경 내용 | 규모 | +|------|----------|------| +| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey`에 `manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경 | ~60줄 | +| `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) | 변화 없음 | +| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 | + +### 멀티테넌시 체크 + +| 항목 | 상태 | 근거 | +|------|------|------| +| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 | +| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 | +| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 | +| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 | + +--- + +## 코드 설계 + +### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지 + +**위치**: L36-48 +**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음 + +```typescript +function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string { + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; + if (val || !result.endsWith(sep)) { + result += sep; + } + } + }); + return result; +} +``` + +### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함 + +**위치**: L75-88 +**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함. + +```typescript +private async buildPrefixKey( + rule: NumberingRuleConfig, + formData?: Record, + manualValues?: string[] +): Promise { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const prefixParts: string[] = []; + let manualIndex = 0; + + for (const part of sortedParts) { + if (part.partType === "sequence") continue; + + if (part.generationMethod === "manual") { + const manualValue = manualValues?.[manualIndex] || ""; + manualIndex++; + if (manualValue) { + prefixParts.push(manualValue); + } + continue; + } + + // ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ... + } + + return prefixParts.join("|"); +} +``` + +**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음. + +### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리 + +**위치**: L1290-1584 +**핵심 변경 2가지**: + +(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경. + +(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거. + +```typescript +async allocateCode(ruleId, companyCode, formData?, userInputCode?) { + // ... 규칙 조회 ... + + // 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행) + const manualParts = rule.parts.filter(p => p.generationMethod === "manual"); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + extractedManualValues = await this.extractManualValuesFromInput( + rule, userInputCode, formData + ); + + // 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용 + if (extractedManualValues.length === 0 && manualParts.length === 1) { + extractedManualValues = [userInputCode]; + } + } + + // 2단계: 수동 값을 포함하여 prefix_key 빌드 + const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); + + // 3단계: 시퀀스 할당 (기존 로직 그대로) + + // 4단계: 코드 조합 (manualConfig.value 폴백 제거) + // 기존: extractedManualValues[i] || part.manualConfig?.value || "" + // 변경: extractedManualValues[i] || "" +} +``` + +### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원 + +기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출. +로직 자체는 변경 없음, 위치만 이동. +카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴. + +```typescript +private async extractManualValuesFromInput( + rule: NumberingRuleConfig, + userInputCode: string, + formData?: Record +): Promise { + // 기존 L1332-1442의 로직을 그대로 이동 + // 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환 + // → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상 +} +``` + +### 5. DB 마이그레이션 - BULK1 유령 기본값 제거 + +**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql` + +`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다. + +```sql +-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리) +UPDATE numbering_rule_parts +SET manual_config = manual_config - 'value' +WHERE generation_method = 'manual' + AND manual_config ? 'value' + AND manual_config->>'value' = 'BULK1'; +``` + +> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거. +> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨. +> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보. + +--- + +## 설계 원칙 + +- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건 +- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리 +- `buildPrefixKey`의 `manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음 +- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님 +- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음 +- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지 +- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름) +- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요 +- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지 +- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상 diff --git a/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md new file mode 100644 index 00000000..9ff76513 --- /dev/null +++ b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md @@ -0,0 +1,130 @@ +# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md) + +--- + +## 왜 이 작업을 하는가 + +- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨 +- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함 +- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함 +- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 수동 값 추출을 buildPrefixKey 전으로 이동 + +- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경 +- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함 +- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향) + +### 2. buildPrefixKey에 수동 파트 값 포함 + +- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함 +- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함 +- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음 + +### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 + +- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용 +- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임 +- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음) + +### 4. 코드 조합에서 manualConfig.value 폴백 제거 + +- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""` → `extractedManualValues[i] || ""` +- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx`에 `value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨 +- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리 + +### 5. DB 마이그레이션은 BULK1만 타겟팅 + +- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정 +- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지 +- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요) + +### 6. extractManualValuesFromInput 헬퍼 분리 + +- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출 +- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움 +- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리) + +### 7. 프론트엔드 변경 불필요 + +- **결정**: 프론트엔드 코드 수정 없음 +- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음 + +### 8. joinPartsWithSeparators 연속 구분자 방지 + +- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음 +- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지 +- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵 + +### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경 + +- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"` → `""`로 변경 +- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]` +- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) | +| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 | +| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 | +| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 | +| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 | +| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 | + +--- + +## 기술 참고 + +### allocateCode 실행 순서 (변경 전 → 후) + +``` +변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합 +변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합 +``` + +### prefix_key 구성 (변경 전 → 후) + +``` +변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키) +변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키) +``` + +### 폴백 체인 (변경 전 → 후) + +``` +변경 전: extractedManualValues[i] || manualConfig.value || "" +변경 후: extractedManualValues[i] || "" +``` + +### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후) + +``` +변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ" +변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ" +``` + +### 템플릿 정합성 (변경 전 → 후) + +``` +변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패 +변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공 +``` + +### BULK1이 DB에 남아있는 이유 + +``` +ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음) +플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue } +→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨 +→ UI에서 제거 불가능한 유령 값 +``` diff --git a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md new file mode 100644 index 00000000..803c679e --- /dev/null +++ b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md @@ -0,0 +1,79 @@ +# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) + +--- + +## 공정 상태 + +- 전체 진행률: **85%** (코드 구현 + DB 마이그레이션 완료, 검증 대기) +- 현재 단계: 검증 대기 + +--- + +## 구현 체크리스트 + +### 1단계: 구조적 변경 (행위 변경 없음) + +- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리 +- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체 +- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음) + +### 2단계: buildPrefixKey 수정 + +- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가 +- [x] 수동 파트 처리 로직 변경: `continue` → `manualValues`에서 값 꺼내 `prefixParts`에 추가 +- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터) + +### 3단계: allocateCode 순서 변경 + 폴백 정리 + +- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동 +- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가 +- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달 +- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거 + +### 4단계: DB 마이그레이션 + +- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성 +- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거 +- [x] 마이그레이션 실행 (9건 정리 완료) + +### 5단계: 연속 구분자(--) 방지 + +- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가 +- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성) + +### 6단계: 검증 + +- [ ] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인 +- [ ] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님) +- [ ] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인 +- [ ] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인 +- [ ] 수동 파트 없는 채번 규칙 동작 영향 없음 확인 +- [ ] previewCode (미리보기) 동작 영향 없음 확인 +- [ ] BULK1이 더 이상 생성되지 않음 확인 + +### 7단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 계획서/맥락노트/체크리스트 최신화 + +--- + +## 알려진 이슈 (보류) + +| 이슈 | 설명 | 상태 | +|------|------|------| +| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 | +| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 | + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-11 | 1-4단계 구현 완료 | +| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) | +| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 | From 1a11b08487301d8d59f7b017da59e51d6027e47d Mon Sep 17 00:00:00 2001 From: syc0123 Date: Thu, 12 Mar 2026 16:07:13 +0900 Subject: [PATCH 02/10] feat: implement real-time numbering preview with manual input handling - Enhanced the `previewCode` endpoint to accept a new `manualInputValue` parameter, allowing for dynamic sequence generation based on user input. - Updated the `NumberingRuleService` to skip legacy sequence lookups when manual input is not provided, ensuring accurate initial sequence display. - Integrated debounce functionality in the `V2Input` component to optimize API calls for real-time suffix updates as users type. - Refactored category resolution logic into a helper function to reduce code duplication and improve maintainability. These changes significantly improve the user experience by providing immediate feedback on numbering sequences based on manual inputs. --- .../controllers/numberingRuleController.ts | 5 +- .../src/services/numberingRuleService.ts | 295 +++++------------- .../MPN[계획]-품번-수동접두어채번.md | 61 +++- .../MPN[맥락]-품번-수동접두어채번.md | 31 ++ .../MPN[체크]-품번-수동접두어채번.md | 24 +- frontend/components/v2/V2Input.tsx | 45 ++- frontend/lib/api/numberingRule.ts | 2 + 7 files changed, 239 insertions(+), 224 deletions(-) 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: "서버 응답이 비어있습니다" }; From a2c532c7c7676cfacc8a1c7de1fcbbcb9ef68100 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 12 Mar 2026 18:26:47 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20+=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=95=A1=EC=85=98=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?+=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20UI=20=EB=8F=99=EC=8B=9C=20=EC=A0=91=EC=88=98=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80(preCondition=20WHERE=20+=20409?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC),=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EC=8B=9C=EC=97=90=EB=A7=8C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=99=9C=EC=84=B1=ED=99=94(owner-match=20showCondi?= =?UTF-8?q?tion),=20=EB=B3=B8=EC=9D=B8=20=EC=B9=B4=EB=93=9C=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=A0=95=EB=A0=AC(ownerSortColumn)=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=98=EA=B3=A0=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=EC=97=90=EC=84=9C=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20UI=203=EC=A2=85?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.=20[=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C]=20-=20popActionRoutes:=20TaskBody=EC=97=90?= =?UTF-8?q?=20preCondition=20=EC=B6=94=EA=B0=80,=20data-update=20WHERE=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=82=BD=EC=9E=85,=20=20=20rowCount=3D0?= =?UTF-8?q?=20=EC=8B=9C=20409=20Conflict=20=EB=B0=98=ED=99=98=20(isPreCond?= =?UTF-8?q?itionFail)=20[=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20-?= =?UTF-8?q?=20=EB=9F=B0=ED=83=80=EC=9E=84]=20-=20types.ts:=20ActionPreCond?= =?UTF-8?q?ition=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4,=20owner-?= =?UTF-8?q?match=20=ED=83=80=EC=9E=85,=20ownerSortColumn=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20-=20cell-renderers:=20evaluateShowCondition?= =?UTF-8?q?=EC=97=90=20owner-match=20=EB=B6=84=EA=B8=B0=20+=20currentUserI?= =?UTF-8?q?d=20prop=20-=20PopCardListV2Component:=20useAuth=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99,=20preCondition=20=EC=A0=84=EB=8B=AC/409=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC,=20=20=20ownerSortColumn=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=A0=95=EB=A0=AC,=20currentUserId=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EC=A0=84=EB=8B=AC=20[=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20-=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=84=A4=EC=A0=95=20UI]=20-=20PopCardListV2Config:?= =?UTF-8?q?=20showCondition=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=97=90=20"=EC=86=8C=EC=9C=A0=EC=9E=90=20=EC=9D=BC=EC=B9=98"?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20+=20=EC=BB=AC=EB=9F=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D,=20=20=20ImmediateActionEditor=EC=97=90=20"=EC=82=AC?= =?UTF-8?q?=EC=A0=84=20=EC=A1=B0=EA=B1=B4(=EC=A4=91=EB=B3=B5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80)"=20=ED=86=A0=EA=B8=80=20+=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC/=EA=B8=B0=EB=8C=80=EA=B0=92/=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A9=94=EC=8B=9C=EC=A7=80,=20=20=20TabActions?= =?UTF-8?q?=EC=97=90=20"=EC=86=8C=EC=9C=A0=EC=9E=90=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC"=20=EC=BB=AC=EB=9F=BC=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EB=8B=A4=EC=9A=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 59 +++- .../PopCardListV2Component.tsx | 168 ++++++++++- .../pop-card-list-v2/PopCardListV2Config.tsx | 272 ++++++++++++++++-- .../pop-card-list-v2/cell-renderers.tsx | 10 +- frontend/lib/registry/pop-components/types.ts | 31 +- 5 files changed, 494 insertions(+), 46 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index d25c6bdc..669cc960 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -104,6 +104,11 @@ interface TaskBody { manualItemField?: string; manualPkColumn?: string; cartScreenId?: string; + preCondition?: { + column: string; + expectedValue: string; + failMessage?: string; + }; } function resolveStatusValue( @@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [resolved, companyCode, lookupValues[i]], + let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const condParams: unknown[] = [resolved, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + condWhere += ` AND "${task.preCondition.column}" = $4`; + condParams.push(task.preCondition.expectedValue); + } + const condResult = await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`, + condParams, ); + if (task.preCondition && condResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } else if (opType === "db-conditional") { - // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (task.preCondition) { + logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", { + taskId: task.id, preCondition: task.preCondition, + }); + } if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; @@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [value, companyCode, lookupValues[i]], + let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const queryParams: unknown[] = [value, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) { + throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + } + whereSql += ` AND "${task.preCondition.column}" = $4`; + queryParams.push(task.preCondition.expectedValue); + } + const updateResult = await client.query( + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`, + queryParams, ); + if (task.preCondition && updateResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } @@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp }); } catch (error: any) { await client.query("ROLLBACK"); + + if (error.isPreConditionFail) { + logger.warn("[pop/execute-action] preCondition 실패", { message: error.message }); + return res.status(409).json({ + success: false, + message: error.message, + errorCode: "PRE_CONDITION_FAIL", + }); + } + logger.error("[pop/execute-action] 오류:", error); return res.status(500).json({ success: false, diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 55829efb..3a52a36e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -34,6 +34,7 @@ import type { TimelineDataSource, ActionButtonUpdate, ActionButtonClickAction, + QuantityInputConfig, StatusValueMapping, SelectModeConfig, SelectModeButtonConfig, @@ -47,6 +48,7 @@ import { screenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; +import { useAuth } from "@/hooks/useAuth"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; @@ -56,6 +58,32 @@ const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopVie type RowData = Record; +function calculateMaxQty( + row: RowData, + processId: string | number | undefined, + cfg?: QuantityInputConfig, +): number { + if (!cfg) return 999999; + const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999; + if (!cfg.currentColumn) return maxVal; + + const processFlow = row.__processFlow__ as Array<{ + isCurrent: boolean; + processId?: string | number; + rawData?: Record; + }> | undefined; + + const currentProcess = processId + ? processFlow?.find((p) => String(p.processId) === String(processId)) + : processFlow?.find((p) => p.isCurrent); + + if (currentProcess?.rawData) { + const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0; + return Math.max(0, maxVal - currentVal); + } + return maxVal; +} + // cart_items 행 파싱 (pop-card-list에서 그대로 차용) function parseCartRow(dbRow: Record): Record { let rowData: Record = {}; @@ -113,6 +141,7 @@ export function PopCardListV2Component({ }: PopCardListV2ComponentProps) { const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); + const { userId: currentUserId } = useAuth(); const isCartListMode = config?.cartListMode?.enabled === true; const [inheritedConfig, setInheritedConfig] = useState | null>(null); @@ -469,7 +498,7 @@ export function PopCardListV2Component({ type: "data-update" as const, targetTable: btnConfig.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -619,11 +648,28 @@ export function PopCardListV2Component({ const scrollAreaRef = useRef(null); + const ownerSortColumn = config?.ownerSortColumn; + const displayCards = useMemo(() => { - if (!isExpanded) return filteredRows.slice(0, visibleCardCount); + let source = filteredRows; + + if (ownerSortColumn && currentUserId) { + const mine: RowData[] = []; + const others: RowData[] = []; + for (const row of source) { + if (String(row[ownerSortColumn] ?? "") === currentUserId) { + mine.push(row); + } else { + others.push(row); + } + } + source = [...mine, ...others]; + } + + if (!isExpanded) return source.slice(0, visibleCardCount); const start = (currentPage - 1) * expandedCardsPerPage; - return filteredRows.slice(start, start + expandedCardsPerPage); - }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + return source.slice(start, start + expandedCardsPerPage); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]); const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; const needsPagination = isExpanded && totalPages > 1; @@ -756,10 +802,17 @@ export function PopCardListV2Component({ if (firstPending) { firstPending.isCurrent = true; } } - return fetchedRows.map((row) => ({ - ...row, - __processFlow__: processMap.get(String(row.id)) || [], - })); + return fetchedRows.map((row) => { + const steps = processMap.get(String(row.id)) || []; + const current = steps.find((s) => s.isCurrent); + const processFields: Record = {}; + if (current?.rawData) { + for (const [key, val] of Object.entries(current.rawData)) { + processFields[`__process_${key}`] = val; + } + } + return { ...row, __processFlow__: steps, ...processFields }; + }); }, []); const fetchData = useCallback(async () => { @@ -1041,6 +1094,7 @@ export function PopCardListV2Component({ onToggleRowSelect={() => toggleRowSelection(row)} onEnterSelectMode={enterSelectMode} onOpenPopModal={openPopModal} + currentUserId={currentUserId} /> ))} @@ -1148,6 +1202,8 @@ interface CardV2Props { onToggleRowSelect?: () => void; onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; onOpenPopModal?: (screenId: string, row: RowData) => void; + currentUserId?: string; + isLockedByOther?: boolean; } function CardV2({ @@ -1155,7 +1211,7 @@ function CardV2({ parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, onRefresh, selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, - onOpenPopModal, + onOpenPopModal, currentUserId, isLockedByOther, }: CardV2Props) { const inputField = config?.inputField; const cartAction = config?.cartAction; @@ -1167,6 +1223,72 @@ function CardV2({ const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); + const [qtyModalState, setQtyModalState] = useState<{ + open: boolean; + row: RowData; + processId?: string | number; + action: ActionButtonClickAction; + } | null>(null); + + const handleQtyConfirm = useCallback(async (value: number) => { + if (!qtyModalState) return; + const { row: actionRow, processId: qtyProcessId, action } = qtyModalState; + setQtyModalState(null); + if (!action.targetTable || !action.updates) return; + + const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk; + if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; } + + const lookupValue = action.joinConfig + ? String(actionRow[action.joinConfig.sourceColumn] ?? rowId) + : rowId; + const lookupColumn = action.joinConfig?.targetColumn || "id"; + + const tasks = action.updates.map((u, idx) => ({ + id: `qty-update-${idx}`, + type: "data-update" as const, + targetTable: action.targetTable!, + targetColumn: u.column, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", + valueSource: "fixed" as const, + fixedValue: u.valueType === "userInput" ? String(value) : + u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: lookupColumn, + manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), + })); + + const targetRow = action.joinConfig + ? { ...actionRow, [lookupColumn]: lookupValue } + : qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow; + + try { + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [targetRow], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) { + toast.success(result.data.message || "처리 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "처리 실패"); + } + } catch (err: unknown) { + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } + } + }, [qtyModalState, onRefresh]); + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); @@ -1365,7 +1487,11 @@ function CardV2({ } for (const action of actionsToRun) { - if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { + if (action.type === "quantity-input" && action.targetTable && action.updates) { + if (action.confirmMessage && !window.confirm(action.confirmMessage)) return; + setQtyModalState({ open: true, row: actionRow, processId, action }); + return; + } else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { if (action.confirmMessage) { if (!window.confirm(action.confirmMessage)) return; } @@ -1381,7 +1507,7 @@ function CardV2({ type: "data-update" as const, targetTable: action.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -1391,6 +1517,7 @@ function CardV2({ lookupMode: "manual" as const, manualItemField: lookupColumn, manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), })); const targetRow = action.joinConfig ? { ...actionRow, [lookupColumn]: lookupValue } @@ -1408,7 +1535,12 @@ function CardV2({ return; } } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } return; } } else if (action.type === "modal-open" && action.modalScreenId) { @@ -1418,6 +1550,7 @@ function CardV2({ }, packageEntries, inputUnit: inputField?.unit, + currentUserId, })} ))} @@ -1437,6 +1570,17 @@ function CardV2({ /> )} + {qtyModalState?.open && ( + { if (!open) setQtyModalState(null); }} + unit={qtyModalState.action.quantityInput?.unit || "EA"} + maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} + showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false} + onConfirm={(value) => handleQtyConfirm(value)} + /> + )} + ); } diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 79d8a31e..04ba0622 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -65,6 +65,33 @@ import { type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +// ===== 컬럼 옵션 그룹 ===== + +interface ColumnOptionGroup { + groupLabel: string; + options: { value: string; label: string }[]; +} + +function renderColumnOptionGroups(groups: ColumnOptionGroup[]) { + if (groups.length <= 1) { + return groups.flatMap((g) => + g.options.map((o) => ( + {o.label} + )) + ); + } + return groups + .filter((g) => g.options.length > 0) + .map((g) => ( + + {g.groupLabel} + {g.options.map((o) => ( + {o.label} + ))} + + )); +} + // ===== Props ===== interface ConfigPanelProps { @@ -271,6 +298,7 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) )} @@ -759,10 +787,36 @@ function TabCardDesign({ sourceTable: j.targetTable, })) ); - const allColumnOptions = [ - ...availableColumns.map((c) => ({ value: c.name, label: c.name })), - ...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid.cells.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const columnOptionGroups: ColumnOptionGroup[] = [ + { + groupLabel: `메인 (${cfg.dataSource.tableName || "테이블"})`, + options: availableColumns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(joinedColumns.length > 0 + ? [{ + groupLabel: "조인", + options: joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + }] + : []), + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), ]; + const allColumnOptions = columnOptionGroups.flatMap((g) => g.options); const [selectedCellId, setSelectedCellId] = useState(null); const [mergeMode, setMergeMode] = useState(false); @@ -1273,6 +1327,7 @@ function TabCardDesign({ cell={selectedCell} allCells={grid.cells} allColumnOptions={allColumnOptions} + columnOptionGroups={columnOptionGroups} columns={columns} selectedColumns={selectedColumns} tables={tables} @@ -1291,6 +1346,7 @@ function CellDetailEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, columns, selectedColumns, tables, @@ -1301,6 +1357,7 @@ function CellDetailEditor({ cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; @@ -1348,9 +1405,7 @@ function CellDetailEditor({ 미지정 - {allColumnOptions.map((o) => ( - {o.label} - ))} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -1417,9 +1472,9 @@ function CellDetailEditor({ {/* 타입별 상세 설정 */} {cell.type === "status-badge" && } {cell.type === "timeline" && } - {cell.type === "action-buttons" && } - {cell.type === "footer-status" && } - {cell.type === "field" && } + {cell.type === "action-buttons" && } + {cell.type === "footer-status" && } + {cell.type === "field" && } {cell.type === "number-input" && (
숫자 입력 설정 @@ -1429,7 +1484,7 @@ function CellDetailEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -1809,12 +1864,14 @@ function ActionButtonsEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, availableTableOptions, onUpdate, }: { cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; availableTableOptions: { value: string; label: string }[]; onUpdate: (partial: Partial) => void; }) { @@ -1975,7 +2032,7 @@ function ActionButtonsEditor({ const isSectionOpen = (key: string) => expandedSections[key] !== false; - const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" }; + const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기", "quantity-input": "수량 입력" }; const getCondSummary = (btn: ActionButtonDef) => { const c = btn.showCondition; @@ -1985,6 +2042,7 @@ function ActionButtonsEditor({ return opt ? opt.label : (c.value || "미설정"); } if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`; + if (c.type === "owner-match") return `소유자(${c.column || "?"})`; return "항상"; }; @@ -2081,8 +2139,21 @@ function ActionButtonsEditor({ 항상 타임라인 카드 컬럼 + 소유자 일치 + {condType === "owner-match" && ( + + )} {condType === "timeline-status" && ( 즉시 실행 + 수량 입력 선택 후 실행 모달 열기 @@ -2191,6 +2261,50 @@ function ActionButtonsEditor({ /> )} + {aType === "quantity-input" && ( +
+ addActionUpdate(bi, ai)} + onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} + onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} + onUpdateAction={(p) => updateAction(bi, ai, p)} + /> +
+ 수량 모달 설정 +
+ 최대값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, maxColumn: e.target.value } })} + placeholder="예: qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 현재값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, currentColumn: e.target.value } })} + placeholder="예: input_qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 단위 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, unit: e.target.value } })} + placeholder="예: EA" + className="h-6 w-20 text-[10px]" + /> +
+
+
+ )} + {aType === "select-mode" && (
@@ -2455,6 +2569,70 @@ function ImmediateActionEditor({ className="h-6 flex-1 text-[10px]" />
+ + {/* 사전 조건 (중복 방지) */} +
+
+ 사전 조건 (중복 방지) + { + if (checked) { + onUpdateAction({ preCondition: { column: "", expectedValue: "", failMessage: "" } }); + } else { + onUpdateAction({ preCondition: undefined }); + } + }} + className="h-3.5 w-7 [&>span]:h-2.5 [&>span]:w-2.5" + /> +
+ {action.preCondition && ( +
+
+ 검증 컬럼 + +
+
+ 기대값 + onUpdateAction({ preCondition: { ...action.preCondition!, expectedValue: e.target.value } })} + placeholder="예: waiting" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 실패 메시지 + onUpdateAction({ preCondition: { ...action.preCondition!, failMessage: e.target.value } })} + placeholder="이미 다른 사용자가 처리했습니다" + className="h-6 flex-1 text-[10px]" + /> +
+

+ 실행 시 해당 컬럼의 현재 DB 값이 기대값과 일치할 때만 처리됩니다 +

+
+ )} +
+
변경할 컬럼{tableName ? ` (${tableName})` : ""} @@ -2491,11 +2669,22 @@ function ImmediateActionEditor({ 직접입력 + 사용자 입력 현재 사용자 현재 시간 컬럼 참조 + {u.valueType === "userInput" && ( + + )} {(u.valueType === "static" || u.valueType === "columnRef") && ( ) => void; }) { const footerStatusMap = cell.footerStatusMap || []; @@ -2644,7 +2835,7 @@ function FooterStatusEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -2680,10 +2871,12 @@ function FooterStatusEditor({ function FieldConfigEditor({ cell, allColumnOptions, + columnOptionGroups, onUpdate, }: { cell: CardCellDefinitionV2; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; onUpdate: (partial: Partial) => void; }) { const valueType = cell.valueType || "column"; @@ -2706,7 +2899,7 @@ function FieldConfigEditor({ onUpdate({ formulaRight: v })}> - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -2741,16 +2934,61 @@ function FieldConfigEditor({ function TabActions({ cfg, onUpdate, + columns, }: { cfg: PopCardListV2Config; onUpdate: (partial: Partial) => void; + columns: ColumnInfo[]; }) { const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const ownerColumnGroups: ColumnOptionGroup[] = useMemo(() => [ + { + groupLabel: `메인 (${cfg.dataSource?.tableName || "테이블"})`, + options: columns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), + ], [columns, processColumns, processTableName, cfg.dataSource?.tableName]); + return (
+ {/* 소유자 우선 정렬 */} +
+ +
+ +
+

+ 선택한 컬럼 값이 현재 로그인 사용자와 일치하는 카드가 맨 위에 표시됩니다 +

+
+ {/* 카드 선택 시 */}
diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index f1863b13..180dc219 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -70,6 +70,7 @@ export interface CellRendererProps { onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; packageEntries?: PackageEntry[]; inputUnit?: string; + currentUserId?: string; } // ===== 메인 디스패치 ===== @@ -592,7 +593,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== -function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { +function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId?: string): "visible" | "disabled" | "hidden" { const cond = btn.showCondition; if (!cond || cond.type === "always") return "visible"; @@ -603,6 +604,9 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | matched = subStatus !== undefined && String(subStatus) === cond.value; } else if (cond.type === "column-value" && cond.column) { matched = String(row[cond.column] ?? "") === (cond.value ?? ""); + } else if (cond.type === "owner-match" && cond.column) { + const ownerValue = String(row[cond.column] ?? ""); + matched = !!currentUserId && ownerValue === currentUserId; } else { return "visible"; } @@ -611,7 +615,7 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; } -function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { +function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) { const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); const currentProcessId = currentProcess?.processId; @@ -619,7 +623,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode } if (cell.actionButtons && cell.actionButtons.length > 0) { const evaluated = cell.actionButtons.map((btn) => ({ btn, - state: evaluateShowCondition(btn, row), + state: evaluateShowCondition(btn, row, currentUserId), })); const activeBtn = evaluated.find((e) => e.state === "visible"); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 3b7ff73e..3680578e 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -851,7 +851,8 @@ export interface CardCellDefinitionV2 { export interface ActionButtonUpdate { column: string; value?: string; - valueType: "static" | "currentUser" | "currentTime" | "columnRef"; + valueType: "static" | "currentUser" | "currentTime" | "columnRef" | "userInput"; + operationType?: "assign" | "add" | "subtract"; } // 액션 버튼 클릭 시 동작 모드 @@ -881,34 +882,49 @@ export interface SelectModeConfig { export interface SelectModeButtonConfig { label: string; variant: ButtonVariant; - clickMode: "status-change" | "modal-open" | "cancel-select"; + clickMode: "status-change" | "modal-open" | "cancel-select" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; modalScreenId?: string; + quantityInput?: QuantityInputConfig; } // ===== 버튼 중심 구조 (신규) ===== export interface ActionButtonShowCondition { - type: "timeline-status" | "column-value" | "always"; + type: "timeline-status" | "column-value" | "always" | "owner-match"; value?: string; column?: string; unmatchBehavior?: "hidden" | "disabled"; } export interface ActionButtonClickAction { - type: "immediate" | "select-mode" | "modal-open"; + type: "immediate" | "select-mode" | "modal-open" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; selectModeButtons?: SelectModeButtonConfig[]; modalScreenId?: string; - // 외부 테이블 조인 설정 (DB 직접 선택 시) joinConfig?: { - sourceColumn: string; // 메인 테이블의 FK 컬럼 - targetColumn: string; // 외부 테이블의 매칭 컬럼 + sourceColumn: string; + targetColumn: string; }; + quantityInput?: QuantityInputConfig; + preCondition?: ActionPreCondition; +} + +export interface QuantityInputConfig { + maxColumn?: string; + currentColumn?: string; + unit?: string; + enablePackage?: boolean; +} + +export interface ActionPreCondition { + column: string; + expectedValue: string; + failMessage?: string; } export interface ActionButtonDef { @@ -976,6 +992,7 @@ export interface PopCardListV2Config { cartAction?: CardCartActionConfig; cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; + ownerSortColumn?: string; } /** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */ From c067c373906bfbc4da9079e9e703dbe0f5f38ba8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 14:19:54 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20BLOCK=20DETAIL=20Phase=202=20-=20?= =?UTF-8?q?=EC=83=9D=EC=82=B0=20=EA=B3=B5=EC=A0=95=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20API=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=9E=91=EC=97=85=EC=A7=80=EC=8B=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EA=B3=B5=EC=A0=95+=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B3=BC=20=EA=B3=B5=EC=A0=95=EB=B3=84=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=A0=9C=EC=96=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=B0=B1=EC=97=94=EB=93=9C=20API=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=ED=95=9C=EB=8B=A4.=20[=EC=8B=A0=EA=B7=9C]=20?= =?UTF-8?q?popProductionController.ts=20-=20createWorkProcesses:=20POST=20?= =?UTF-8?q?/api/pop/production/create-work-processes=20=20=20-=20item=5Fro?= =?UTF-8?q?uting=5Fdetail=20+=20process=5Fmng=20JOIN=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=20=20-=20work=5Forder=5Fprocess=20INSERT=20(=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EB=B3=84)=20=20=20-=20process=5Fwork=5Fresult=20INSERT=20SELEC?= =?UTF-8?q?T=20(=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=8A=A4=EB=83=85=EC=83=B7?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC)=20=20=20-=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(409=20Conflict)=20?= =?UTF-8?q?=20=20-=201=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20-=20controlTimer:=20POST=20/api/pop/production/time?= =?UTF-8?q?r=20=20=20-=20start:=20started=5Fat=20=EC=84=A4=EC=A0=95=20+=20?= =?UTF-8?q?status=20waiting->in=5Fprogress=20(=EB=A9=B1=EB=93=B1)=20=20=20?= =?UTF-8?q?-=20pause:=20paused=5Fat=20=EC=84=A4=EC=A0=95=20=20=20-=20resum?= =?UTF-8?q?e:=20total=5Fpaused=5Ftime=20=EB=88=84=EC=A0=81=20+=20paused=5F?= =?UTF-8?q?at=20=EC=B4=88=EA=B8=B0=ED=99=94=20[=EC=8B=A0=EA=B7=9C]=20popPr?= =?UTF-8?q?oductionRoutes.ts=20-=20authenticateToken=20=EB=AF=B8=EB=93=A4?= =?UTF-8?q?=EC=9B=A8=EC=96=B4=20=EC=A0=84=EC=97=AD=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?-=202=EA=B0=9C=20POST=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20[=EC=88=98=EC=A0=95]=20app.ts?= =?UTF-8?q?=20-=20popProductionRoutes=20import=20+=20/api/pop/production?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../controllers/popProductionController.ts | 291 ++++++++++++++++++ .../src/routes/popProductionRoutes.ts | 15 + 3 files changed, 308 insertions(+) create mode 100644 backend-node/src/controllers/popProductionController.ts create mode 100644 backend-node/src/routes/popProductionRoutes.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f45a88cd..6b86a333 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -124,6 +124,7 @@ import entitySearchRoutes, { import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 +import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -259,6 +260,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/pop", popActionRoutes); // POP 액션 실행 +app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts new file mode 100644 index 00000000..d575b07a --- /dev/null +++ b/backend-node/src/controllers/popProductionController.ts @@ -0,0 +1,291 @@ +import { Response } from "express"; +import { getPool } from "../database/db"; +import logger from "../utils/logger"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; + +/** + * D-BE1: 작업지시 공정 일괄 생성 + * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. + */ +export const createWorkProcesses = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_instruction_id, item_code, routing_version_id, plan_qty } = + req.body; + + if (!work_instruction_id || !routing_version_id) { + return res.status(400).json({ + success: false, + message: + "work_instruction_id와 routing_version_id는 필수입니다.", + }); + } + + logger.info("[pop/production] create-work-processes 요청", { + companyCode, + userId, + work_instruction_id, + item_code, + routing_version_id, + plan_qty, + }); + + await client.query("BEGIN"); + + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [work_instruction_id, companyCode] + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 공정이 생성된 작업지시입니다.", + }); + } + + // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) + const routingDetails = await client.query( + `SELECT rd.id, rd.seq_no, rd.process_code, + COALESCE(pm.process_name, rd.process_code) as process_name, + rd.is_required, rd.is_fixed_order, rd.standard_time + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON pm.process_code = rd.process_code + AND pm.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, + [routing_version_id, companyCode] + ); + + if (routingDetails.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "라우팅 버전에 등록된 공정이 없습니다.", + }); + } + + const processes: Array<{ + id: string; + seq_no: string; + process_name: string; + checklist_count: number; + }> = []; + let totalChecklists = 0; + + for (const rd of routingDetails.rows) { + // 2. work_order_process INSERT + const wopResult = await client.query( + `INSERT INTO work_order_process ( + company_code, wo_id, seq_no, process_code, process_name, + is_required, is_fixed_order, standard_time, plan_qty, + status, routing_detail_id, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id`, + [ + companyCode, + work_instruction_id, + rd.seq_no, + rd.process_code, + rd.process_name, + rd.is_required, + rd.is_fixed_order, + rd.standard_time, + plan_qty || null, + "waiting", + rd.id, + userId, + ] + ); + const wopId = wopResult.rows[0].id; + + // 3. process_work_result INSERT (스냅샷 복사) + // process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사 + const snapshotResult = await client.query( + `INSERT INTO process_work_result ( + company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + pwi.company_code, $1, + pwi.id, pwd.id, + pwi.work_phase, pwi.title, pwi.sort_order::text, + pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, + pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, + pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, + 'pending', $2 + FROM process_work_item pwi + JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id + AND pwd.company_code = pwi.company_code + WHERE pwi.routing_detail_id = $3 + AND pwi.company_code = $4 + ORDER BY pwi.sort_order, pwd.sort_order`, + [wopId, userId, rd.id, companyCode] + ); + + const checklistCount = snapshotResult.rowCount ?? 0; + totalChecklists += checklistCount; + + processes.push({ + id: wopId, + seq_no: rd.seq_no, + process_name: rd.process_name, + checklist_count: checklistCount, + }); + + logger.info("[pop/production] 공정 생성 완료", { + wopId, + processName: rd.process_name, + checklistCount, + }); + } + + await client.query("COMMIT"); + + logger.info("[pop/production] create-work-processes 완료", { + companyCode, + work_instruction_id, + total_processes: processes.length, + total_checklists: totalChecklists, + }); + + return res.json({ + success: true, + data: { + processes, + total_processes: processes.length, + total_checklists: totalChecklists, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("[pop/production] create-work-processes 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "공정 생성 중 오류가 발생했습니다.", + }); + } finally { + client.release(); + } +}; + +/** + * D-BE2: 타이머 API (시작/일시정지/재시작) + */ +export const controlTimer = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_order_process_id, action } = req.body; + + if (!work_order_process_id || !action) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 action은 필수입니다.", + }); + } + + if (!["start", "pause", "resume"].includes(action)) { + return res.status(400).json({ + success: false, + message: "action은 start, pause, resume 중 하나여야 합니다.", + }); + } + + logger.info("[pop/production] timer 요청", { + companyCode, + userId, + work_order_process_id, + action, + }); + + let result; + + switch (action) { + case "start": + // 최초 1회만 설정, 이미 있으면 무시 + result = await pool.query( + `UPDATE work_order_process + SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, + status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, started_at, status`, + [work_order_process_id, companyCode] + ); + break; + + case "pause": + result = await pool.query( + `UPDATE work_order_process + SET paused_at = NOW()::text, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NULL + RETURNING id, paused_at`, + [work_order_process_id, companyCode] + ); + break; + + case "resume": + // 일시정지 시간 누적 후 paused_at 초기화 + result = await pool.query( + `UPDATE work_order_process + SET total_paused_time = ( + COALESCE(total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int + )::text, + paused_at = NULL, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL + RETURNING id, total_paused_time`, + [work_order_process_id, companyCode] + ); + break; + } + + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } + + logger.info("[pop/production] timer 완료", { + action, + work_order_process_id, + result: result.rows[0], + }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "타이머 처리 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts new file mode 100644 index 00000000..f20d470d --- /dev/null +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + createWorkProcesses, + controlTimer, +} from "../controllers/popProductionController"; + +const router = Router(); + +router.use(authenticateToken); + +router.post("/create-work-processes", createWorkProcesses); +router.post("/timer", controlTimer); + +export default router; From c4d7b165382609d11c81456d43f1ecad41a2b1c8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 14:23:26 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20LOCK-OWNER=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20UI=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=B6=84=20=EB=B0=98=EC=98=81=20a2c532c7=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20CardV2=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20UI=EB=A5=BC=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20-=20locked=20=EA=B3=84=EC=82=B0:=20ownerSortColumn?= =?UTF-8?q?=20=EA=B0=92=EC=9D=B4=20=EC=A1=B4=EC=9E=AC=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=99=80=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=8B=9C=20true=20-=20isLockedBy?= =?UTF-8?q?Other=20prop=EC=9D=84=20CardV2=EC=97=90=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?-=20=EC=9E=A0=EA=B8=88=20=EC=B9=B4=EB=93=9C:=20opacity-50,=20cu?= =?UTF-8?q?rsor-not-allowed,=20onClick/onKeyDown=20=EC=B0=A8=EB=8B=A8,=20t?= =?UTF-8?q?abIndex=3D-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PopCardListV2Component.tsx | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 3a52a36e..141c3ffc 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -1067,36 +1067,42 @@ export function PopCardListV2Component({ className={`min-h-0 flex-1 grid ${scrollClassName}`} style={{ ...cardAreaStyle, alignContent: "start", justifyContent: isHorizontalMode ? "start" : "center" }} > - {displayCards.map((row, index) => ( - { - const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; - if (!cartId) return; - setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); - }} - onDeleteItem={handleDeleteItem} - onUpdateQuantity={handleUpdateQuantity} - onRefresh={fetchData} - selectMode={selectMode} - isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} - isSelectable={isRowSelectable(row)} - onToggleRowSelect={() => toggleRowSelection(row)} - onEnterSelectMode={enterSelectMode} - onOpenPopModal={openPopModal} - currentUserId={currentUserId} - /> - ))} + {displayCards.map((row, index) => { + const locked = !!ownerSortColumn + && !!String(row[ownerSortColumn] ?? "") + && String(row[ownerSortColumn] ?? "") !== (currentUserId ?? ""); + return ( + { + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} + onRefresh={fetchData} + selectMode={selectMode} + isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} + isSelectable={isRowSelectable(row)} + onToggleRowSelect={() => toggleRowSelection(row)} + onEnterSelectMode={enterSelectMode} + onOpenPopModal={openPopModal} + currentUserId={currentUserId} + isLockedByOther={locked} + /> + ); + })}
{/* 선택 모드 하단 액션 바 */} @@ -1394,16 +1400,24 @@ function CardV2({ return (
{ + if (isLockedByOther) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); }} role="button" - tabIndex={0} + tabIndex={isLockedByOther ? -1 : 0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { + if (isLockedByOther) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); } From 842ac27d60c3ec11827a25e398021a04482e47a5 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 16:03:24 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20V6=20=EC=A0=95=EC=82=AC=EA=B0=81?= =?UTF-8?q?=ED=98=95=20=EB=B8=94=EB=A1=9D=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=8B=A4=ED=97=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=A0=EC=A0=95=20=EC=B9=B8=20=EC=88=98(4/6/8/12?= =?UTF-8?q?)=20=EA=B8=B0=EB=B0=98=EC=9D=98=20V5=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=2024px=20=EC=A0=95=EC=82=AC=EA=B0=81?= =?UTF-8?q?=ED=98=95=20=EB=B8=94=EB=A1=9D=20=EA=B8=B0=EB=B0=98=EC=9D=98=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=B9=B8=20=EC=88=98=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20=EB=B7=B0=ED=8F=AC=ED=8A=B8=20=EB=84=88=EB=B9=84?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EB=B8=94=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0=EB=90=98?= =?UTF-8?q?=EB=A9=B0(375px=3D13=EC=B9=B8,=201024px=3D38=EC=B9=B8),=20?= =?UTF-8?q?=EC=9E=91=EC=9D=80=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20=ED=96=89=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20(CSS=20Flexbox=20wrap=20=EC=9B=90=EB=A6=AC?= =?UTF-8?q?)=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=9E=AC=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EB=90=9C=EB=8B=A4.=20[=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4]=20-=20pop-layout.ts:=20BLOCK=5FSIZE=3D24,=20BLOCK=5F?= =?UTF-8?q?GAP=3D2,=20BLOCK=5FPADDING=3D8,=20=20=20getBlockColumns()=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=B9=B8=20=EC=88=98=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?,=20GRID=5FBREAKPOINTS=20V6=20=EA=B0=92=20-=20gridUtils.ts:=20?= =?UTF-8?q?=ED=96=89=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0(=EB=B0=A9=EC=8B=9D=20F)=20-=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=96=89=20=EB=AC=B6=EC=9D=8C=20=EC=B2=98=EB=A6=AC,=20=20=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=202x2=EC=B9=B8=20=ED=84=B0=EC=B9=98=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5,=20=EB=A9=94=EC=9D=B8=20=EC=BB=A8=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=A0=84=EC=B2=B4=20=EB=84=88=EB=B9=84=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20-=20PopRenderer.tsx:=20repeat(N,=201fr)=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EB=A0=8C=EB=8D=94=EB=A7=81,=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=20=EC=B9=B8=20=EC=88=98=20-=20PopCanvas.tsx:=20=EB=B7=B0?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=ED=94=84=EB=A6=AC=EC=85=8B=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EC=B9=B8=20=EC=88=98,=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=20=EB=B3=80=ED=99=98=20[V5=E2=86=92V6=20?= =?UTF-8?q?=EB=9F=B0=ED=83=80=EC=9E=84=20=EB=B3=80=ED=99=98]=20-=20convert?= =?UTF-8?q?V5LayoutToV6:=20DB=20=EB=AF=B8=EC=88=98=EC=A0=95,=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=20=2012=EC=B9=B8=20=EC=A2=8C=ED=91=9C=20=E2=86=92?= =?UTF-8?q?=2038=EC=B9=B8=20=EB=B8=94=EB=A1=9D=20=EB=B3=80=ED=99=98,=20V5?= =?UTF-8?q?=20overrides=20=EC=A0=9C=EA=B1=B0=20-=20PopDesigner/page.tsx:?= =?UTF-8?q?=20=EB=A1=9C=EB=93=9C=20=EC=A7=80=EC=A0=90=EC=97=90=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=ED=95=A8=EC=88=98=20=EC=82=BD=EC=9E=85=20[?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0]=20-=20ComponentEditorP?= =?UTF-8?q?anel:=20=EB=86=92=EC=9D=B4=20=ED=91=9C=EC=8B=9C/=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EB=9D=BC=EB=B2=A8=20V6=20=EC=88=98=EC=B9=98=20-=20?= =?UTF-8?q?PopCardListConfig:=20=EC=B9=B4=EB=93=9C=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?threshold=20V6=20=EA=B8=B0=EC=A4=80=20-=20PopDesigner:=20handle?= =?UTF-8?q?HideComponent=20=EA=B8=B0=EB=B3=B8=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=ED=95=B4=EC=A0=9C=20[=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EC=A6=88]=20-=20=EC=86=8C=ED=98=95(2x2):?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98,=20=ED=94=84=EB=A1=9C=ED=95=84,?= =?UTF-8?q?=20=EC=8A=A4=EC=BA=90=EB=84=88=20-=20=EC=A4=91=ED=98=95(8x4):?= =?UTF-8?q?=20=EA=B2=80=EC=83=89,=20=EB=B2=84=ED=8A=BC,=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20-=20=EB=8C=80=ED=98=95(19x6~10):=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C,=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C,=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20DB=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4,=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4,?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=200=EA=B1=B4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(pop)/pop/screens/[screenId]/page.tsx | 15 +- .../components/pop/designer/PopCanvas.tsx | 52 +-- .../components/pop/designer/PopDesigner.tsx | 12 +- .../designer/panels/ComponentEditorPanel.tsx | 12 +- .../pop/designer/renderers/PopRenderer.tsx | 47 ++- .../pop/designer/types/pop-layout.ts | 148 +++---- .../pop/designer/utils/gridUtils.ts | 377 ++++++++++-------- .../pop-card-list/PopCardListConfig.tsx | 4 +- 8 files changed, 375 insertions(+), 292 deletions(-) diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 7fe11270..bf2878a5 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -23,8 +23,11 @@ import { createEmptyPopLayoutV5, GAP_PRESETS, GRID_BREAKPOINTS, + BLOCK_GAP, + BLOCK_PADDING, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; +import { convertV5LayoutToV6 } from "@/components/pop/designer/utils/gridUtils"; // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) import "@/lib/registry/pop-components"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; @@ -117,8 +120,8 @@ function PopScreenViewPage() { const popLayout = await screenApi.getLayoutPop(screenId); if (popLayout && isV5Layout(popLayout)) { - // v5 레이아웃 로드 - setLayout(popLayout); + const v6Layout = convertV5LayoutToV6(popLayout); + setLayout(v6Layout); const componentCount = Object.keys(popLayout.components).length; console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { @@ -318,12 +321,8 @@ function PopScreenViewPage() { style={{ maxWidth: 1366 }} > {(() => { - // Gap 프리셋 계산 - const currentGapPreset = layout.settings.gapPreset || "medium"; - const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; - const breakpoint = GRID_BREAKPOINTS[currentModeKey]; - const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); - const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; return ( (null); const canvasRef = useRef(null); - // 현재 뷰포트 해상도 + // V6: 뷰포트에서 동적 블록 칸 수 계산 const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; - const breakpoint = GRID_BREAKPOINTS[currentMode]; + const dynamicColumns = getBlockColumns(customWidth); + const breakpoint = { + ...GRID_BREAKPOINTS[currentMode], + columns: dynamicColumns, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `${dynamicColumns}칸 블록`, + }; - // Gap 프리셋 적용 + // V6: 블록 간격 고정 (프리셋 무관) const currentGapPreset = layout.settings.gapPreset || "medium"; - const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; - const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); - const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; // 숨김 컴포넌트 ID 목록 (activeLayout 기반) const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; @@ -805,7 +807,7 @@ export default function PopCanvas({ {/* 하단 정보 */}
- {breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px) + V6 블록 그리드 - {dynamicColumns}칸 (블록: {BLOCK_SIZE}px, 간격: {BLOCK_GAP}px)
Space + 드래그: 패닝 | Ctrl + 휠: 줌 diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 36241817..de131032 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -33,7 +33,7 @@ import { PopModalDefinition, PopDataConnection, } from "./types/pop-layout"; -import { getAllEffectivePositions } from "./utils/gridUtils"; +import { getAllEffectivePositions, convertV5LayoutToV6 } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { PopDesignerContext } from "./PopDesignerContext"; @@ -151,13 +151,12 @@ export default function PopDesigner({ const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { - // v5 레이아웃 로드 - // 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가 if (!loadedLayout.settings.gapPreset) { loadedLayout.settings.gapPreset = "medium"; } - setLayout(loadedLayout); - setHistory([loadedLayout]); + const v6Layout = convertV5LayoutToV6(loadedLayout); + setLayout(v6Layout); + setHistory([v6Layout]); setHistoryIndex(0); // 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지) @@ -605,9 +604,6 @@ export default function PopDesigner({ // ======================================== const handleHideComponent = useCallback((componentId: string) => { - // 12칸 모드에서는 숨기기 불가 - if (currentMode === "tablet_landscape") return; - const currentHidden = layout.overrides?.[currentMode]?.hidden || []; // 이미 숨겨져 있으면 무시 diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 32ff5e06..ec22426d 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -7,6 +7,8 @@ import { PopGridPosition, GridMode, GRID_BREAKPOINTS, + BLOCK_SIZE, + getBlockColumns, } from "../types/pop-layout"; import { Settings, @@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate

- 높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px + 높이: {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px

@@ -470,10 +472,10 @@ interface VisibilityFormProps { function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { const modes: Array<{ key: GridMode; label: string }> = [ - { key: "tablet_landscape", label: "태블릿 가로 (12칸)" }, - { key: "tablet_portrait", label: "태블릿 세로 (8칸)" }, - { key: "mobile_landscape", label: "모바일 가로 (6칸)" }, - { key: "mobile_portrait", label: "모바일 세로 (4칸)" }, + { key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` }, + { key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` }, + { key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` }, + { key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` }, ]; const handleVisibilityChange = (mode: GridMode, visible: boolean) => { diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index a9c7db6e..373bed9b 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -13,6 +13,10 @@ import { GridBreakpoint, detectGridMode, PopComponentType, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, } from "../types/pop-layout"; import { convertAndResolvePositions, @@ -107,18 +111,27 @@ export default function PopRenderer({ }: PopRendererProps) { const { gridConfig, components, overrides } = layout; - // 현재 모드 (자동 감지 또는 지정) + // V6: 뷰포트 너비에서 블록 칸 수 동적 계산 const mode = currentMode || detectGridMode(viewportWidth); - const breakpoint = GRID_BREAKPOINTS[mode]; + const columns = getBlockColumns(viewportWidth); - // Gap/Padding: 오버라이드 우선, 없으면 기본값 사용 - const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap; - const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding; + // V6: 블록 간격 고정 + const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP; + const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING; + + // 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용) + const breakpoint: GridBreakpoint = { + columns, + rowHeight: BLOCK_SIZE, + gap: finalGap, + padding: finalPadding, + label: `${columns}칸 블록`, + }; // 숨김 컴포넌트 ID 목록 const hiddenIds = overrides?.[mode]?.hidden || []; - // 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외) + // 동적 행 수 계산 const dynamicRowCount = useMemo(() => { const visibleComps = Object.values(components).filter( comp => !hiddenIds.includes(comp.id) @@ -131,19 +144,17 @@ export default function PopRenderer({ return Math.max(10, maxRowEnd + 3); }, [components, overrides, mode, hiddenIds]); - // CSS Grid 스타일 - // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집) - // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능) + // V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE const rowTemplate = isDesignMode - ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` - : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; + ? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)` + : `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`; const autoRowHeight = isDesignMode - ? `${breakpoint.rowHeight}px` - : `minmax(${breakpoint.rowHeight}px, auto)`; + ? `${BLOCK_SIZE}px` + : `minmax(${BLOCK_SIZE}px, auto)`; const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", - gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridTemplateColumns: `repeat(${columns}, 1fr)`, gridTemplateRows: rowTemplate, gridAutoRows: autoRowHeight, gap: `${finalGap}px`, @@ -151,15 +162,15 @@ export default function PopRenderer({ minHeight: "100%", backgroundColor: "#ffffff", position: "relative", - }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); + }), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); - // 그리드 가이드 셀 생성 (동적 행 수) + // 그리드 가이드 셀 생성 const gridCells = useMemo(() => { if (!isDesignMode || !showGridGuide) return []; const cells = []; for (let row = 1; row <= dynamicRowCount; row++) { - for (let col = 1; col <= breakpoint.columns; col++) { + for (let col = 1; col <= columns; col++) { cells.push({ id: `cell-${col}-${row}`, col, @@ -168,7 +179,7 @@ export default function PopRenderer({ } } return cells; - }, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]); + }, [isDesignMode, showGridGuide, columns, dynamicRowCount]); // visibility 체크 const isVisible = (comp: PopComponentDefinitionV5): boolean => { diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 9fb9a847..44e4c1c4 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -99,24 +99,39 @@ export interface PopLayoutMetadata { } // ======================================== -// v5 그리드 기반 레이아웃 +// v6 정사각형 블록 그리드 시스템 // ======================================== -// 핵심: CSS Grid로 정확한 위치 지정 -// - 열/행 좌표로 배치 (col, row) -// - 칸 단위 크기 (colSpan, rowSpan) -// - Material Design 브레이크포인트 기반 +// 핵심: 균일한 정사각형 블록 (24px x 24px) +// - 열/행 좌표로 배치 (col, row) - 블록 단위 +// - 뷰포트 너비에 따라 칸 수 동적 계산 +// - 단일 좌표계 (모드별 변환 불필요) /** - * 그리드 모드 (4가지) + * V6 블록 상수 + */ +export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형) +export const BLOCK_GAP = 2; // 블록 간격 (px) +export const BLOCK_PADDING = 8; // 캔버스 패딩 (px) + +/** + * 뷰포트 너비에서 블록 칸 수 계산 + */ +export function getBlockColumns(viewportWidth: number): number { + const available = viewportWidth - BLOCK_PADDING * 2; + return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP))); +} + +/** + * 그리드 모드 (하위 호환용 - V6에서는 뷰포트 프리셋 라벨로만 사용) */ export type GridMode = - | "mobile_portrait" // 4칸 - | "mobile_landscape" // 6칸 - | "tablet_portrait" // 8칸 - | "tablet_landscape"; // 12칸 (기본) + | "mobile_portrait" + | "mobile_landscape" + | "tablet_portrait" + | "tablet_landscape"; /** - * 그리드 브레이크포인트 설정 + * 그리드 브레이크포인트 설정 (하위 호환용) */ export interface GridBreakpoint { minWidth?: number; @@ -129,50 +144,43 @@ export interface GridBreakpoint { } /** - * 브레이크포인트 상수 - * 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반 + * V6 브레이크포인트 (블록 기반 동적 칸 수) + * columns는 각 뷰포트 너비에서의 블록 수 */ export const GRID_BREAKPOINTS: Record = { - // 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra) mobile_portrait: { maxWidth: 479, - columns: 4, - rowHeight: 40, - gap: 8, - padding: 12, - label: "모바일 세로 (4칸)", + columns: getBlockColumns(375), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 세로 (${getBlockColumns(375)}칸)`, }, - - // 스마트폰 가로 + 소형 태블릿 mobile_landscape: { minWidth: 480, maxWidth: 767, - columns: 6, - rowHeight: 44, - gap: 8, - padding: 16, - label: "모바일 가로 (6칸)", + columns: getBlockColumns(600), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 가로 (${getBlockColumns(600)}칸)`, }, - - // 태블릿 세로 (iPad Mini ~ iPad Pro) tablet_portrait: { minWidth: 768, maxWidth: 1023, - columns: 8, - rowHeight: 48, - gap: 12, - padding: 16, - label: "태블릿 세로 (8칸)", + columns: getBlockColumns(820), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 세로 (${getBlockColumns(820)}칸)`, }, - - // 태블릿 가로 + 데스크톱 (기본) tablet_landscape: { minWidth: 1024, - columns: 12, - rowHeight: 48, - gap: 16, - padding: 24, - label: "태블릿 가로 (12칸)", + columns: getBlockColumns(1024), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 가로 (${getBlockColumns(1024)}칸)`, }, } as const; @@ -183,7 +191,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; /** * 뷰포트 너비로 모드 감지 - * GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용 */ export function detectGridMode(viewportWidth: number): GridMode { if (viewportWidth < 480) return "mobile_portrait"; @@ -225,17 +232,17 @@ export interface PopLayoutDataV5 { } /** - * 그리드 설정 + * 그리드 설정 (V6: 블록 단위) */ export interface PopGridConfig { - // 행 높이 (px) - 1행의 기본 높이 - rowHeight: number; // 기본 48px + // 행 높이 = 블록 크기 (px) + rowHeight: number; // V6 기본 24px (= BLOCK_SIZE) // 간격 (px) - gap: number; // 기본 8px + gap: number; // V6 기본 2px (= BLOCK_GAP) // 패딩 (px) - padding: number; // 기본 16px + padding: number; // V6 기본 8px (= BLOCK_PADDING) } /** @@ -274,7 +281,7 @@ export interface PopComponentDefinitionV5 { } /** - * Gap 프리셋 타입 + * Gap 프리셋 타입 (V6: 단일 간격이므로 medium만 유효, 하위 호환용 유지) */ export type GapPreset = "narrow" | "medium" | "wide"; @@ -287,12 +294,12 @@ export interface GapPresetConfig { } /** - * Gap 프리셋 상수 + * Gap 프리셋 상수 (V6: 모두 동일 - 블록 간격 고정) */ export const GAP_PRESETS: Record = { - narrow: { multiplier: 0.5, label: "좁게" }, - medium: { multiplier: 1.0, label: "보통" }, - wide: { multiplier: 1.5, label: "넓게" }, + narrow: { multiplier: 1.0, label: "기본" }, + medium: { multiplier: 1.0, label: "기본" }, + wide: { multiplier: 1.0, label: "기본" }, }; /** @@ -330,9 +337,9 @@ export interface PopModeOverrideV5 { export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ version: "pop-5.0", gridConfig: { - rowHeight: 48, - gap: 8, - padding: 16, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, }, components: {}, dataFlow: { connections: [] }, @@ -351,22 +358,27 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { }; /** - * 컴포넌트 타입별 기본 크기 (칸 단위) + * 컴포넌트 타입별 기본 크기 (블록 단위, V6) + * + * 소형 (2x2) : 최소 단위. 아이콘, 프로필, 스캐너 등 단일 요소 + * 중형 (8x4) : 검색, 버튼, 텍스트 등 한 줄 입력/표시 + * 대형 (8x6) : 샘플, 상태바, 필드 등 여러 줄 컨텐츠 + * 초대형 (19x8~) : 카드, 리스트, 대시보드 등 메인 영역 */ export const DEFAULT_COMPONENT_GRID_SIZE: Record = { - "pop-sample": { colSpan: 2, rowSpan: 1 }, - "pop-text": { colSpan: 3, rowSpan: 1 }, - "pop-icon": { colSpan: 1, rowSpan: 2 }, - "pop-dashboard": { colSpan: 6, rowSpan: 3 }, - "pop-card-list": { colSpan: 4, rowSpan: 3 }, - "pop-card-list-v2": { colSpan: 4, rowSpan: 3 }, - "pop-button": { colSpan: 2, rowSpan: 1 }, - "pop-string-list": { colSpan: 4, rowSpan: 3 }, - "pop-search": { colSpan: 2, rowSpan: 1 }, - "pop-status-bar": { colSpan: 6, rowSpan: 1 }, - "pop-field": { colSpan: 6, rowSpan: 2 }, - "pop-scanner": { colSpan: 1, rowSpan: 1 }, - "pop-profile": { colSpan: 1, rowSpan: 1 }, + "pop-sample": { colSpan: 8, rowSpan: 6 }, + "pop-text": { colSpan: 8, rowSpan: 4 }, + "pop-icon": { colSpan: 2, rowSpan: 2 }, + "pop-dashboard": { colSpan: 19, rowSpan: 10 }, + "pop-card-list": { colSpan: 19, rowSpan: 10 }, + "pop-card-list-v2": { colSpan: 19, rowSpan: 10 }, + "pop-button": { colSpan: 8, rowSpan: 4 }, + "pop-string-list": { colSpan: 19, rowSpan: 10 }, + "pop-search": { colSpan: 8, rowSpan: 4 }, + "pop-status-bar": { colSpan: 19, rowSpan: 4 }, + "pop-field": { colSpan: 19, rowSpan: 6 }, + "pop-scanner": { colSpan: 2, rowSpan: 2 }, + "pop-profile": { colSpan: 2, rowSpan: 2 }, }; /** diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index 308ce730..e5078f64 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -6,196 +6,148 @@ import { GapPreset, GAP_PRESETS, PopLayoutDataV5, - PopComponentDefinitionV5, + PopComponentDefinitionV5, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, } from "../types/pop-layout"; // ======================================== -// Gap/Padding 조정 +// Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환) // ======================================== -/** - * Gap 프리셋에 따라 breakpoint의 gap/padding 조정 - * - * @param base 기본 breakpoint 설정 - * @param preset Gap 프리셋 ("narrow" | "medium" | "wide") - * @returns 조정된 breakpoint (gap, padding 계산됨) - */ export function getAdjustedBreakpoint( base: GridBreakpoint, preset: GapPreset ): GridBreakpoint { - const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0; - - return { - ...base, - gap: Math.round(base.gap * multiplier), - padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px - }; + return { ...base }; } // ======================================== -// 그리드 위치 변환 +// 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요) // ======================================== /** - * 12칸 기준 위치를 다른 모드로 변환 + * V6: 단일 좌표계이므로 변환 없이 원본 반환 + * @deprecated V6에서는 좌표 변환이 불필요합니다 */ export function convertPositionToMode( position: PopGridPosition, targetMode: GridMode ): PopGridPosition { - const sourceColumns = 12; - const targetColumns = GRID_BREAKPOINTS[targetMode].columns; - - // 같은 칸 수면 그대로 반환 - if (sourceColumns === targetColumns) { - return position; - } - - const ratio = targetColumns / sourceColumns; - - // 열 위치 변환 - let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); - let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - // 범위 초과 방지 - if (newCol > targetColumns) { - newCol = 1; - } - if (newCol + newColSpan - 1 > targetColumns) { - newColSpan = targetColumns - newCol + 1; - } - - return { - col: newCol, - row: position.row, - colSpan: Math.max(1, newColSpan), - rowSpan: position.rowSpan, - }; + return position; } /** - * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 - * - * v5.1 자동 줄바꿈: - * - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치 - * - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨 + * V6 행 그룹 리플로우 (방식 F) + * + * 원리: CSS Flexbox wrap과 동일. + * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 + * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) + * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) + * 4. 설계 너비의 50% 이상 → 전체 너비 확장 + * 5. 리플로우 후 겹침 해결 (resolveOverlaps) */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, targetMode: GridMode ): Array<{ id: string; position: PopGridPosition }> { - // 엣지 케이스: 빈 배열 - if (components.length === 0) { - return []; - } + if (components.length === 0) return []; const targetColumns = GRID_BREAKPOINTS[targetMode].columns; + const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns; - // 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존) - const converted = components.map(comp => ({ - id: comp.id, - position: convertPositionToMode(comp.position, targetMode), - originalCol: comp.position.col, // 원본 col 보존 - })); + if (targetColumns >= designColumns) { + return components.map(c => ({ id: c.id, position: { ...c.position } })); + } - // 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리 - const normalComponents = converted.filter(c => c.originalCol <= targetColumns); - const overflowComponents = converted.filter(c => c.originalCol > targetColumns); + const ratio = targetColumns / designColumns; + const MIN_COL_SPAN = 2; + const MIN_ROW_SPAN = 2; - // 3단계: 정상 컴포넌트의 최대 row 계산 - const maxRow = normalComponents.length > 0 - ? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1)) - : 0; - - // 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치 - let currentRow = maxRow + 1; - const wrappedComponents = overflowComponents.map(comp => { - const wrappedPosition: PopGridPosition = { - col: 1, // 왼쪽 끝부터 시작 - row: currentRow, - colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한 - rowSpan: comp.position.rowSpan, - }; - currentRow += comp.position.rowSpan; // 다음 행으로 이동 - - return { - id: comp.id, - position: wrappedPosition, - }; + // 1. 원본 row 기준 그룹핑 + const rowGroups: Record> = {}; + components.forEach(comp => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(comp); }); - // 5단계: 정상 + 줄바꿈 컴포넌트 병합 - const adjusted = [ - ...normalComponents.map(c => ({ id: c.id, position: c.position })), - ...wrappedComponents, - ]; + const placed: Array<{ id: string; position: PopGridPosition }> = []; + let outputRow = 1; - // 6단계: 겹침 해결 (아래로 밀기) - return resolveOverlaps(adjusted, targetColumns); + // 2. 각 행 그룹을 순서대로 처리 + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + + for (const rowKey of sortedRows) { + const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col); + let currentCol = 1; + let maxRowSpanInLine = 0; + + for (const comp of group) { + const pos = comp.position; + const isMainContent = pos.colSpan >= designColumns * 0.5; + + let scaledSpan = isMainContent + ? targetColumns + : Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio)); + scaledSpan = Math.min(scaledSpan, targetColumns); + + const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan); + + // 현재 줄에 안 들어가면 줄바꿈 + if (currentCol + scaledSpan - 1 > targetColumns) { + outputRow += Math.max(1, maxRowSpanInLine); + currentCol = 1; + maxRowSpanInLine = 0; + } + + placed.push({ + id: comp.id, + position: { + col: currentCol, + row: outputRow, + colSpan: scaledSpan, + rowSpan: scaledRowSpan, + }, + }); + + maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan); + currentCol += scaledSpan; + } + + outputRow += Math.max(1, maxRowSpanInLine); + } + + // 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리) + return resolveOverlaps(placed, targetColumns); } // ======================================== -// 검토 필요 판별 +// 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음) // ======================================== /** - * 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인 - * - * v5.1 검토 필요 기준: - * - 12칸 모드(기본 모드)가 아님 - * - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함) - * - * @param currentMode 현재 그리드 모드 - * @param hasOverride 해당 모드에서 오버라이드 존재 여부 - * @returns true = 검토 필요, false = 검토 완료 또는 불필요 + * V6: 단일 좌표계 + 자동 줄바꿈이므로 검토 필요 없음 + * 항상 false 반환 */ export function needsReview( currentMode: GridMode, hasOverride: boolean ): boolean { - const targetColumns = GRID_BREAKPOINTS[currentMode].columns; - - // 12칸 모드는 기본 모드이므로 검토 불필요 - if (targetColumns === 12) { - return false; - } - - // 오버라이드가 있으면 이미 편집함 → 검토 완료 - if (hasOverride) { - return false; - } - - // 오버라이드 없으면 → 검토 필요 - return true; + return false; } /** - * @deprecated v5.1부터 needsReview() 사용 권장 - * - * 기존 isOutOfBounds는 "화면 밖" 개념이었으나, - * v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다. - * 대신 needsReview()로 "검토 필요" 여부를 판별하세요. + * @deprecated V6에서는 자동 줄바꿈이므로 화면 밖 개념 없음 */ export function isOutOfBounds( originalPosition: PopGridPosition, currentMode: GridMode, overridePosition?: PopGridPosition | null ): boolean { - const targetColumns = GRID_BREAKPOINTS[currentMode].columns; - - // 12칸 모드면 초과 불가 - if (targetColumns === 12) { - return false; - } - - // 오버라이드가 있으면 오버라이드 위치로 판단 - if (overridePosition) { - return overridePosition.col > targetColumns; - } - - // 오버라이드 없으면 원본 col로 판단 - return originalPosition.col > targetColumns; + return false; } // ======================================== @@ -269,12 +221,8 @@ export function resolveOverlaps( // ======================================== /** - * 마우스 좌표 → 그리드 좌표 변환 - * - * CSS Grid 계산 방식: - * - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1) - * - 각 칸 너비 = 사용 가능 너비 / columns - * - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap) + * V6: 마우스 좌표 → 블록 그리드 좌표 변환 + * 블록 크기가 고정(BLOCK_SIZE)이므로 계산이 단순함 */ export function mouseToGridPosition( mouseX: number, @@ -285,28 +233,19 @@ export function mouseToGridPosition( gap: number, padding: number ): { col: number; row: number } { - // 캔버스 내 상대 위치 (패딩 영역 포함) const relX = mouseX - canvasRect.left - padding; const relY = mouseY - canvasRect.top - padding; - // CSS Grid 1fr 계산과 동일하게 - // 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap) - const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1); - const colWidth = availableWidth / columns; + const cellStride = BLOCK_SIZE + gap; - // 각 셀의 실제 간격 (셀 너비 + gap) - const cellStride = colWidth + gap; - - // 그리드 좌표 계산 (1부터 시작) - // relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음 const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); - const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); + const row = Math.max(1, Math.floor(relY / cellStride) + 1); return { col, row }; } /** - * 그리드 좌표 → 픽셀 좌표 변환 + * V6: 블록 그리드 좌표 → 픽셀 좌표 변환 */ export function gridToPixelPosition( col: number, @@ -319,14 +258,13 @@ export function gridToPixelPosition( gap: number, padding: number ): { x: number; y: number; width: number; height: number } { - const totalGap = gap * (columns - 1); - const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; + const cellStride = BLOCK_SIZE + gap; return { - x: padding + (col - 1) * (colWidth + gap), - y: padding + (row - 1) * (rowHeight + gap), - width: colWidth * colSpan + gap * (colSpan - 1), - height: rowHeight * rowSpan + gap * (rowSpan - 1), + x: padding + (col - 1) * cellStride, + y: padding + (row - 1) * cellStride, + width: BLOCK_SIZE * colSpan + gap * (colSpan - 1), + height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1), }; } @@ -560,3 +498,126 @@ export function getAllEffectivePositions( return result; } + +// ======================================== +// V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환) +// ======================================== + +const V5_BASE_COLUMNS = 12; +const V5_BASE_ROW_HEIGHT = 48; +const V5_BASE_GAP = 16; +const V5_DESIGN_WIDTH = 1024; + +/** + * V5 레이아웃 판별: gridConfig.rowHeight가 V5 기본값(48)이고 + * 좌표가 12칸 체계인 경우만 V5로 판정 + */ +function isV5GridConfig(layout: PopLayoutDataV5): boolean { + if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; + + const maxCol = Object.values(layout.components).reduce((max, comp) => { + const end = comp.position.col + comp.position.colSpan - 1; + return Math.max(max, end); + }, 0); + + return maxCol <= V5_BASE_COLUMNS; +} + +function convertV5PositionToV6( + pos: PopGridPosition, + v6DesignColumns: number, +): PopGridPosition { + const colRatio = v6DesignColumns / V5_BASE_COLUMNS; + const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP); + + const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); + let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); + const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); + + if (newCol + newColSpan - 1 > v6DesignColumns) { + newColSpan = v6DesignColumns - newCol + 1; + } + + return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; +} + +/** + * V5 레이아웃을 V6 블록 좌표로 런타임 변환 + * - 기본 모드(tablet_landscape) 좌표를 블록 단위로 변환 + * - 모드별 overrides 폐기 (자동 줄바꿈으로 대체) + * - DB 데이터는 건드리지 않음 (메모리에서만 변환) + */ +export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 { + // V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음) + // 좌표 변환 필요 여부와 무관하게 항상 제거 + if (!isV5GridConfig(layout)) { + return { + ...layout, + gridConfig: { + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + }, + overrides: undefined, + }; + } + + const v6Columns = getBlockColumns(V5_DESIGN_WIDTH); + + const rowGroups: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(id); + }); + + const convertedPositions: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns); + }); + + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + const rowMapping: Record = {}; + let v6Row = 1; + for (const v5Row of sortedRows) { + rowMapping[v5Row] = v6Row; + const maxSpan = Math.max( + ...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan) + ); + v6Row += maxSpan; + } + + const newComponents = { ...layout.components }; + Object.entries(newComponents).forEach(([id, comp]) => { + const converted = convertedPositions[id]; + const mappedRow = rowMapping[comp.position.row] ?? converted.row; + newComponents[id] = { + ...comp, + position: { ...converted, row: mappedRow }, + }; + }); + + const newModals = layout.modals?.map(modal => { + const modalComps = { ...modal.components }; + Object.entries(modalComps).forEach(([id, comp]) => { + modalComps[id] = { + ...comp, + position: convertV5PositionToV6(comp.position, v6Columns), + }; + }); + return { + ...modal, + gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, + components: modalComps, + overrides: undefined, + }; + }); + + return { + ...layout, + gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, + components: newComponents, + overrides: undefined, + modals: newModals, + }; +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 1c351cf2..f5d06036 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -295,8 +295,8 @@ function BasicSettingsTab({ const recommendation = useMemo(() => { if (!currentMode) return null; const cols = GRID_BREAKPOINTS[currentMode].columns; - if (cols >= 8) return { rows: 3, cols: 2 }; - if (cols >= 6) return { rows: 3, cols: 1 }; + if (cols >= 25) return { rows: 3, cols: 2 }; + if (cols >= 18) return { rows: 3, cols: 1 }; return { rows: 2, cols: 1 }; }, [currentMode]); From 320100c4e2581e79dd69c622e2c5196e8560e713 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 16:32:20 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20POP=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=AA=85=EC=B9=AD=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20+=20Dead=20Code=20=EC=A0=9C=EA=B1=B0=20V5?= =?UTF-8?q?=E2=86=92V6=20=EC=A0=84=ED=99=98=20=EA=B3=BC=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=88=84=EC=A0=81=EB=90=9C=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=A0=91=EB=AF=B8=EC=82=AC,=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98,=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=9E=94?= =?UTF-8?q?=EC=9E=AC=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A6=AC=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=ED=99=95=EB=B3=B4=ED=95=9C=EB=8B=A4.=2014=EA=B0=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95,=20365=EC=A4=84=20=EC=88=9C?= =?UTF-8?q?=EA=B0=90.=20[=ED=83=80=EC=9E=85=20=EB=A6=AC=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D]=20(14=EA=B0=9C=20=ED=8C=8C=EC=9D=BC)=20-=20PopLayout?= =?UTF-8?q?DataV5=20=E2=86=92=20PopLayoutData=20-=20PopComponentDefinition?= =?UTF-8?q?V5=20=E2=86=92=20PopComponentDefinition=20-=20PopGlobalSettings?= =?UTF-8?q?V5=20=E2=86=92=20PopGlobalSettings=20-=20PopModeOverrideV5=20?= =?UTF-8?q?=E2=86=92=20PopModeOverride=20-=20createEmptyPopLayoutV5=20?= =?UTF-8?q?=E2=86=92=20createEmptyLayout=20-=20isV5Layout=20=E2=86=92=20is?= =?UTF-8?q?PopLayout=20-=20addComponentToV5Layout=20=E2=86=92=20addCompone?= =?UTF-8?q?ntToLayout=20-=20createComponentDefinitionV5=20=E2=86=92=20crea?= =?UTF-8?q?teComponentDefinition=20-=20=EA=B5=AC=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=80=20deprecated=20=EB=B3=84=EC=B9=AD=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80=20(=ED=95=98=EC=9C=84=20=ED=98=B8?= =?UTF-8?q?=ED=99=98)=20[Dead=20Code=20=EC=82=AD=EC=A0=9C]=20(gridUtils.ts?= =?UTF-8?q?=20-350=EC=A4=84)=20-=20getAdjustedBreakpoint,=20convertPositio?= =?UTF-8?q?nToMode,=20isOutOfBounds,=20=20=20mouseToGridPosition,=20gridTo?= =?UTF-8?q?PixelPosition,=20isValidPosition,=20=20=20clampPosition,=20auto?= =?UTF-8?q?LayoutComponents=20(=EC=A0=84=EB=B6=80=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=200=EA=B1=B4)=20-=20needsReview=20+=20Review?= =?UTF-8?q?Panel/ReviewItem=20(=ED=95=AD=EC=83=81=20false,=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9)=20-=20getEffectiveComponentPosition=20expor?= =?UTF-8?q?t=20=E2=86=92=20=EB=82=B4=EB=B6=80=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20[=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20=EB=A1=9C=EB=8D=94=20=EB=B6=84=EB=A6=AC]=20(=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20legacyLoader.ts)=20-=20convertV5LayoutToV6=20?= =?UTF-8?q?=E2=86=92=20loadLegacyLayout=20(legacyLoader.ts)=20-=20V5=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=83=81=EC=88=98/=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20gridUtils=EC=97=90=EC=84=9C=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?[=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC]=20-=20"v5=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C"=20=E2=86=92=20"POP=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C"=20-=20"=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=9A=A9"=20=E2=86=92=20"=EB=B7=B0=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=ED=94=84=EB=A6=AC=EC=85=8B"=20/=20"=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=84=A4=EC=A0=95=EC=9A=A9"=20-?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=ED=97=A4=EB=8D=94,=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EA=B5=AC=EB=B6=84,=20=ED=95=A8=EC=88=98=20JSDoc=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?0=EA=B1=B4.=20DB=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4.=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(pop)/pop/screens/[screenId]/page.tsx | 20 +- .../components/pop/designer/PopCanvas.tsx | 134 +----- .../components/pop/designer/PopDesigner.tsx | 43 +- frontend/components/pop/designer/index.ts | 7 +- .../designer/panels/ComponentEditorPanel.tsx | 22 +- .../pop/designer/panels/ConnectionEditor.tsx | 20 +- .../pop/designer/renderers/PopRenderer.tsx | 20 +- .../pop/designer/types/pop-layout.ts | 98 ++-- .../pop/designer/utils/gridUtils.ts | 419 +----------------- .../pop/designer/utils/legacyLoader.ts | 128 ++++++ .../pop/viewer/PopViewerWithModals.tsx | 6 +- .../PopCardListV2Component.tsx | 8 +- .../registry/pop-components/pop-scanner.tsx | 6 +- .../pop-search/PopSearchConfig.tsx | 8 +- .../pop-status-bar/PopStatusBarConfig.tsx | 2 +- 15 files changed, 301 insertions(+), 640 deletions(-) create mode 100644 frontend/components/pop/designer/utils/legacyLoader.ts diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index bf2878a5..c7933033 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -17,17 +17,17 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { - PopLayoutDataV5, + PopLayoutData, GridMode, - isV5Layout, - createEmptyPopLayoutV5, + isPopLayout, + createEmptyLayout, GAP_PRESETS, GRID_BREAKPOINTS, BLOCK_GAP, BLOCK_PADDING, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; -import { convertV5LayoutToV6 } from "@/components/pop/designer/utils/gridUtils"; +import { loadLegacyLayout } from "@/components/pop/designer/utils/legacyLoader"; // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) import "@/lib/registry/pop-components"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; @@ -82,7 +82,7 @@ function PopScreenViewPage() { const { user } = useAuth(); const [screen, setScreen] = useState(null); - const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [layout, setLayout] = useState(createEmptyLayout()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -119,22 +119,22 @@ function PopScreenViewPage() { try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && isV5Layout(popLayout)) { - const v6Layout = convertV5LayoutToV6(popLayout); + if (popLayout && isPopLayout(popLayout)) { + const v6Layout = loadLegacyLayout(popLayout); setLayout(v6Layout); const componentCount = Object.keys(popLayout.components).length; console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { // 다른 버전 레이아웃은 빈 v5로 처리 console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } else { console.log("[POP] 레이아웃 없음"); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } } catch (layoutError) { console.warn("[POP] 레이아웃 로드 실패:", layoutError); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } } catch (error) { console.error("[POP] 화면 로드 실패:", error); diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index acb654ae..d12422ec 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -4,8 +4,8 @@ import { useCallback, useRef, useState, useEffect, useMemo } from "react"; import { useDrop } from "react-dnd"; import { cn } from "@/lib/utils"; import { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopComponentType, PopGridPosition, GridMode, @@ -22,7 +22,7 @@ import { BLOCK_PADDING, getBlockColumns, } from "./types/pop-layout"; -import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useDrag } from "react-dnd"; import { Button } from "@/components/ui/button"; import { @@ -34,7 +34,7 @@ import { } from "@/components/ui/select"; import { toast } from "sonner"; import PopRenderer from "./renderers/PopRenderer"; -import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils"; +import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions } from "./utils/gridUtils"; import { DND_ITEM_TYPES } from "./constants"; /** @@ -95,13 +95,13 @@ const CANVAS_EXTRA_ROWS = 3; // 여유 행 수 // Props // ======================================== interface PopCanvasProps { - layout: PopLayoutDataV5; + layout: PopLayoutData; selectedComponentId: string | null; currentMode: GridMode; onModeChange: (mode: GridMode) => void; onSelectComponent: (id: string | null) => void; onDropComponent: (type: PopComponentType, position: PopGridPosition) => void; - onUpdateComponent: (componentId: string, updates: Partial) => void; + onUpdateComponent: (componentId: string, updates: Partial) => void; onDeleteComponent: (componentId: string) => void; onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void; onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void; @@ -163,7 +163,7 @@ export default function PopCanvas({ }, [layout.modals]); // activeCanvasId에 따라 렌더링할 layout 분기 - const activeLayout = useMemo((): PopLayoutDataV5 => { + const activeLayout = useMemo((): PopLayoutData => { if (activeCanvasId === "main") return layout; const modal = layout.modals?.find(m => m.id === activeCanvasId); if (!modal) return layout; // fallback @@ -401,7 +401,7 @@ export default function PopCanvas({ const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 - // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 + // 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 const currentEffectivePos = effectivePositions.get(dragItem.componentId); const componentData = layout.components[dragItem.componentId]; @@ -472,22 +472,8 @@ export default function PopCanvas({ ); }, [activeLayout.components, hiddenComponentIds]); - // 검토 필요 컴포넌트 목록 - const reviewComponents = useMemo(() => { - return visibleComponents.filter(comp => { - const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id]; - return needsReview(currentMode, hasOverride); - }); - }, [visibleComponents, activeLayout.overrides, currentMode]); - - // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) - const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; - - // 12칸 모드가 아닐 때만 패널 표시 - // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 const hasGridComponents = Object.keys(activeLayout.components).length > 0; const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); - const showRightPanel = showReviewPanel || showHiddenPanel; return (
@@ -668,7 +654,7 @@ export default function PopCanvas({
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */} - {showRightPanel && ( + {showHiddenPanel && (
- {/* 검토 필요 패널 */} - {showReviewPanel && ( - - )} - {/* 숨김 컴포넌트 패널 */} {showHiddenPanel && ( void; -} - -function ReviewPanel({ - components, - selectedComponentId, - onSelectComponent, -}: ReviewPanelProps) { - return ( -
- {/* 헤더 */} -
- - - 검토 필요 ({components.length}개) - -
- - {/* 컴포넌트 목록 */} -
- {components.map((comp) => ( - onSelectComponent(comp.id)} - /> - ))} -
- - {/* 안내 문구 */} -
-

- 자동 배치됨. 클릭하여 확인 후 편집 가능 -

-
-
- ); -} - -// ======================================== -// 검토 필요 아이템 (ReviewPanel 내부) -// ======================================== - -interface ReviewItemProps { - component: PopComponentDefinitionV5; - isSelected: boolean; - onSelect: () => void; -} - -function ReviewItem({ - component, - isSelected, - onSelect, -}: ReviewItemProps) { - return ( -
{ - e.stopPropagation(); - onSelect(); - }} - > - - {component.label || component.id} - - - 자동 배치됨 - -
- ); -} - // ======================================== // 숨김 컴포넌트 영역 (오른쪽 패널) // ======================================== interface HiddenPanelProps { - components: PopComponentDefinitionV5[]; + components: PopComponentDefinition[]; selectedComponentId: string | null; onSelectComponent: (id: string | null) => void; onHideComponent?: (componentId: string) => void; @@ -999,7 +889,7 @@ function HiddenPanel({ // ======================================== interface HiddenItemProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; isSelected: boolean; onSelect: () => void; } diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index de131032..259ead41 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -19,21 +19,22 @@ import PopCanvas from "./PopCanvas"; import ComponentEditorPanel from "./panels/ComponentEditorPanel"; import ComponentPalette from "./panels/ComponentPalette"; import { - PopLayoutDataV5, + PopLayoutData, PopComponentType, - PopComponentDefinitionV5, + PopComponentDefinition, PopGridPosition, GridMode, GapPreset, - createEmptyPopLayoutV5, - isV5Layout, - addComponentToV5Layout, - createComponentDefinitionV5, + createEmptyLayout, + isPopLayout, + addComponentToLayout, + createComponentDefinition, GRID_BREAKPOINTS, PopModalDefinition, PopDataConnection, } from "./types/pop-layout"; -import { getAllEffectivePositions, convertV5LayoutToV6 } from "./utils/gridUtils"; +import { getAllEffectivePositions } from "./utils/gridUtils"; +import { loadLegacyLayout } from "./utils/legacyLoader"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { PopDesignerContext } from "./PopDesignerContext"; @@ -59,10 +60,10 @@ export default function PopDesigner({ // ======================================== // 레이아웃 상태 // ======================================== - const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [layout, setLayout] = useState(createEmptyLayout()); // 히스토리 - const [history, setHistory] = useState([]); + const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); // UI 상태 @@ -84,7 +85,7 @@ export default function PopDesigner({ const [activeCanvasId, setActiveCanvasId] = useState("main"); // 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회) - const selectedComponent: PopComponentDefinitionV5 | null = (() => { + const selectedComponent: PopComponentDefinition | null = (() => { if (!selectedComponentId) return null; if (activeCanvasId === "main") { return layout.components[selectedComponentId] || null; @@ -96,7 +97,7 @@ export default function PopDesigner({ // ======================================== // 히스토리 관리 // ======================================== - const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => { + const saveToHistory = useCallback((newLayout: PopLayoutData) => { setHistory((prev) => { const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(JSON.parse(JSON.stringify(newLayout))); @@ -150,11 +151,11 @@ export default function PopDesigner({ try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { + if (loadedLayout && isPopLayout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { if (!loadedLayout.settings.gapPreset) { loadedLayout.settings.gapPreset = "medium"; } - const v6Layout = convertV5LayoutToV6(loadedLayout); + const v6Layout = loadLegacyLayout(loadedLayout); setLayout(v6Layout); setHistory([v6Layout]); setHistoryIndex(0); @@ -174,7 +175,7 @@ export default function PopDesigner({ console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`); } else { // 새 화면 또는 빈 레이아웃 - const emptyLayout = createEmptyPopLayoutV5(); + const emptyLayout = createEmptyLayout(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); @@ -183,7 +184,7 @@ export default function PopDesigner({ } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); - const emptyLayout = createEmptyPopLayoutV5(); + const emptyLayout = createEmptyLayout(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); @@ -224,13 +225,13 @@ export default function PopDesigner({ if (activeCanvasId === "main") { // 메인 캔버스 - const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + const newLayout = addComponentToLayout(layout, componentId, type, position, `${type} ${idCounter}`); setLayout(newLayout); saveToHistory(newLayout); } else { // 모달 캔버스 setLayout(prev => { - const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`); + const comp = createComponentDefinition(componentId, type, position, `${type} ${idCounter}`); const newLayout = { ...prev, modals: (prev.modals || []).map(m => { @@ -249,7 +250,7 @@ export default function PopDesigner({ ); const handleUpdateComponent = useCallback( - (componentId: string, updates: Partial) => { + (componentId: string, updates: Partial) => { // 함수적 업데이트로 stale closure 방지 setLayout((prev) => { if (activeCanvasId === "main") { @@ -302,7 +303,7 @@ export default function PopDesigner({ const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const newConnection: PopDataConnection = { ...conn, id: newId }; const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, @@ -321,7 +322,7 @@ export default function PopDesigner({ (connectionId: string, conn: Omit) => { setLayout((prev) => { const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, @@ -342,7 +343,7 @@ export default function PopDesigner({ (connectionId: string) => { setLayout((prev) => { const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, diff --git a/frontend/components/pop/designer/index.ts b/frontend/components/pop/designer/index.ts index 37d86aec..c58ec3db 100644 --- a/frontend/components/pop/designer/index.ts +++ b/frontend/components/pop/designer/index.ts @@ -1,4 +1,4 @@ -// POP 디자이너 컴포넌트 export (v5 그리드 시스템) +// POP 디자이너 컴포넌트 export (블록 그리드 시스템) // 타입 export * from "./types"; @@ -17,11 +17,12 @@ export { default as PopRenderer } from "./renderers/PopRenderer"; // 유틸리티 export * from "./utils/gridUtils"; +export * from "./utils/legacyLoader"; // 핵심 타입 재export (편의) export type { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopComponentType, PopGridPosition, GridMode, diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index ec22426d..d79883ad 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -3,7 +3,7 @@ import React from "react"; import { cn } from "@/lib/utils"; import { - PopComponentDefinitionV5, + PopComponentDefinition, PopGridPosition, GridMode, GRID_BREAKPOINTS, @@ -33,15 +33,15 @@ import ConnectionEditor from "./ConnectionEditor"; interface ComponentEditorPanelProps { /** 선택된 컴포넌트 */ - component: PopComponentDefinitionV5 | null; + component: PopComponentDefinition | null; /** 현재 모드 */ currentMode: GridMode; /** 컴포넌트 업데이트 */ - onUpdateComponent?: (updates: Partial) => void; + onUpdateComponent?: (updates: Partial) => void; /** 추가 className */ className?: string; /** 그리드에 배치된 모든 컴포넌트 */ - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; /** 컴포넌트 선택 콜백 */ onSelectComponent?: (componentId: string) => void; /** 현재 선택된 컴포넌트 ID */ @@ -249,11 +249,11 @@ export default function ComponentEditorPanel({ // ======================================== interface PositionFormProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; currentMode: GridMode; isDefaultMode: boolean; columns: number; - onUpdate?: (updates: Partial) => void; + onUpdate?: (updates: Partial) => void; } function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) { @@ -402,13 +402,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate // ======================================== interface ComponentSettingsFormProps { - component: PopComponentDefinitionV5; - onUpdate?: (updates: Partial) => void; + component: PopComponentDefinition; + onUpdate?: (updates: Partial) => void; currentMode?: GridMode; previewPageIndex?: number; onPreviewPage?: (pageIndex: number) => void; modals?: PopModalDefinition[]; - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; connections?: PopDataConnection[]; } @@ -466,8 +466,8 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn // ======================================== interface VisibilityFormProps { - component: PopComponentDefinitionV5; - onUpdate?: (updates: Partial) => void; + component: PopComponentDefinition; + onUpdate?: (updates: Partial) => void; } function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 84b56935..0a64e82a 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -13,7 +13,7 @@ import { SelectValue, } from "@/components/ui/select"; import { - PopComponentDefinitionV5, + PopComponentDefinition, PopDataConnection, } from "../types/pop-layout"; import { @@ -26,8 +26,8 @@ import { getTableColumns } from "@/lib/api/tableManagement"; // ======================================== interface ConnectionEditorProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; connections: PopDataConnection[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; @@ -102,8 +102,8 @@ export default function ConnectionEditor({ // ======================================== interface SendSectionProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; outgoing: PopDataConnection[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; @@ -197,15 +197,15 @@ function SendSection({ // ======================================== interface SimpleConnectionFormProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; initial?: PopDataConnection; onSubmit: (data: Omit) => void; onCancel?: () => void; submitLabel: string; } -function extractSubTableName(comp: PopComponentDefinitionV5): string | null { +function extractSubTableName(comp: PopComponentDefinition): string | null { const cfg = comp.config as Record | undefined; if (!cfg) return null; @@ -423,8 +423,8 @@ function SimpleConnectionForm({ // ======================================== interface ReceiveSectionProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; incoming: PopDataConnection[]; } diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 373bed9b..89b4a551 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -5,8 +5,8 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { DND_ITEM_TYPES } from "../constants"; import { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopGridPosition, GridMode, GRID_BREAKPOINTS, @@ -31,7 +31,7 @@ import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; interface PopRendererProps { /** v5 레이아웃 데이터 */ - layout: PopLayoutDataV5; + layout: PopLayoutData; /** 현재 뷰포트 너비 */ viewportWidth: number; /** 현재 모드 (자동 감지 또는 수동 지정) */ @@ -182,7 +182,7 @@ export default function PopRenderer({ }, [isDesignMode, showGridGuide, columns, dynamicRowCount]); // visibility 체크 - const isVisible = (comp: PopComponentDefinitionV5): boolean => { + const isVisible = (comp: PopComponentDefinition): boolean => { if (!comp.visibility) return true; const modeVisibility = comp.visibility[mode]; return modeVisibility !== false; @@ -207,7 +207,7 @@ export default function PopRenderer({ }; // 오버라이드 적용 또는 자동 재배치 - const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { + const getEffectivePosition = (comp: PopComponentDefinition): PopGridPosition => { // 1순위: 오버라이드가 있으면 사용 const override = overrides?.[mode]?.positions?.[comp.id]; if (override) { @@ -225,7 +225,7 @@ export default function PopRenderer({ }; // 오버라이드 숨김 체크 - const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => { + const isHiddenByOverride = (comp: PopComponentDefinition): boolean => { return overrides?.[mode]?.hidden?.includes(comp.id) ?? false; }; @@ -322,7 +322,7 @@ export default function PopRenderer({ // ======================================== interface DraggableComponentProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; position: PopGridPosition; positionStyle: React.CSSProperties; isSelected: boolean; @@ -423,7 +423,7 @@ function DraggableComponent({ // ======================================== interface ResizeHandlesProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; position: PopGridPosition; breakpoint: GridBreakpoint; viewportWidth: number; @@ -544,7 +544,7 @@ function ResizeHandles({ // ======================================== interface ComponentContentProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; @@ -614,7 +614,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect // ======================================== function renderActualComponent( - component: PopComponentDefinitionV5, + component: PopComponentDefinition, effectivePosition?: PopGridPosition, onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void, screenId?: string, diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 44e4c1c4..7b008caf 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -1,6 +1,4 @@ -// POP 디자이너 레이아웃 타입 정의 -// v5.0: CSS Grid 기반 그리드 시스템 -// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전 +// POP 블록 그리드 레이아웃 타입 정의 // ======================================== // 공통 타입 @@ -122,7 +120,7 @@ export function getBlockColumns(viewportWidth: number): number { } /** - * 그리드 모드 (하위 호환용 - V6에서는 뷰포트 프리셋 라벨로만 사용) + * 뷰포트 프리셋 (디자이너 해상도 전환용) */ export type GridMode = | "mobile_portrait" @@ -131,7 +129,7 @@ export type GridMode = | "tablet_landscape"; /** - * 그리드 브레이크포인트 설정 (하위 호환용) + * 뷰포트 프리셋 설정 */ export interface GridBreakpoint { minWidth?: number; @@ -200,31 +198,31 @@ export function detectGridMode(viewportWidth: number): GridMode { } /** - * v5 레이아웃 (그리드 기반) + * POP 레이아웃 데이터 */ -export interface PopLayoutDataV5 { +export interface PopLayoutData { version: "pop-5.0"; // 그리드 설정 gridConfig: PopGridConfig; // 컴포넌트 정의 (ID → 정의) - components: Record; + components: Record; // 데이터 흐름 dataFlow: PopDataFlow; // 전역 설정 - settings: PopGlobalSettingsV5; + settings: PopGlobalSettings; // 메타데이터 metadata?: PopLayoutMetadata; // 모드별 오버라이드 (위치 변경용) overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; + mobile_portrait?: PopModeOverride; + mobile_landscape?: PopModeOverride; + tablet_portrait?: PopModeOverride; }; // 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성) @@ -256,9 +254,9 @@ export interface PopGridPosition { } /** - * v5 컴포넌트 정의 + * POP 컴포넌트 정의 */ -export interface PopComponentDefinitionV5 { +export interface PopComponentDefinition { id: string; type: PopComponentType; label?: string; @@ -303,9 +301,9 @@ export const GAP_PRESETS: Record = { }; /** - * v5 전역 설정 + * POP 전역 설정 */ -export interface PopGlobalSettingsV5 { +export interface PopGlobalSettings { // 터치 최소 크기 (px) touchTargetMin: number; // 기본 48 @@ -317,9 +315,9 @@ export interface PopGlobalSettingsV5 { } /** - * v5 모드별 오버라이드 + * 모드별 오버라이드 (위치/숨김) */ -export interface PopModeOverrideV5 { +export interface PopModeOverride { // 컴포넌트별 위치 오버라이드 positions?: Record>; @@ -328,13 +326,13 @@ export interface PopModeOverrideV5 { } // ======================================== -// v5 유틸리티 함수 +// 레이아웃 유틸리티 함수 // ======================================== /** - * 빈 v5 레이아웃 생성 + * 빈 POP 레이아웃 생성 */ -export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ +export const createEmptyLayout = (): PopLayoutData => ({ version: "pop-5.0", gridConfig: { rowHeight: BLOCK_SIZE, @@ -351,9 +349,9 @@ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ }); /** - * v5 레이아웃 여부 확인 + * POP 레이아웃 데이터인지 확인 */ -export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { +export const isPopLayout = (layout: any): layout is PopLayoutData => { return layout?.version === "pop-5.0"; }; @@ -382,14 +380,14 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record ({ +): PopComponentDefinition => ({ id, type, label, @@ -397,21 +395,21 @@ export const createComponentDefinitionV5 = ( }); /** - * v5 레이아웃에 컴포넌트 추가 + * POP 레이아웃에 컴포넌트 추가 */ -export const addComponentToV5Layout = ( - layout: PopLayoutDataV5, +export const addComponentToLayout = ( + layout: PopLayoutData, componentId: string, type: PopComponentType, position: PopGridPosition, label?: string -): PopLayoutDataV5 => { +): PopLayoutData => { const newLayout = { ...layout }; // 컴포넌트 정의 추가 newLayout.components = { ...newLayout.components, - [componentId]: createComponentDefinitionV5(componentId, type, position, label), + [componentId]: createComponentDefinition(componentId, type, position, label), }; return newLayout; @@ -486,12 +484,12 @@ export interface PopModalDefinition { /** 모달 내부 그리드 설정 */ gridConfig: PopGridConfig; /** 모달 내부 컴포넌트 */ - components: Record; + components: Record; /** 모드별 오버라이드 */ overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; + mobile_portrait?: PopModeOverride; + mobile_landscape?: PopModeOverride; + tablet_portrait?: PopModeOverride; }; /** 모달 프레임 설정 (닫기 방식) */ frameConfig?: { @@ -507,15 +505,29 @@ export interface PopModalDefinition { } // ======================================== -// 레거시 타입 별칭 (하위 호환 - 추후 제거) +// 레거시 타입 별칭 (이전 코드 호환용) // ======================================== -// 기존 코드에서 import 오류 방지용 -/** @deprecated v5에서는 PopLayoutDataV5 사용 */ -export type PopLayoutData = PopLayoutDataV5; +/** @deprecated PopLayoutData 사용 */ +export type PopLayoutDataV5 = PopLayoutData; -/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */ -export type PopComponentDefinition = PopComponentDefinitionV5; +/** @deprecated PopComponentDefinition 사용 */ +export type PopComponentDefinitionV5 = PopComponentDefinition; -/** @deprecated v5에서는 PopGridPosition 사용 */ -export type GridPosition = PopGridPosition; +/** @deprecated PopGlobalSettings 사용 */ +export type PopGlobalSettingsV5 = PopGlobalSettings; + +/** @deprecated PopModeOverride 사용 */ +export type PopModeOverrideV5 = PopModeOverride; + +/** @deprecated createEmptyLayout 사용 */ +export const createEmptyPopLayoutV5 = createEmptyLayout; + +/** @deprecated isPopLayout 사용 */ +export const isV5Layout = isPopLayout; + +/** @deprecated addComponentToLayout 사용 */ +export const addComponentToV5Layout = addComponentToLayout; + +/** @deprecated createComponentDefinition 사용 */ +export const createComponentDefinitionV5 = createComponentDefinition; diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index e5078f64..5a8895d8 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -1,53 +1,25 @@ +// POP 그리드 유틸리티 (리플로우, 겹침 해결, 위치 계산) + import { PopGridPosition, GridMode, GRID_BREAKPOINTS, - GridBreakpoint, - GapPreset, - GAP_PRESETS, - PopLayoutDataV5, - PopComponentDefinitionV5, - BLOCK_SIZE, - BLOCK_GAP, - BLOCK_PADDING, - getBlockColumns, + PopLayoutData, } from "../types/pop-layout"; // ======================================== -// Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환) -// ======================================== - -export function getAdjustedBreakpoint( - base: GridBreakpoint, - preset: GapPreset -): GridBreakpoint { - return { ...base }; -} - -// ======================================== -// 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요) +// 리플로우 (행 그룹 기반 자동 재배치) // ======================================== /** - * V6: 단일 좌표계이므로 변환 없이 원본 반환 - * @deprecated V6에서는 좌표 변환이 불필요합니다 - */ -export function convertPositionToMode( - position: PopGridPosition, - targetMode: GridMode -): PopGridPosition { - return position; -} - -/** - * V6 행 그룹 리플로우 (방식 F) + * 행 그룹 리플로우 * - * 원리: CSS Flexbox wrap과 동일. + * CSS Flexbox wrap 원리로 자동 재배치한다. * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) - * 4. 설계 너비의 50% 이상 → 전체 너비 확장 - * 5. 리플로우 후 겹침 해결 (resolveOverlaps) + * 4. 설계 너비의 50% 이상인 컴포넌트는 전체 너비 확장 + * 5. 리플로우 후 겹침 해결 */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, @@ -66,7 +38,6 @@ export function convertAndResolvePositions( const MIN_COL_SPAN = 2; const MIN_ROW_SPAN = 2; - // 1. 원본 row 기준 그룹핑 const rowGroups: Record> = {}; components.forEach(comp => { const r = comp.position.row; @@ -77,7 +48,6 @@ export function convertAndResolvePositions( const placed: Array<{ id: string; position: PopGridPosition }> = []; let outputRow = 1; - // 2. 각 행 그룹을 순서대로 처리 const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); for (const rowKey of sortedRows) { @@ -96,7 +66,6 @@ export function convertAndResolvePositions( const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan); - // 현재 줄에 안 들어가면 줄바꿈 if (currentCol + scaledSpan - 1 > targetColumns) { outputRow += Math.max(1, maxRowSpanInLine); currentCol = 1; @@ -120,50 +89,18 @@ export function convertAndResolvePositions( outputRow += Math.max(1, maxRowSpanInLine); } - // 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리) return resolveOverlaps(placed, targetColumns); } -// ======================================== -// 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음) -// ======================================== - -/** - * V6: 단일 좌표계 + 자동 줄바꿈이므로 검토 필요 없음 - * 항상 false 반환 - */ -export function needsReview( - currentMode: GridMode, - hasOverride: boolean -): boolean { - return false; -} - -/** - * @deprecated V6에서는 자동 줄바꿈이므로 화면 밖 개념 없음 - */ -export function isOutOfBounds( - originalPosition: PopGridPosition, - currentMode: GridMode, - overridePosition?: PopGridPosition | null -): boolean { - return false; -} - // ======================================== // 겹침 감지 및 해결 // ======================================== -/** - * 두 위치가 겹치는지 확인 - */ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { - // 열 겹침 체크 const aColEnd = a.col + a.colSpan - 1; const bColEnd = b.col + b.colSpan - 1; const colOverlap = !(aColEnd < b.col || bColEnd < a.col); - // 행 겹침 체크 const aRowEnd = a.row + a.rowSpan - 1; const bRowEnd = b.row + b.rowSpan - 1; const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row); @@ -171,14 +108,10 @@ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { return colOverlap && rowOverlap; } -/** - * 겹침 해결 (아래로 밀기) - */ export function resolveOverlaps( positions: Array<{ id: string; position: PopGridPosition }>, columns: number ): Array<{ id: string; position: PopGridPosition }> { - // row, col 순으로 정렬 const sorted = [...positions].sort((a, b) => a.position.row - b.position.row || a.position.col - b.position.col ); @@ -188,21 +121,15 @@ export function resolveOverlaps( sorted.forEach((item) => { let { row, col, colSpan, rowSpan } = item.position; - // 열이 범위를 초과하면 조정 if (col + colSpan - 1 > columns) { colSpan = columns - col + 1; } - // 기존 배치와 겹치면 아래로 이동 let attempts = 0; - const maxAttempts = 100; - - while (attempts < maxAttempts) { + while (attempts < 100) { const currentPos: PopGridPosition = { col, row, colSpan, rowSpan }; const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position)); - if (!hasOverlap) break; - row++; attempts++; } @@ -217,110 +144,9 @@ export function resolveOverlaps( } // ======================================== -// 좌표 변환 +// 자동 배치 (새 컴포넌트 드롭 시) // ======================================== -/** - * V6: 마우스 좌표 → 블록 그리드 좌표 변환 - * 블록 크기가 고정(BLOCK_SIZE)이므로 계산이 단순함 - */ -export function mouseToGridPosition( - mouseX: number, - mouseY: number, - canvasRect: DOMRect, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { col: number; row: number } { - const relX = mouseX - canvasRect.left - padding; - const relY = mouseY - canvasRect.top - padding; - - const cellStride = BLOCK_SIZE + gap; - - const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); - const row = Math.max(1, Math.floor(relY / cellStride) + 1); - - return { col, row }; -} - -/** - * V6: 블록 그리드 좌표 → 픽셀 좌표 변환 - */ -export function gridToPixelPosition( - col: number, - row: number, - colSpan: number, - rowSpan: number, - canvasWidth: number, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { x: number; y: number; width: number; height: number } { - const cellStride = BLOCK_SIZE + gap; - - return { - x: padding + (col - 1) * cellStride, - y: padding + (row - 1) * cellStride, - width: BLOCK_SIZE * colSpan + gap * (colSpan - 1), - height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1), - }; -} - -// ======================================== -// 위치 검증 -// ======================================== - -/** - * 위치가 그리드 범위 내에 있는지 확인 - */ -export function isValidPosition( - position: PopGridPosition, - columns: number -): boolean { - return ( - position.col >= 1 && - position.row >= 1 && - position.colSpan >= 1 && - position.rowSpan >= 1 && - position.col + position.colSpan - 1 <= columns - ); -} - -/** - * 위치를 그리드 범위 내로 조정 - */ -export function clampPosition( - position: PopGridPosition, - columns: number -): PopGridPosition { - let { col, row, colSpan, rowSpan } = position; - - // 최소값 보장 - col = Math.max(1, col); - row = Math.max(1, row); - colSpan = Math.max(1, colSpan); - rowSpan = Math.max(1, rowSpan); - - // 열 범위 초과 방지 - if (col + colSpan - 1 > columns) { - if (col > columns) { - col = 1; - } - colSpan = columns - col + 1; - } - - return { col, row, colSpan, rowSpan }; -} - -// ======================================== -// 자동 배치 -// ======================================== - -/** - * 다음 빈 위치 찾기 - */ export function findNextEmptyPosition( existingPositions: PopGridPosition[], colSpan: number, @@ -329,168 +155,94 @@ export function findNextEmptyPosition( ): PopGridPosition { let row = 1; let col = 1; - - const maxAttempts = 1000; let attempts = 0; - while (attempts < maxAttempts) { + while (attempts < 1000) { const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan }; - // 범위 체크 if (col + colSpan - 1 > columns) { col = 1; row++; continue; } - // 겹침 체크 - const hasOverlap = existingPositions.some(pos => - isOverlapping(candidatePos, pos) - ); + const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePos, pos)); + if (!hasOverlap) return candidatePos; - if (!hasOverlap) { - return candidatePos; - } - - // 다음 위치로 이동 col++; if (col + colSpan - 1 > columns) { col = 1; row++; } - attempts++; } - // 실패 시 마지막 행에 배치 return { col: 1, row: row + 1, colSpan, rowSpan }; } -/** - * 컴포넌트들을 자동으로 배치 - */ -export function autoLayoutComponents( - components: Array<{ id: string; colSpan: number; rowSpan: number }>, - columns: number -): Array<{ id: string; position: PopGridPosition }> { - const result: Array<{ id: string; position: PopGridPosition }> = []; - - let currentRow = 1; - let currentCol = 1; - - components.forEach(comp => { - // 현재 행에 공간이 부족하면 다음 행으로 - if (currentCol + comp.colSpan - 1 > columns) { - currentRow++; - currentCol = 1; - } - - result.push({ - id: comp.id, - position: { - col: currentCol, - row: currentRow, - colSpan: comp.colSpan, - rowSpan: comp.rowSpan, - }, - }); - - currentCol += comp.colSpan; - }); - - return result; -} - // ======================================== -// 유효 위치 계산 (통합 함수) +// 유효 위치 계산 // ======================================== /** - * 컴포넌트의 유효 위치를 계산합니다. + * 컴포넌트의 유효 위치를 계산한다. * 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치 - * - * @param componentId 컴포넌트 ID - * @param layout 전체 레이아웃 데이터 - * @param mode 현재 그리드 모드 - * @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적) */ -export function getEffectiveComponentPosition( +function getEffectiveComponentPosition( componentId: string, - layout: PopLayoutDataV5, + layout: PopLayoutData, mode: GridMode, autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }> ): PopGridPosition | null { const component = layout.components[componentId]; if (!component) return null; - // 1순위: 오버라이드가 있으면 사용 const override = layout.overrides?.[mode]?.positions?.[componentId]; if (override) { return { ...component.position, ...override }; } - // 2순위: 자동 재배치된 위치 사용 if (autoResolvedPositions) { const autoResolved = autoResolvedPositions.find(p => p.id === componentId); - if (autoResolved) { - return autoResolved.position; - } + if (autoResolved) return autoResolved.position; } else { - // 자동 재배치 직접 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const resolved = convertAndResolvePositions(componentsArray, mode); const autoResolved = resolved.find(p => p.id === componentId); - if (autoResolved) { - return autoResolved.position; - } + if (autoResolved) return autoResolved.position; } - // 3순위: 원본 위치 (12칸 모드) return component.position; } /** - * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. - * 숨김 처리된 컴포넌트는 제외됩니다. - * - * v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로 - * "화면 밖" 개념이 제거되었습니다. + * 모든 컴포넌트의 유효 위치를 일괄 계산한다. + * 숨김 처리된 컴포넌트는 제외. */ export function getAllEffectivePositions( - layout: PopLayoutDataV5, + layout: PopLayoutData, mode: GridMode ): Map { const result = new Map(); - // 숨김 처리된 컴포넌트 ID 목록 const hiddenIds = layout.overrides?.[mode]?.hidden || []; - // 자동 재배치 위치 미리 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode); - // 각 컴포넌트의 유효 위치 계산 Object.keys(layout.components).forEach(componentId => { - // 숨김 처리된 컴포넌트는 제외 - if (hiddenIds.includes(componentId)) { - return; - } + if (hiddenIds.includes(componentId)) return; const position = getEffectiveComponentPosition( - componentId, - layout, - mode, - autoResolvedPositions + componentId, layout, mode, autoResolvedPositions ); - // v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음 - // 따라서 추가 필터링 불필요 if (position) { result.set(componentId, position); } @@ -498,126 +250,3 @@ export function getAllEffectivePositions( return result; } - -// ======================================== -// V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환) -// ======================================== - -const V5_BASE_COLUMNS = 12; -const V5_BASE_ROW_HEIGHT = 48; -const V5_BASE_GAP = 16; -const V5_DESIGN_WIDTH = 1024; - -/** - * V5 레이아웃 판별: gridConfig.rowHeight가 V5 기본값(48)이고 - * 좌표가 12칸 체계인 경우만 V5로 판정 - */ -function isV5GridConfig(layout: PopLayoutDataV5): boolean { - if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; - - const maxCol = Object.values(layout.components).reduce((max, comp) => { - const end = comp.position.col + comp.position.colSpan - 1; - return Math.max(max, end); - }, 0); - - return maxCol <= V5_BASE_COLUMNS; -} - -function convertV5PositionToV6( - pos: PopGridPosition, - v6DesignColumns: number, -): PopGridPosition { - const colRatio = v6DesignColumns / V5_BASE_COLUMNS; - const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP); - - const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); - let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); - const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); - - if (newCol + newColSpan - 1 > v6DesignColumns) { - newColSpan = v6DesignColumns - newCol + 1; - } - - return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; -} - -/** - * V5 레이아웃을 V6 블록 좌표로 런타임 변환 - * - 기본 모드(tablet_landscape) 좌표를 블록 단위로 변환 - * - 모드별 overrides 폐기 (자동 줄바꿈으로 대체) - * - DB 데이터는 건드리지 않음 (메모리에서만 변환) - */ -export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 { - // V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음) - // 좌표 변환 필요 여부와 무관하게 항상 제거 - if (!isV5GridConfig(layout)) { - return { - ...layout, - gridConfig: { - rowHeight: BLOCK_SIZE, - gap: BLOCK_GAP, - padding: BLOCK_PADDING, - }, - overrides: undefined, - }; - } - - const v6Columns = getBlockColumns(V5_DESIGN_WIDTH); - - const rowGroups: Record = {}; - Object.entries(layout.components).forEach(([id, comp]) => { - const r = comp.position.row; - if (!rowGroups[r]) rowGroups[r] = []; - rowGroups[r].push(id); - }); - - const convertedPositions: Record = {}; - Object.entries(layout.components).forEach(([id, comp]) => { - convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns); - }); - - const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); - const rowMapping: Record = {}; - let v6Row = 1; - for (const v5Row of sortedRows) { - rowMapping[v5Row] = v6Row; - const maxSpan = Math.max( - ...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan) - ); - v6Row += maxSpan; - } - - const newComponents = { ...layout.components }; - Object.entries(newComponents).forEach(([id, comp]) => { - const converted = convertedPositions[id]; - const mappedRow = rowMapping[comp.position.row] ?? converted.row; - newComponents[id] = { - ...comp, - position: { ...converted, row: mappedRow }, - }; - }); - - const newModals = layout.modals?.map(modal => { - const modalComps = { ...modal.components }; - Object.entries(modalComps).forEach(([id, comp]) => { - modalComps[id] = { - ...comp, - position: convertV5PositionToV6(comp.position, v6Columns), - }; - }); - return { - ...modal, - gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, - components: modalComps, - overrides: undefined, - }; - }); - - return { - ...layout, - gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, - components: newComponents, - overrides: undefined, - modals: newModals, - }; -} diff --git a/frontend/components/pop/designer/utils/legacyLoader.ts b/frontend/components/pop/designer/utils/legacyLoader.ts new file mode 100644 index 00000000..42cf20d7 --- /dev/null +++ b/frontend/components/pop/designer/utils/legacyLoader.ts @@ -0,0 +1,128 @@ +// 레거시 레이아웃 로더 +// DB에 저장된 V5(12칸) 좌표를 현재 블록 좌표로 변환한다. +// DB 데이터는 건드리지 않고, 로드 시 메모리에서만 변환. + +import { + PopGridPosition, + PopLayoutData, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, +} from "../types/pop-layout"; + +const LEGACY_COLUMNS = 12; +const LEGACY_ROW_HEIGHT = 48; +const LEGACY_GAP = 16; +const DESIGN_WIDTH = 1024; + +function isLegacyGridConfig(layout: PopLayoutData): boolean { + if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; + + const maxCol = Object.values(layout.components).reduce((max, comp) => { + const end = comp.position.col + comp.position.colSpan - 1; + return Math.max(max, end); + }, 0); + + return maxCol <= LEGACY_COLUMNS; +} + +function convertLegacyPosition( + pos: PopGridPosition, + targetColumns: number, +): PopGridPosition { + const colRatio = targetColumns / LEGACY_COLUMNS; + const rowRatio = (LEGACY_ROW_HEIGHT + LEGACY_GAP) / (BLOCK_SIZE + BLOCK_GAP); + + const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); + let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); + const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); + + if (newCol + newColSpan - 1 > targetColumns) { + newColSpan = targetColumns - newCol + 1; + } + + return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; +} + +const BLOCK_GRID_CONFIG = { + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, +}; + +/** + * DB에서 로드한 레이아웃을 현재 블록 좌표로 변환한다. + * + * - 12칸 레거시 좌표 → 블록 좌표 변환 + * - 이미 블록 좌표인 경우 → gridConfig만 보정 + * - 구 모드별 overrides는 항상 제거 (리플로우가 대체) + */ +export function loadLegacyLayout(layout: PopLayoutData): PopLayoutData { + if (!isLegacyGridConfig(layout)) { + return { + ...layout, + gridConfig: BLOCK_GRID_CONFIG, + overrides: undefined, + }; + } + + const blockColumns = getBlockColumns(DESIGN_WIDTH); + + const rowGroups: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(id); + }); + + const convertedPositions: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + convertedPositions[id] = convertLegacyPosition(comp.position, blockColumns); + }); + + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + const rowMapping: Record = {}; + let currentRow = 1; + for (const legacyRow of sortedRows) { + rowMapping[legacyRow] = currentRow; + const maxSpan = Math.max( + ...rowGroups[legacyRow].map(id => convertedPositions[id].rowSpan) + ); + currentRow += maxSpan; + } + + const newComponents = { ...layout.components }; + Object.entries(newComponents).forEach(([id, comp]) => { + const converted = convertedPositions[id]; + const mappedRow = rowMapping[comp.position.row] ?? converted.row; + newComponents[id] = { + ...comp, + position: { ...converted, row: mappedRow }, + }; + }); + + const newModals = layout.modals?.map(modal => { + const modalComps = { ...modal.components }; + Object.entries(modalComps).forEach(([id, comp]) => { + modalComps[id] = { + ...comp, + position: convertLegacyPosition(comp.position, blockColumns), + }; + }); + return { + ...modal, + gridConfig: BLOCK_GRID_CONFIG, + components: modalComps, + overrides: undefined, + }; + }); + + return { + ...layout, + gridConfig: BLOCK_GRID_CONFIG, + components: newComponents, + overrides: undefined, + modals: newModals, + }; +} diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx index f322d4c0..9fbe0af5 100644 --- a/frontend/components/pop/viewer/PopViewerWithModals.tsx +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import PopRenderer from "../designer/renderers/PopRenderer"; -import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; +import type { PopLayoutData, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; @@ -31,7 +31,7 @@ import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; interface PopViewerWithModalsProps { /** 전체 레이아웃 (모달 정의 포함) */ - layout: PopLayoutDataV5; + layout: PopLayoutData; /** 뷰포트 너비 */ viewportWidth: number; /** 화면 ID (이벤트 버스용) */ @@ -178,7 +178,7 @@ export default function PopViewerWithModals({ const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; - const modalLayout: PopLayoutDataV5 = { + const modalLayout: PopLayoutData = { ...layout, gridConfig: definition.gridConfig, components: definition.components, diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 55829efb..6fe66e41 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -49,8 +49,8 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; -import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; -import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; +import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout"; +import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; import dynamic from "next/dynamic"; const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false }); @@ -216,7 +216,7 @@ export function PopCardListV2Component({ // ===== 모달 열기 (POP 화면) ===== const [popModalOpen, setPopModalOpen] = useState(false); - const [popModalLayout, setPopModalLayout] = useState(null); + const [popModalLayout, setPopModalLayout] = useState(null); const [popModalScreenId, setPopModalScreenId] = useState(""); const [popModalRow, setPopModalRow] = useState(null); @@ -228,7 +228,7 @@ export function PopCardListV2Component({ return; } const popLayout = await screenApi.getLayoutPop(sid); - if (popLayout && isV5Layout(popLayout)) { + if (popLayout && isPopLayout(popLayout)) { setPopModalLayout(popLayout); setPopModalScreenId(String(sid)); setPopModalRow(row); diff --git a/frontend/lib/registry/pop-components/pop-scanner.tsx b/frontend/lib/registry/pop-components/pop-scanner.tsx index e2230170..4ce86cc8 100644 --- a/frontend/lib/registry/pop-components/pop-scanner.tsx +++ b/frontend/lib/registry/pop-components/pop-scanner.tsx @@ -19,7 +19,7 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { BarcodeScanModal } from "@/components/common/BarcodeScanModal"; import type { PopDataConnection, - PopComponentDefinitionV5, + PopComponentDefinition, } from "@/components/pop/designer/types/pop-layout"; // ======================================== @@ -99,7 +99,7 @@ function parseScanResult( function getConnectedFields( componentId?: string, connections?: PopDataConnection[], - allComponents?: PopComponentDefinitionV5[], + allComponents?: PopComponentDefinition[], ): ConnectedFieldInfo[] { if (!componentId || !connections || !allComponents) return []; @@ -308,7 +308,7 @@ const PARSE_MODE_LABELS: Record = { interface PopScannerConfigPanelProps { config: PopScannerConfig; onUpdate: (config: PopScannerConfig) => void; - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; connections?: PopDataConnection[]; componentId?: string; } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 8c619429..b0752146 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -72,7 +72,7 @@ const DEFAULT_CONFIG: PopSearchConfig = { interface ConfigPanelProps { config: PopSearchConfig | undefined; onUpdate: (config: PopSearchConfig) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -151,7 +151,7 @@ export function PopSearchConfigPanel({ config, onUpdate, allComponents, connecti interface StepProps { cfg: PopSearchConfig; update: (partial: Partial) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -268,7 +268,7 @@ interface FilterConnectionSectionProps { update: (partial: Partial) => void; showFieldName: boolean; fixedFilterMode?: SearchFilterMode; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -284,7 +284,7 @@ interface ConnectedComponentInfo { function getConnectedComponentInfo( componentId?: string, connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[], - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[], + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[], ): ConnectedComponentInfo { const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() }; if (!componentId || !connections || !allComponents) return empty; diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx index 3b0ce864..8118dfe2 100644 --- a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx @@ -22,7 +22,7 @@ import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types"; interface ConfigPanelProps { config: StatusBarConfig | undefined; onUpdate: (config: StatusBarConfig) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } From 3bd0eff82e3d577c1b1c675dde48d188c0a6b732 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 16 Mar 2026 10:32:58 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20BLOCK=20DETAIL=20Phase=203=20-=20?= =?UTF-8?q?pop-work-detail=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20+=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=84=B8=EB=B6=80=EC=A7=84=ED=96=89?= =?UTF-8?q?=ED=99=94=EB=A9=B4(4502)=EC=9D=98=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B5=AC=ED=98=84:=20pop-work-detail=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B3=BC=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EC=9D=84=20=ED=86=B5=ED=95=B4,=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EB=B3=84=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8/?= =?UTF-8?q?=EA=B2=80=EC=82=AC/=EC=8B=A4=EC=A0=81=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= =?UTF-8?q?.=20[=EC=8B=A0=EA=B7=9C]=20pop-work-detail=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20(4=ED=8C=8C=EC=9D=BC)=20-=20PopWorkDetailC?= =?UTF-8?q?omponent:=20parentRow=20=E2=86=92=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=EC=B6=94=EC=B6=9C=20=E2=86=92=20process?= =?UTF-8?q?=5Fwork=5Fresult=20=EC=A1=B0=ED=9A=8C,=20=20=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94(PRE/IN/POST=20?= =?UTF-8?q?3=EB=8B=A8=EA=B3=84=20=EC=9E=91=EC=97=85=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9)=20+=20=EC=9A=B0=EC=B8=A1=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8(5=EC=A2=85:=20check/inspec?= =?UTF-8?q?t/=20=20=20input/procedure/material)=20+=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=EC=A0=9C=EC=96=B4(start/pause/resume)=20+=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EB=93=B1=EB=A1=9D=20+=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EC=99=84=EB=A3=8C=20-=20PopWorkDetailConfig:=20sho?= =?UTF-8?q?wTimer/showQuantityInput/phaseLabels=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20-=20PopWorkDetailPreview:=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=ED=94=84=EB=A6=AC=EB=B7=B0=20-?= =?UTF-8?q?=20index.tsx:=20PopComponentRegistry=20=EB=93=B1=EB=A1=9D=20(ca?= =?UTF-8?q?tegory:=20display,=20touchOptimized)=20[=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=8B=9C=EC=8A=A4=ED=85=9C]=20Po?= =?UTF-8?q?pDesigner.tsx=20=EB=8C=80=EA=B7=9C=EB=AA=A8=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20handleMoveComponent/handleRes?= =?UTF-8?q?izeComponent/handleRequestResize:=20=20=20layout=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=B0=B8=EC=A1=B0=20=E2=86=92=20setLayout(prev=20?= =?UTF-8?q?=3D>=20...)=20=ED=95=A8=EC=88=98=ED=98=95=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A1=9C=20=EC=A0=84=ED=99=98=20=20=20+=20ac?= =?UTF-8?q?tiveCanvasId=20=EB=B6=84=EA=B8=B0:=20main=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EB=A1=9C=EC=A7=81,=20modal-*=EC=9D=B4?= =?UTF-8?q?=EB=A9=B4=20modals=20=EB=B0=B0=EC=97=B4=20=EB=82=B4=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EB=AA=A8=EB=8B=AC=20=EC=88=98=EC=A0=95=20-=20PopCa?= =?UTF-8?q?rdListV2Config:=20=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1/=EC=97=B4=EA=B8=B0=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20(usePopDesignerContext=20=EC=97=B0=EB=8F=99)=20-=20?= =?UTF-8?q?PopCardListV2Component:=20modal-*=20screenId=20=E2=86=92=20setS?= =?UTF-8?q?haredData=20+=20=5F=5Fpop=5Fmodal=5Fopen=5F=5F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20-=20PopViewerWithModals:=20parentRow=20pro?= =?UTF-8?q?p=20+=20fullscreen=20=EB=AA=A8=EB=8B=AC=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?+=20flex=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20[=EA=B8=B0?= =?UTF-8?q?=ED=83=80]=20-=20ComponentPalette:=20pop-work-detail=20?= =?UTF-8?q?=ED=8C=94=EB=A0=88=ED=8A=B8=20=ED=95=AD=EB=AA=A9=20+=20Clipboar?= =?UTF-8?q?dCheck=20=EC=95=84=EC=9D=B4=EC=BD=98=20-=20pop-layout.ts:=20Pop?= =?UTF-8?q?ComponentType=EC=97=90=20pop-work-detail=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=ED=81=AC=EA=B8=B0=2038x26=20-=20PopRen?= =?UTF-8?q?derer:=20COMPONENT=5FTYPE=5FLABELS=EC=97=90=20pop-work-detail?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20types.ts:=20PopWorkDetailConfig=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20-=20PopCanvas.t?= =?UTF-8?q?sx:=20activeLayout.components=20=EC=B0=B8=EC=A1=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=20=ED=98=B8=ED=99=98)=20DB=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4.?= =?UTF-8?q?=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=200?= =?UTF-8?q?=EA=B1=B4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/pop/designer/PopCanvas.tsx | 2 +- .../components/pop/designer/PopDesigner.tsx | 345 +++++--- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/renderers/PopRenderer.tsx | 1 + .../pop/designer/types/pop-layout.ts | 3 +- .../pop/viewer/PopViewerWithModals.tsx | 34 +- frontend/lib/registry/pop-components/index.ts | 1 + .../PopCardListV2Component.tsx | 12 +- .../pop-card-list-v2/PopCardListV2Config.tsx | 57 +- .../PopWorkDetailComponent.tsx | 832 ++++++++++++++++++ .../pop-work-detail/PopWorkDetailConfig.tsx | 72 ++ .../pop-work-detail/PopWorkDetailPreview.tsx | 30 + .../pop-components/pop-work-detail/index.tsx | 39 + frontend/lib/registry/pop-components/types.ts | 11 + 14 files changed, 1299 insertions(+), 148 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/index.tsx diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index d12422ec..a306c1f1 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -403,7 +403,7 @@ export default function PopCanvas({ // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 // 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 const currentEffectivePos = effectivePositions.get(dragItem.componentId); - const componentData = layout.components[dragItem.componentId]; + const componentData = activeLayout.components[dragItem.componentId]; if (!currentEffectivePos && !componentData) return; diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 259ead41..8e6df1a3 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -389,97 +389,156 @@ export default function PopDesigner({ const handleMoveComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { - const component = layout.components[componentId]; - if (!component) return; - - // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - // 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리 - const currentHidden = layout.overrides?.[currentMode]?.hidden || []; - const isHidden = currentHidden.includes(componentId); - const newHidden = isHidden - ? currentHidden.filter(id => id !== componentId) - : currentHidden; - - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + setLayout((prev) => { + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + + if (currentMode === "tablet_landscape") { + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - // 숨김 배열 업데이트 (빈 배열이면 undefined로) - hidden: newHidden.length > 0 ? newHidden : undefined, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } + }; + saveToHistory(newLayout); + return newLayout; + } else { + const currentHidden = prev.overrides?.[currentMode]?.hidden || []; + const newHidden = currentHidden.filter(id => id !== componentId); + const newLayout = { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + const currentHidden = m.overrides?.[currentMode]?.hidden || []; + const newHidden = currentHidden.filter(id => id !== componentId); + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + } + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); + setHasChanges(true); }, - [layout, saveToHistory, currentMode] + [saveToHistory, currentMode, activeCanvasId] ); const handleResizeComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { - const component = layout.components[componentId]; - if (!component) return; - - // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - // 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장 - // 현재는 간단히 매번 저장 (최적화 가능) - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + setLayout((prev) => { + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + + if (currentMode === "tablet_landscape") { + return { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - }, - }, - }; - setLayout(newLayout); - setHasChanges(true); - } + }; + } else { + return { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + } else { + // 모달 캔버스 + return { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + }), + }; + } + }); + setHasChanges(true); }, - [layout, currentMode] + [currentMode, activeCanvasId] ); const handleResizeEnd = useCallback( @@ -493,51 +552,87 @@ export default function PopDesigner({ // 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등) const handleRequestResize = useCallback( (componentId: string, newRowSpan: number, newColSpan?: number) => { - const component = layout.components[componentId]; - if (!component) return; + setLayout((prev) => { + const buildPosition = (comp: PopComponentDefinition) => ({ + ...comp.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }); - const newPosition = { - ...component.position, - rowSpan: newRowSpan, - ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), - }; - - // 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + const newPosition = buildPosition(component); + + if (currentMode === "tablet_landscape") { + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } + }; + saveToHistory(newLayout); + return newLayout; + } else { + const newLayout = { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + const newPosition = buildPosition(component); + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); + setHasChanges(true); }, - [layout, currentMode, saveToHistory] + [currentMode, saveToHistory, activeCanvasId] ); // ======================================== diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 3817b54d..ddedc7d0 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -93,6 +93,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: UserCircle, description: "사용자 프로필 / PC 전환 / 로그아웃", }, + { + type: "pop-work-detail", + label: "작업 상세", + icon: ClipboardCheck, + description: "공정별 체크리스트/검사/실적 상세 작업 화면", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 89b4a551..3af031b4 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -84,6 +84,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-field": "입력", "pop-scanner": "스캐너", "pop-profile": "프로필", + "pop-work-detail": "작업 상세", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 7b008caf..f859cf5d 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -7,7 +7,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail"; /** * 데이터 흐름 정의 @@ -377,6 +377,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record; } /** 열린 모달 상태 */ interface OpenModal { definition: PopModalDefinition; returnTo?: string; + fullscreen?: boolean; } // ======================================== @@ -61,10 +64,17 @@ export default function PopViewerWithModals({ currentMode, overrideGap, overridePadding, + parentRow, }: PopViewerWithModalsProps) { const router = useRouter(); const [modalStack, setModalStack] = useState([]); - const { subscribe, publish } = usePopEvent(screenId); + const { subscribe, publish, setSharedData } = usePopEvent(screenId); + + useEffect(() => { + if (parentRow) { + setSharedData("parentRow", parentRow); + } + }, [parentRow, setSharedData]); // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환 const stableConnections = useMemo( @@ -96,6 +106,7 @@ export default function PopViewerWithModals({ title?: string; mode?: string; returnTo?: string; + fullscreen?: boolean; }; if (data?.modalId) { @@ -104,6 +115,7 @@ export default function PopViewerWithModals({ setModalStack(prev => [...prev, { definition: modalDef, returnTo: data.returnTo, + fullscreen: data.fullscreen, }]); } } @@ -173,7 +185,7 @@ export default function PopViewerWithModals({ {/* 모달 스택 렌더링 */} {modalStack.map((modal, index) => { - const { definition } = modal; + const { definition, fullscreen } = modal; const isTopModal = index === modalStack.length - 1; const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; @@ -185,10 +197,15 @@ export default function PopViewerWithModals({ overrides: definition.overrides, }; - const detectedMode = currentMode || detectGridMode(viewportWidth); - const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); - const isFull = modalWidth >= viewportWidth; - const rendererWidth = isFull ? viewportWidth : modalWidth - 32; + const isFull = fullscreen || (() => { + const detectedMode = currentMode || detectGridMode(viewportWidth); + const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); + return modalWidth >= viewportWidth; + })(); + const rendererWidth = isFull + ? viewportWidth + : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth) - 32; + const modalWidth = isFull ? viewportWidth : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth); return ( { - // 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지) if (!isTopModal || !closeOnOverlay) e.preventDefault(); }} onEscapeKeyDown={(e) => { if (!isTopModal || !closeOnEsc) e.preventDefault(); }} > - + {definition.title} diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 351d6700..28e6a746 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -26,3 +26,4 @@ import "./pop-status-bar"; import "./pop-field"; import "./pop-scanner"; import "./pop-profile"; +import "./pop-work-detail"; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 6d04d91c..8c3c6447 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -139,7 +139,7 @@ export function PopCardListV2Component({ currentColSpan, onRequestResize, }: PopCardListV2ComponentProps) { - const { subscribe, publish } = usePopEvent(screenId || "default"); + const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default"); const router = useRouter(); const { userId: currentUserId } = useAuth(); @@ -250,6 +250,13 @@ export function PopCardListV2Component({ const [popModalRow, setPopModalRow] = useState(null); const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => { + // 내부 모달 캔버스 (디자이너에서 생성한 modal-*)인 경우 이벤트 발행 + if (screenIdStr.startsWith("modal-")) { + setSharedData("parentRow", row); + publish("__pop_modal_open__", { modalId: screenIdStr, fullscreen: true }); + return; + } + // 외부 POP 화면 ID인 경우 기존 fetch 방식 try { const sid = parseInt(screenIdStr, 10); if (isNaN(sid)) { @@ -268,7 +275,7 @@ export function PopCardListV2Component({ } catch { toast.error("POP 화면을 불러오는데 실패했습니다."); } - }, []); + }, [publish, setSharedData]); const handleCardSelect = useCallback((row: RowData) => { @@ -1176,6 +1183,7 @@ export function PopCardListV2Component({ viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024} screenId={popModalScreenId} currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)} + parentRow={popModalRow ?? undefined} /> )}
diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 04ba0622..9fc1339a 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "rea import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext"; import { Switch } from "@/components/ui/switch"; import { Select, @@ -2940,6 +2941,7 @@ function TabActions({ onUpdate: (partial: Partial) => void; columns: ColumnInfo[]; }) { + const designerCtx = usePopDesignerContext(); const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; @@ -3013,15 +3015,52 @@ function TabActions({
{clickAction === "modal-open" && (
-
- POP 화면 ID - onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} - placeholder="화면 ID (예: 4481)" - className="h-7 flex-1 text-[10px]" - /> -
+ {/* 모달 캔버스 (디자이너 모드) */} + {designerCtx && ( +
+ {modalConfig.screenId?.startsWith("modal-") ? ( + + ) : ( + + )} +
+ )} + {/* 뷰어 모드 또는 직접 입력 폴백 */} + {!designerCtx && ( +
+ 모달 ID + onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} + placeholder="모달 ID" + className="h-7 flex-1 text-[10px]" + /> +
+ )}
모달 제목 ; + +interface WorkResultRow { + id: string; + work_order_process_id: string; + source_work_item_id: string; + source_detail_id: string; + work_phase: string; + item_title: string; + item_sort_order: string; + detail_type: string; + detail_label: string; + detail_sort_order: string; + spec_value: string | null; + lower_limit: string | null; + upper_limit: string | null; + input_type: string | null; + result_value: string | null; + status: string; + is_passed: string | null; + recorded_by: string | null; + recorded_at: string | null; +} + +interface WorkGroup { + phase: string; + title: string; + itemId: string; + sortOrder: number; + total: number; + completed: number; +} + +type WorkPhase = "PRE" | "IN" | "POST"; +const PHASE_ORDER: Record = { PRE: 1, IN: 2, POST: 3 }; + +interface ProcessTimerData { + started_at: string | null; + paused_at: string | null; + total_paused_time: string | null; + status: string; + good_qty: string | null; + defect_qty: string | null; +} + +// ======================================== +// Props +// ======================================== + +interface PopWorkDetailComponentProps { + config?: PopWorkDetailConfig; + screenId?: string; + componentId?: string; + currentRowSpan?: number; + currentColSpan?: number; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +export function PopWorkDetailComponent({ + config, + screenId, + componentId, +}: PopWorkDetailComponentProps) { + const { getSharedData } = usePopEvent(screenId || "default"); + const { user } = useAuth(); + + const cfg: PopWorkDetailConfig = { + showTimer: config?.showTimer ?? true, + showQuantityInput: config?.showQuantityInput ?? true, + phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + }; + + // parentRow에서 현재 공정 정보 추출 + const parentRow = getSharedData("parentRow"); + const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; + const currentProcess = processFlow?.find((p) => p.isCurrent); + const workOrderProcessId = currentProcess?.processId + ? String(currentProcess.processId) + : undefined; + const processName = currentProcess?.processName ?? "공정 상세"; + + // ======================================== + // 상태 + // ======================================== + + const [allResults, setAllResults] = useState([]); + const [processData, setProcessData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [tick, setTick] = useState(Date.now()); + const [savingIds, setSavingIds] = useState>(new Set()); + + // 수량 입력 로컬 상태 + const [goodQty, setGoodQty] = useState(""); + const [defectQty, setDefectQty] = useState(""); + + // ======================================== + // D-FE1: 데이터 로드 + // ======================================== + + const fetchData = useCallback(async () => { + if (!workOrderProcessId) { + setLoading(false); + return; + } + + try { + setLoading(true); + + const [resultRes, processRes] = await Promise.all([ + dataApi.getTableData("process_work_result", { + size: 500, + filters: { work_order_process_id: workOrderProcessId }, + }), + dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: workOrderProcessId }, + }), + ]); + + setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); + + const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; + setProcessData(proc); + if (proc) { + setGoodQty(proc.good_qty ?? ""); + setDefectQty(proc.defect_qty ?? ""); + } + } catch { + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [workOrderProcessId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ======================================== + // D-FE2: 좌측 사이드바 - 작업항목 그룹핑 + // ======================================== + + const groups = useMemo(() => { + const map = new Map(); + for (const row of allResults) { + const key = row.source_work_item_id; + if (!map.has(key)) { + map.set(key, { + phase: row.work_phase, + title: row.item_title, + itemId: key, + sortOrder: parseInt(row.item_sort_order || "0", 10), + total: 0, + completed: 0, + }); + } + const g = map.get(key)!; + g.total++; + if (row.status === "completed") g.completed++; + } + return Array.from(map.values()).sort( + (a, b) => + (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || + a.sortOrder - b.sortOrder + ); + }, [allResults]); + + // phase별로 그룹핑 + const groupsByPhase = useMemo(() => { + const result: Record = {}; + for (const g of groups) { + if (!result[g.phase]) result[g.phase] = []; + result[g.phase].push(g); + } + return result; + }, [groups]); + + // 첫 그룹 자동 선택 + useEffect(() => { + if (groups.length > 0 && !selectedGroupId) { + setSelectedGroupId(groups[0].itemId); + } + }, [groups, selectedGroupId]); + + // ======================================== + // D-FE3: 우측 체크리스트 + // ======================================== + + const currentItems = useMemo( + () => + allResults + .filter((r) => r.source_work_item_id === selectedGroupId) + .sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)), + [allResults, selectedGroupId] + ); + + const saveResultValue = useCallback( + async ( + rowId: string, + resultValue: string, + isPassed: string | null, + newStatus: string + ) => { + setSavingIds((prev) => new Set(prev).add(rowId)); + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] }, + { type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] }, + ...(isPassed !== null + ? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }] + : []), + { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] }, + { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] }, + ], + data: { items: [{ id: rowId }], fieldValues: {} }, + }); + + setAllResults((prev) => + prev.map((r) => + r.id === rowId + ? { + ...r, + result_value: resultValue, + status: newStatus, + is_passed: isPassed, + recorded_by: user?.userId ?? null, + recorded_at: new Date().toISOString(), + } + : r + ) + ); + } catch { + toast.error("저장에 실패했습니다."); + } finally { + setSavingIds((prev) => { + const next = new Set(prev); + next.delete(rowId); + return next; + }); + } + }, + [user?.userId] + ); + + // ======================================== + // D-FE4: 타이머 + // ======================================== + + useEffect(() => { + if (!cfg.showTimer || !processData?.started_at) return; + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, [cfg.showTimer, processData?.started_at]); + + const elapsedMs = useMemo(() => { + if (!processData?.started_at) return 0; + const now = tick; + const totalMs = now - new Date(processData.started_at).getTime(); + const pausedSec = parseInt(processData.total_paused_time || "0", 10); + const currentPauseMs = processData.paused_at + ? now - new Date(processData.paused_at).getTime() + : 0; + return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs); + }, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]); + + const formattedTime = useMemo(() => { + const totalSec = Math.floor(elapsedMs / 1000); + const h = String(Math.floor(totalSec / 3600)).padStart(2, "0"); + const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0"); + const s = String(totalSec % 60).padStart(2, "0"); + return `${h}:${m}:${s}`; + }, [elapsedMs]); + + const isPaused = !!processData?.paused_at; + const isStarted = !!processData?.started_at; + + const handleTimerAction = useCallback( + async (action: "start" | "pause" | "resume") => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/api/pop/production/timer", { + workOrderProcessId, + action, + }); + // 타이머 상태 새로고침 + const res = await dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: workOrderProcessId }, + }); + const proc = (res.data?.[0] ?? null) as ProcessTimerData | null; + if (proc) setProcessData(proc); + } catch { + toast.error("타이머 제어에 실패했습니다."); + } + }, + [workOrderProcessId] + ); + + // ======================================== + // D-FE5: 수량 등록 + 완료 + // ======================================== + + const handleQuantityRegister = useCallback(async () => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] }, + { type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] }, + ], + data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, + }); + toast.success("수량이 등록되었습니다."); + } catch { + toast.error("수량 등록에 실패했습니다."); + } + }, [workOrderProcessId, goodQty, defectQty]); + + const handleProcessComplete = useCallback(async () => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] }, + { type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] }, + ], + data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, + }); + toast.success("공정이 완료되었습니다."); + setProcessData((prev) => + prev ? { ...prev, status: "completed" } : prev + ); + } catch { + toast.error("공정 완료 처리에 실패했습니다."); + } + }, [workOrderProcessId]); + + // ======================================== + // 안전 장치 + // ======================================== + + if (!parentRow) { + return ( +
+ + 카드를 선택해주세요 +
+ ); + } + + if (!workOrderProcessId) { + return ( +
+ + 공정 정보를 찾을 수 없습니다 +
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (allResults.length === 0) { + return ( +
+ + 작업기준이 등록되지 않았습니다 +
+ ); + } + + const isProcessCompleted = processData?.status === "completed"; + + // ======================================== + // 렌더링 + // ======================================== + + return ( +
+ {/* 헤더 */} +
+

{processName}

+ {cfg.showTimer && ( +
+ + + {formattedTime} + + {!isProcessCompleted && ( + <> + {!isStarted && ( + + )} + {isStarted && !isPaused && ( + + )} + {isStarted && isPaused && ( + + )} + + )} +
+ )} +
+ + {/* 본문: 좌측 사이드바 + 우측 체크리스트 */} +
+ {/* 좌측 사이드바 */} +
+ {(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => { + const phaseGroups = groupsByPhase[phase]; + if (!phaseGroups || phaseGroups.length === 0) return null; + return ( +
+
+ {cfg.phaseLabels[phase] ?? phase} +
+ {phaseGroups.map((g) => ( + + ))} +
+ ); + })} +
+ + {/* 우측 체크리스트 */} +
+ {selectedGroupId && ( +
+ {currentItems.map((item) => ( + + ))} +
+ )} +
+
+ + {/* 하단: 수량 입력 + 완료 */} + {cfg.showQuantityInput && ( +
+ +
+ 양품 + setGoodQty(e.target.value)} + disabled={isProcessCompleted} + /> +
+
+ 불량 + setDefectQty(e.target.value)} + disabled={isProcessCompleted} + /> +
+ +
+ {!isProcessCompleted && ( + + )} + {isProcessCompleted && ( + + 완료됨 + + )} +
+ )} +
+ ); +} + +// ======================================== +// 체크리스트 개별 항목 +// ======================================== + +interface ChecklistItemProps { + item: WorkResultRow; + saving: boolean; + disabled: boolean; + onSave: ( + rowId: string, + resultValue: string, + isPassed: string | null, + newStatus: string + ) => void; +} + +function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) { + const isSaving = saving; + const isDisabled = disabled || isSaving; + + switch (item.detail_type) { + case "check": + return ; + case "inspect": + return ; + case "input": + return ; + case "procedure": + return ; + case "material": + return ; + default: + return ( +
+ 알 수 없는 유형: {item.detail_type} +
+ ); + } +} + +// ===== check: 체크박스 ===== + +function CheckItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const checked = item.result_value === "Y"; + return ( +
+ { + const val = v ? "Y" : "N"; + onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending"); + }} + /> + {item.detail_label} + {saving && } + {item.status === "completed" && !saving && ( + + 완료 + + )} +
+ ); +} + +// ===== inspect: 측정값 입력 (범위 판정) ===== + +function InspectItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const lower = parseFloat(item.lower_limit ?? ""); + const upper = parseFloat(item.upper_limit ?? ""); + const hasRange = !isNaN(lower) && !isNaN(upper); + + const handleBlur = () => { + if (!inputVal || disabled) return; + const numVal = parseFloat(inputVal); + let passed: string | null = null; + if (hasRange) { + passed = numVal >= lower && numVal <= upper ? "Y" : "N"; + } + onSave(item.id, inputVal, passed, "completed"); + }; + + const isPassed = item.is_passed; + + return ( +
+
+ {item.detail_label} + {hasRange && ( + + 기준: {item.lower_limit} ~ {item.upper_limit} + {item.spec_value ? ` (표준: ${item.spec_value})` : ""} + + )} +
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="측정값 입력" + /> + {saving && } + {isPassed === "Y" && !saving && ( + 합격 + )} + {isPassed === "N" && !saving && ( + 불합격 + )} +
+
+ ); +} + +// ===== input: 자유 입력 ===== + +function InputItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const inputType = item.input_type === "number" ? "number" : "text"; + + const handleBlur = () => { + if (!inputVal || disabled) return; + onSave(item.id, inputVal, null, "completed"); + }; + + return ( +
+
{item.detail_label}
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="값 입력" + /> + {saving && } +
+
+ ); +} + +// ===== procedure: 절차 확인 (읽기 전용 + 체크) ===== + +function ProcedureItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const checked = item.result_value === "Y"; + return ( +
+
+ {item.spec_value || item.detail_label} +
+
+ { + onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); + }} + /> + 확인 + {saving && } +
+
+ ); +} + +// ===== material: 자재/LOT 입력 ===== + +function MaterialItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + + const handleBlur = () => { + if (!inputVal || disabled) return; + onSave(item.id, inputVal, null, "completed"); + }; + + return ( +
+
{item.detail_label}
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="LOT 번호 입력" + /> + {saving && } +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx new file mode 100644 index 00000000..7b75cf78 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import type { PopWorkDetailConfig } from "../types"; + +interface PopWorkDetailConfigPanelProps { + config?: PopWorkDetailConfig; + onChange?: (config: PopWorkDetailConfig) => void; +} + +const DEFAULT_PHASE_LABELS: Record = { + PRE: "작업 전", + IN: "작업 중", + POST: "작업 후", +}; + +export function PopWorkDetailConfigPanel({ + config, + onChange, +}: PopWorkDetailConfigPanelProps) { + const cfg: PopWorkDetailConfig = { + showTimer: config?.showTimer ?? true, + showQuantityInput: config?.showQuantityInput ?? true, + phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS }, + }; + + const update = (partial: Partial) => { + onChange?.({ ...cfg, ...partial }); + }; + + return ( +
+
+ + update({ showTimer: v })} + /> +
+ +
+ + update({ showQuantityInput: v })} + /> +
+ +
+ + {(["PRE", "IN", "POST"] as const).map((phase) => ( +
+ + {phase} + + + update({ + phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value }, + }) + } + /> +
+ ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx new file mode 100644 index 00000000..d5eed206 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { ClipboardCheck } from "lucide-react"; +import type { PopWorkDetailConfig } from "../types"; + +interface PopWorkDetailPreviewProps { + config?: PopWorkDetailConfig; +} + +export function PopWorkDetailPreviewComponent({ config }: PopWorkDetailPreviewProps) { + const labels = config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }; + return ( +
+ + + 작업 상세 + +
+ {Object.values(labels).map((l) => ( + + {l} + + ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx new file mode 100644 index 00000000..941db8d4 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopWorkDetailComponent } from "./PopWorkDetailComponent"; +import { PopWorkDetailConfigPanel } from "./PopWorkDetailConfig"; +import { PopWorkDetailPreviewComponent } from "./PopWorkDetailPreview"; +import type { PopWorkDetailConfig } from "../types"; + +const defaultConfig: PopWorkDetailConfig = { + showTimer: true, + showQuantityInput: true, + phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, +}; + +PopComponentRegistry.registerComponent({ + id: "pop-work-detail", + name: "작업 상세", + description: "공정별 체크리스트/검사/실적 상세 작업 화면", + category: "display", + icon: "ClipboardCheck", + component: PopWorkDetailComponent, + configPanel: PopWorkDetailConfigPanel, + preview: PopWorkDetailPreviewComponent, + defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { + key: "process_completed", + label: "공정 완료", + type: "event", + category: "event", + description: "공정 작업 전체 완료 이벤트", + }, + ], + receivable: [], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 3680578e..a32a53cd 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1000,3 +1000,14 @@ export const VIRTUAL_SUB_STATUS = "__subStatus__" as const; export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const; export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const; export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const; + + +// ============================================= +// pop-work-detail 전용 타입 +// ============================================= + +export interface PopWorkDetailConfig { + showTimer: boolean; + showQuantityInput: boolean; + phaseLabels: Record; +} From 3225a7bb21db8b3f7a81961baf46f75fa87c5131 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Mon, 16 Mar 2026 10:38:12 +0900 Subject: [PATCH 09/10] feat: add bulk update script for COMPANY_7 button styles - Introduced a new script `btn-bulk-update-company7.ts` to facilitate bulk updates of button styles for COMPANY_7. - The script includes functionalities for testing, running updates, creating backups, and restoring from backups. - Implemented logic to dynamically apply button styles based on action types, ensuring consistent UI across the application. - Updated documentation to reflect changes in button icon mapping and dynamic loading of icons. This addition enhances the maintainability and consistency of button styles for COMPANY_7, streamlining the update process. --- .../scripts/btn-bulk-update-company7.ts | 318 ++++++++++++++++++ docs/ycshin-node/BIC[계획]-버튼-아이콘화.md | 51 ++- docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md | 44 ++- docs/ycshin-node/BIC[체크]-버튼-아이콘화.md | 25 +- .../BTN-일괄변경-탑씰-버튼스타일.md | 171 ++++++++++ .../MPN[체크]-품번-수동접두어채번.md | 19 +- .../config-panels/ButtonConfigPanel.tsx | 13 +- frontend/lib/button-icon-map.tsx | 17 +- .../ButtonPrimaryComponent.tsx | 19 +- .../components/v2-button-primary/config.ts | 25 +- .../components/v2-button-primary/index.ts | 18 +- 11 files changed, 678 insertions(+), 42 deletions(-) create mode 100644 backend-node/scripts/btn-bulk-update-company7.ts create mode 100644 docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md diff --git a/backend-node/scripts/btn-bulk-update-company7.ts b/backend-node/scripts/btn-bulk-update-company7.ts new file mode 100644 index 00000000..ee757a0c --- /dev/null +++ b/backend-node/scripts/btn-bulk-update-company7.ts @@ -0,0 +1,318 @@ +/** + * 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트 + * + * 사용법: + * npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK) + * npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT) + * npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성 + * npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복 + */ + +import { Pool } from "pg"; + +// ── 배포 DB 연결 ── +const pool = new Pool({ + connectionString: + "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor", +}); + +const COMPANY_CODE = "COMPANY_7"; +const BACKUP_TABLE = "screen_layouts_v2_backup_20260313"; + +// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ── +const actionIconMap: Record = { + save: "Check", + delete: "Trash2", + edit: "Pencil", + navigate: "ArrowRight", + modal: "Maximize2", + transferData: "SendHorizontal", + excel_download: "Download", + excel_upload: "Upload", + quickInsert: "Zap", + control: "Settings", + barcode_scan: "ScanLine", + operation_control: "Truck", + event: "Send", + copy: "Copy", +}; +const FALLBACK_ICON = "SquareMousePointer"; + +function getIconForAction(actionType?: string): string { + if (actionType && actionIconMap[actionType]) { + return actionIconMap[actionType]; + } + return FALLBACK_ICON; +} + +// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ── +function isTopLevelButton(comp: any): boolean { + return ( + comp.url?.includes("v2-button-primary") || + comp.overrides?.type === "v2-button-primary" + ); +} + +function isTabChildButton(comp: any): boolean { + return comp.componentType === "v2-button-primary"; +} + +function isButtonComponent(comp: any): boolean { + return isTopLevelButton(comp) || isTabChildButton(comp); +} + +// ── 탭 위젯인지 판별 ── +function isTabsWidget(comp: any): boolean { + return ( + comp.url?.includes("v2-tabs-widget") || + comp.overrides?.type === "v2-tabs-widget" + ); +} + +// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ── +function applyButtonStyle(config: any, actionType: string | undefined) { + const iconName = getIconForAction(actionType); + + config.displayMode = "icon-text"; + + config.icon = { + name: iconName, + type: "lucide", + size: "보통", + ...(config.icon?.color ? { color: config.icon.color } : {}), + }; + + config.iconTextPosition = "right"; + config.iconGap = 6; + + if (!config.style) config.style = {}; + delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용) + config.style.borderRadius = "8px"; + config.style.labelColor = "#FFFFFF"; + config.style.fontSize = "12px"; + config.style.fontWeight = "normal"; + config.style.labelTextAlign = "left"; + + if (actionType === "delete") { + config.style.backgroundColor = "#F04544"; + } else if (actionType === "excel_upload" || actionType === "excel_download") { + config.style.backgroundColor = "#212121"; + } else { + config.style.backgroundColor = "#3B83F6"; + } +} + +function updateButtonStyle(comp: any): boolean { + if (isTopLevelButton(comp)) { + const overrides = comp.overrides || {}; + const actionType = overrides.action?.type; + + if (!comp.size) comp.size = {}; + comp.size.height = 40; + + applyButtonStyle(overrides, actionType); + comp.overrides = overrides; + return true; + } + + if (isTabChildButton(comp)) { + const config = comp.componentConfig || {}; + const actionType = config.action?.type; + + if (!comp.size) comp.size = {}; + comp.size.height = 40; + + applyButtonStyle(config, actionType); + comp.componentConfig = config; + + // 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음 + if (!comp.style) comp.style = {}; + comp.style.borderRadius = "8px"; + comp.style.labelColor = "#FFFFFF"; + comp.style.fontSize = "12px"; + comp.style.fontWeight = "normal"; + comp.style.labelTextAlign = "left"; + comp.style.backgroundColor = config.style.backgroundColor; + + return true; + } + + return false; +} + +// ── 백업 테이블 생성 ── +async function createBackup() { + console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`); + + const exists = await pool.query( + `SELECT to_regclass($1) AS tbl`, + [BACKUP_TABLE], + ); + if (exists.rows[0].tbl) { + console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`); + const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`); + console.log(`기존 백업 레코드 수: ${count.rows[0].count}`); + return; + } + + await pool.query( + `CREATE TABLE ${BACKUP_TABLE} AS + SELECT * FROM screen_layouts_v2 + WHERE company_code = $1`, + [COMPANY_CODE], + ); + + const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`); + console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`); +} + +// ── 백업에서 원복 ── +async function restoreFromBackup() { + console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`); + + const result = await pool.query( + `UPDATE screen_layouts_v2 AS target + SET layout_data = backup.layout_data, + updated_at = backup.updated_at + FROM ${BACKUP_TABLE} AS backup + WHERE target.screen_id = backup.screen_id + AND target.company_code = backup.company_code + AND target.layer_id = backup.layer_id`, + ); + console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`); +} + +// ── 메인: 버튼 일괄 변경 ── +async function updateButtons(testMode: boolean) { + const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)"; + console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`); + + // company_7 레코드 조회 + const rows = await pool.query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE company_code = $1 + ORDER BY screen_id, layer_id`, + [COMPANY_CODE], + ); + console.log(`대상 레코드 수: ${rows.rowCount}`); + + if (!rows.rowCount) { + console.log("변경할 레코드가 없습니다."); + return; + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + let totalUpdated = 0; + let totalButtons = 0; + const targetRows = testMode ? [rows.rows[0]] : rows.rows; + + for (const row of targetRows) { + const layoutData = row.layout_data; + if (!layoutData?.components || !Array.isArray(layoutData.components)) { + continue; + } + + let buttonsInRow = 0; + for (const comp of layoutData.components) { + // 최상위 버튼 처리 + if (updateButtonStyle(comp)) { + buttonsInRow++; + } + + // 탭 위젯 내부 버튼 처리 + if (isTabsWidget(comp)) { + const tabs = comp.overrides?.tabs || []; + for (const tab of tabs) { + const tabComps = tab.components || []; + for (const tabComp of tabComps) { + if (updateButtonStyle(tabComp)) { + buttonsInRow++; + } + } + } + } + } + + if (buttonsInRow > 0) { + await client.query( + `UPDATE screen_layouts_v2 + SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`, + [JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id], + ); + totalUpdated++; + totalButtons += buttonsInRow; + + console.log( + ` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`, + ); + + // 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력 + if (testMode) { + const sampleBtn = layoutData.components.find(isButtonComponent); + if (sampleBtn) { + console.log("\n--- 변경 후 샘플 버튼 ---"); + console.log(JSON.stringify(sampleBtn, null, 2)); + } + } + } + } + + console.log(`\n--- 결과 ---`); + console.log(`변경된 레코드: ${totalUpdated}개`); + console.log(`변경된 버튼: ${totalButtons}개`); + + if (testMode) { + await client.query("ROLLBACK"); + console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음."); + } else { + await client.query("COMMIT"); + console.log("\nCOMMIT 완료."); + } + } catch (err) { + await client.query("ROLLBACK"); + console.error("\n에러 발생. ROLLBACK 완료.", err); + throw err; + } finally { + client.release(); + } +} + +// ── CLI 진입점 ── +async function main() { + const arg = process.argv[2]; + + if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) { + console.log("사용법:"); + console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)"); + console.log(" --run : 전체 실행 (COMMIT)"); + console.log(" --backup : 백업 테이블 생성"); + console.log(" --restore : 백업에서 원복"); + process.exit(1); + } + + try { + if (arg === "--backup") { + await createBackup(); + } else if (arg === "--restore") { + await restoreFromBackup(); + } else if (arg === "--test") { + await createBackup(); + await updateButtons(true); + } else if (arg === "--run") { + await createBackup(); + await updateButtons(false); + } + } catch (err) { + console.error("스크립트 실행 실패:", err); + process.exit(1); + } finally { + await pool.end(); + } +} + +main(); diff --git a/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md b/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md index 816eaa1e..be3a3776 100644 --- a/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md +++ b/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md @@ -323,7 +323,7 @@ interface ButtonComponentConfig { | 파일 | 내용 | |------|------| -| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 | +| `frontend/lib/button-icon-map.tsx` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 | --- @@ -338,3 +338,52 @@ interface ButtonComponentConfig { - 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능 - lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장 - lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화 +- **동적 아이콘 로딩**: `iconMap`에 명시적으로 import되지 않은 lucide 아이콘도 `getLucideIcon()` 호출 시 `lucide-react`의 전체 아이콘(`icons`)에서 자동 조회 후 캐싱 → 화면 관리에서 선택한 모든 lucide 아이콘이 실제 화면에서도 렌더링됨 +- **커스텀 아이콘 전역 관리 (미구현)**: 커스텀 아이콘을 버튼별(`componentConfig`)이 아닌 시스템 전역(`custom_icon_registry` 테이블)으로 관리하여, 한번 추가한 커스텀 아이콘이 모든 화면의 모든 버튼에서 사용 가능하도록 확장 예정 + +--- + +## [미구현] 커스텀 아이콘 전역 관리 + +### 현재 문제 + +- 커스텀 아이콘이 `componentConfig.customIcons`에 저장 → **해당 버튼에서만** 보임 +- 저장1 버튼에 추가한 커스텀 아이콘이 저장2 버튼, 다른 화면에서는 안 보임 +- 같은 아이콘을 쓰려면 매번 검색해서 다시 추가해야 함 + +### 변경 후 동작 + +- 커스텀 아이콘을 **회사(company_code) 단위 전역**으로 관리 +- 어떤 화면의 어떤 버튼에서든 커스텀 아이콘 추가 → 모든 화면의 모든 버튼에서 커스텀란에 표시 +- 버튼 액션 종류와 무관하게 모든 커스텀 아이콘이 노출 + +### DB 테이블 (신규) + +```sql +CREATE TABLE custom_icon_registry ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + icon_name VARCHAR(500) NOT NULL, + icon_type VARCHAR(500) DEFAULT 'lucide', -- 'lucide' | 'svg' + svg_data TEXT, -- SVG일 경우 원본 데이터 + created_date TIMESTAMP DEFAULT now(), + updated_date TIMESTAMP DEFAULT now(), + writer VARCHAR(500) +); + +CREATE INDEX idx_custom_icon_registry_company ON custom_icon_registry(company_code); +``` + +### 백엔드 API (신규) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/api/custom-icons` | 커스텀 아이콘 목록 조회 (company_code 필터) | +| POST | `/api/custom-icons` | 커스텀 아이콘 추가 | +| DELETE | `/api/custom-icons/:id` | 커스텀 아이콘 삭제 | + +### 프론트엔드 변경 + +- `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 API 호출로 변경 +- 기존 `componentConfig.customIcons` 데이터는 하위 호환으로 병합 표시 (점진적 마이그레이션) +- `componentConfig.customSvgIcons`도 동일하게 전역 테이블로 이관 diff --git a/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md b/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md index f4b2b16d..ba19e386 100644 --- a/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md +++ b/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md @@ -145,8 +145,24 @@ - **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능 - **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수 -- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링 -- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가 +- **구현**: `lucide-react`의 `icons` 객체에서 `Object.keys()`로 전체 이름 목록을 가져오고, CommandInput으로 필터링 +- **주의**: `allLucideIcons`는 `button-icon-map.tsx`에서 re-export하여 import를 중앙화 + +### 18. 커스텀 아이콘 전역 관리 (미구현) + +- **결정**: 커스텀 아이콘을 버튼별(`componentConfig`) → 시스템 전역(`custom_icon_registry` 테이블)으로 변경 +- **근거**: 현재는 버튼 A에서 추가한 커스텀 아이콘이 버튼 B, 다른 화면에서 안 보여 매번 재등록 필요. 아이콘은 시각적 자원이므로 액션이나 화면에 종속될 이유가 없음 +- **범위 검토**: 버튼별 < 화면 단위 < **시스템 전역(채택)** — 같은 아이콘을 여러 화면에서 재사용하는 ERP 특성에 시스템 전역이 가장 적합 +- **저장**: `custom_icon_registry` 테이블 (company_code 멀티테넌시), lucide 이름 또는 SVG 데이터 저장 +- **하위 호환**: 기존 `componentConfig.customIcons` 데이터는 병합 표시 후 점진적 마이그레이션 + +### 19. 동적 아이콘 로딩 (getLucideIcon fallback) + +- **결정**: `getLucideIcon(name)`이 `iconMap`에 없는 아이콘을 `lucide-react`의 `icons` 전체 객체에서 동적으로 조회 후 캐싱 +- **근거**: 화면 관리에서 커스텀 lucide 아이콘을 선택하면 `componentConfig.customIcons`에 이름만 저장됨. 디자이너 세션에서는 `addToIconMap()`으로 런타임에 등록되지만, 실제 화면(뷰어) 로드 시에는 `iconMap`에 해당 아이콘이 없어 렌더링 실패. `icons` fallback을 추가하면 **어떤 lucide 아이콘이든 이름만으로 자동 렌더링** +- **구현**: `button-icon-map.tsx`에 `import { icons as allLucideIcons } from "lucide-react"` 추가, `getLucideIcon()`에서 `iconMap` miss 시 `allLucideIcons[name]` 조회 후 `iconMap`에 캐싱 +- **번들 영향**: `icons` 전체 객체 import로 번들 크기 증가 (~100-200KB). ERP 애플리케이션 특성상 수용 가능한 수준이며, 관리자가 선택한 모든 아이콘이 실제 화면에서 동작하는 것이 더 중요 +- **대안 검토**: 뷰어 로드 시 `customIcons`를 순회하여 개별 등록 → 기각 (모든 뷰어 컴포넌트에 로직 추가 필요, 누락 위험) --- @@ -159,7 +175,7 @@ | 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) | | 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) | | 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) | -| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 | +| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.tsx` | 액션별 추천 아이콘 + 동적 렌더링 유틸 + allLucideIcons fallback | | 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 | --- @@ -169,17 +185,21 @@ ### lucide-react 아이콘 동적 렌더링 ```typescript -// button-icon-map.ts -import { Check, Save, Trash2, Pencil, ... } from "lucide-react"; +// button-icon-map.tsx +import { Check, Save, ..., icons as allLucideIcons, type LucideIcon } from "lucide-react"; -const iconMap: Record> = { - Check, Save, Trash2, Pencil, ... -}; +// 추천 아이콘은 명시적 import, 나머지는 동적 조회 +const iconMap: Record = { Check, Save, ... }; -export function renderButtonIcon(name: string, size: string | number) { - const IconComponent = iconMap[name]; - if (!IconComponent) return null; - return ; +export function getLucideIcon(name: string): LucideIcon | undefined { + if (iconMap[name]) return iconMap[name]; + // iconMap에 없으면 lucide-react 전체에서 동적 조회 후 캐싱 + const found = allLucideIcons[name as keyof typeof allLucideIcons]; + if (found) { + iconMap[name] = found; + return found; + } + return undefined; } ``` diff --git a/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md b/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md index a02a15b1..1b20cab9 100644 --- a/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md +++ b/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md @@ -125,12 +125,30 @@ - [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인 - [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인 -### 6단계: 정리 +### 6단계: 동적 아이콘 로딩 (뷰어 렌더링 누락 수정) -- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러) +- [x] `button-icon-map.tsx`에 `icons as allLucideIcons` import 추가 +- [x] `getLucideIcon()` — `iconMap` miss 시 `allLucideIcons` fallback 조회 + 캐싱 +- [x] `allLucideIcons`를 `button-icon-map.tsx`에서 re-export (import 중앙화) +- [x] `ButtonConfigPanel.tsx` — `lucide-react` 직접 import 제거, `button-icon-map`에서 import로 통합 +- [x] 화면 관리에서 선택한 커스텀 lucide 아이콘이 실제 화면(뷰어)에서도 렌더링됨 확인 + +### 7단계: 정리 + +- [x] TypeScript 컴파일 에러 없음 확인 - [x] 불필요한 import 없음 확인 +- [x] 문서 3개 최신화 (동적 로딩 반영) - [x] 이 체크리스트 완료 표시 업데이트 +### 8단계: 커스텀 아이콘 전역 관리 (미구현) + +- [ ] `custom_icon_registry` 테이블 마이그레이션 SQL 작성 및 실행 (개발섭 + 본섭) +- [ ] 백엔드 API 구현 (GET/POST/DELETE `/api/custom-icons`) +- [ ] 프론트엔드 API 클라이언트 함수 추가 (`lib/api/`) +- [ ] `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 전역 API로 변경 +- [ ] 기존 `componentConfig.customIcons` 하위 호환 병합 처리 +- [ ] 검증: 화면 A에서 추가한 커스텀 아이콘이 화면 B에서도 보이는지 확인 + --- ## 변경 이력 @@ -156,3 +174,6 @@ | 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) | | 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 | | 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 | +| 2026-03-13 | 동적 아이콘 로딩 — `getLucideIcon()` fallback으로 `allLucideIcons` 조회+캐싱, import 중앙화 | +| 2026-03-13 | 문서 3개 최신화 (계획서 설계 원칙, 맥락노트 결정사항 #18, 체크리스트 6-7단계) | +| 2026-03-13 | 커스텀 아이콘 전역 관리 계획 추가 (8단계, 미구현) — DB 테이블 + API + 프론트 변경 예정 | diff --git a/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md b/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md new file mode 100644 index 00000000..83976b73 --- /dev/null +++ b/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md @@ -0,0 +1,171 @@ +# BTN - 버튼 UI 스타일 기준정보 + +## 1. 스타일 기준 + +### 공통 스타일 + +| 항목 | 값 | +|---|---| +| 높이 | 40px | +| 표시모드 | 아이콘 + 텍스트 (icon-text) | +| 아이콘 | 액션별 첫 번째 기본 아이콘 (자동 선택) | +| 아이콘 크기 비율 | 보통 | +| 아이콘-텍스트 간격 | 6px | +| 텍스트 위치 | 오른쪽 (아이콘 왼쪽, 텍스트 오른쪽) | +| 테두리 모서리 | 8px | +| 테두리 색상/두께 | 없음 (투명, borderWidth: 0) | +| 텍스트 색상 | #FFFFFF (흰색) | +| 텍스트 크기 | 12px | +| 텍스트 굵기 | normal (보통) | +| 텍스트 정렬 | 왼쪽 | + +### 배경색 (액션별) + +| 액션 타입 | 배경색 | 비고 | +|---|---|---| +| `delete` | `#F04544` | 빨간색 | +| `excel_download`, `excel_upload`, `multi_table_excel_upload` | `#212121` | 검정색 | +| 그 외 모든 액션 | `#3B83F6` | 파란색 (기본값) | + +배경색은 디자이너에서 액션을 변경하면 자동으로 바뀐다. + +### 너비 (텍스트 글자수별) + +| 글자수 | 너비 | +|---|---| +| 6글자 이하 | 140px | +| 7글자 이상 | 160px | + +### 액션별 기본 아이콘 + +디자이너에서 표시모드를 "아이콘" 또는 "아이콘+텍스트"로 변경하면 액션에 맞는 첫 번째 아이콘이 자동 선택된다. + +소스: `frontend/lib/button-icon-map.tsx` > `actionIconMap` + +| action.type | 기본 아이콘 | +|---|---| +| `save` | Check | +| `delete` | Trash2 | +| `edit` | Pencil | +| `navigate` | ArrowRight | +| `modal` | Maximize2 | +| `transferData` | SendHorizontal | +| `excel_download` | Download | +| `excel_upload` | Upload | +| `quickInsert` | Zap | +| `control` | Settings | +| `barcode_scan` | ScanLine | +| `operation_control` | Truck | +| `event` | Send | +| `copy` | Copy | +| (그 외/없음) | SquareMousePointer | + +--- + +## 2. 코드 반영 현황 + +### 컴포넌트 기본값 (신규 버튼 생성 시 적용) + +| 파일 | 내용 | +|---|---| +| `frontend/lib/registry/components/v2-button-primary/index.ts` | defaultConfig, defaultSize (140x40) | +| `frontend/lib/registry/components/v2-button-primary/config.ts` | ButtonPrimaryDefaultConfig | + +### 액션 변경 시 배경색 자동 변경 + +| 파일 | 내용 | +|---|---| +| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 액션 변경 시 배경색/텍스트색 자동 설정 | + +### 렌더링 배경색 우선순위 + +| 파일 | 내용 | +|---|---| +| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 배경색 결정 우선순위 개선 | + +배경색 결정 순서: +1. `webTypeConfig.backgroundColor` +2. `componentConfig.backgroundColor` +3. `component.style.backgroundColor` +4. `componentConfig.style.backgroundColor` +5. `component.style.labelColor` (레거시 호환) +6. 액션별 기본 배경색 (`#F04544` / `#212121` / `#3B83F6`) + +### 미반영 (추후 작업) + +- split-panel 내부 버튼의 코드 기본값 (split-panel 컴포넌트가 자체 생성하는 버튼) + +--- + +## 3. DB 데이터 매핑 (layout_data JSON) + +버튼은 `layout_data.components[]` 배열 안에 `url`이 `v2-button-primary`인 컴포넌트로 저장된다. + +| 항목 | JSON 위치 | 값 | +|---|---|---| +| 높이 | `size.height` | `40` | +| 너비 | `size.width` | `140` 또는 `160` | +| 표시모드 | `overrides.displayMode` | `"icon-text"` | +| 아이콘 이름 | `overrides.icon.name` | 액션별 영문 이름 | +| 아이콘 타입 | `overrides.icon.type` | `"lucide"` | +| 아이콘 크기 | `overrides.icon.size` | `"보통"` | +| 텍스트 위치 | `overrides.iconTextPosition` | `"right"` | +| 아이콘-텍스트 간격 | `overrides.iconGap` | `6` | +| 테두리 모서리 | `overrides.style.borderRadius` | `"8px"` | +| 텍스트 색상 | `overrides.style.labelColor` | `"#FFFFFF"` | +| 텍스트 크기 | `overrides.style.fontSize` | `"12px"` | +| 텍스트 굵기 | `overrides.style.fontWeight` | `"normal"` | +| 텍스트 정렬 | `overrides.style.labelTextAlign` | `"left"` | +| 배경색 | `overrides.style.backgroundColor` | 액션별 색상 | + +버튼이 위치하는 구조별 경로: +- 일반 버튼: `layout_data.components[]` +- 탭 위젯 내부: `layout_data.components[].overrides.tabs[].components[]` +- split-panel 내부: `layout_data.components[].overrides.rightPanel.components[]` + +--- + +## 4. 탑씰(COMPANY_7) 일괄 변경 작업 기록 + +### 대상 +- **회사**: 탑씰 (company_code = 'COMPANY_7') +- **테이블**: screen_layouts_v2 (배포서버) +- **스크립트**: `backend-node/scripts/btn-bulk-update-company7.ts` +- **백업 테이블**: `screen_layouts_v2_backup_company7` + +### 작업 이력 + +| 날짜 | 작업 내용 | 비고 | +|---|---|---| +| 2026-03-13 | 백업 테이블 생성 | | +| 2026-03-13 | 전체 버튼 공통 스타일 일괄 적용 | 높이, 아이콘, 텍스트 스타일, 배경색, 모서리 | +| 2026-03-13 | 탭 위젯 내부 버튼 스타일 보정 | componentConfig + root style 양쪽 적용 | +| 2026-03-13 | fontWeight "400" → "normal" 보정 | | +| 2026-03-13 | overrides.style.width 제거 | size.width와 충돌 방지 | +| 2026-03-13 | save 액션 55개에 "저장" 텍스트 명시 | | +| 2026-03-13 | "엑셀다운로드" → "Excel" 텍스트 통일 | | +| 2026-03-13 | Excel 버튼 배경색 #212121 통일 | | +| 2026-03-13 | 전체 버튼 너비 140px 통일 | | +| 2026-03-13 | 7글자 이상 버튼 너비 160px 재조정 | | +| 2026-03-13 | split-panel 내부 버튼 스타일 적용 | BOM관리 등 7개 버튼 | + +### 스킵 항목 +- `transferData` 액션의 텍스트 없는 버튼 1개 (screen=5976) + +### 알려진 이슈 +- **반응형 너비 불일치**: 디자이너에서 설정한 `size.width`가 실제 화면(`ResponsiveGridRenderer`)에서 반영되지 않을 수 있음. 버튼 wrapper에 `width` 속성이 누락되어 flex shrink-to-fit 동작으로 너비가 줄어드는 현상. 세로(height)는 정상 반영됨. + +### 원복 (필요 시) + +```sql +UPDATE screen_layouts_v2 AS target +SET layout_data = backup.layout_data +FROM screen_layouts_v2_backup_company7 AS backup +WHERE target.layout_id = backup.layout_id; +``` + +### 백업 테이블 정리 + +```sql +DROP TABLE screen_layouts_v2_backup_company7; +``` diff --git a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md index b74eed58..cbcb5f27 100644 --- a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md +++ b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md @@ -6,8 +6,8 @@ ## 공정 상태 -- 전체 진행률: **95%** (코드 구현 + DB 마이그레이션 + 실시간 미리보기 + 코드 정리 완료, 검증 대기) -- 현재 단계: 검증 대기 +- 전체 진행률: **100%** (전체 완료) +- 현재 단계: 완료 --- @@ -45,13 +45,13 @@ ### 6단계: 검증 -- [ ] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인 -- [ ] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님) -- [ ] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인 -- [ ] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인 -- [ ] 수동 파트 없는 채번 규칙 동작 영향 없음 확인 -- [ ] previewCode (미리보기) 동작 영향 없음 확인 -- [ ] BULK1이 더 이상 생성되지 않음 확인 +- [x] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인 +- [x] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님) +- [x] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인 +- [x] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인 +- [x] 수동 파트 없는 채번 규칙 동작 영향 없음 확인 +- [x] previewCode (미리보기) 동작 영향 없음 확인 +- [x] BULK1이 더 이상 생성되지 않음 확인 ### 7단계: 실시간 순번 미리보기 @@ -97,3 +97,4 @@ | 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 | | 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 | | 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 | +| 2026-03-12 | 6단계 검증 완료. 전체 완료 | diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 295371c0..14e123ed 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -33,7 +33,6 @@ import { QuickInsertConfigSection } from "./QuickInsertConfigSection"; import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval"; import DOMPurify from "isomorphic-dompurify"; import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent"; -import { icons as allLucideIcons } from "lucide-react"; import { actionIconMap, noIconActions, @@ -42,6 +41,7 @@ import { getLucideIcon, addToIconMap, getDefaultIconForAction, + allLucideIcons, } from "@/lib/button-icon-map"; // 🆕 제목 블록 타입 @@ -989,8 +989,15 @@ export const ButtonConfigPanel: React.FC = ({ } setTimeout(() => { - const newColor = value === "delete" ? "#ef4444" : "#212121"; - onUpdateProperty("style.labelColor", newColor); + const excelActions = ["excel_download", "excel_upload", "multi_table_excel_upload"]; + let newBgColor = "#3B83F6"; + if (value === "delete") { + newBgColor = "#F04544"; + } else if (excelActions.includes(value)) { + newBgColor = "#212121"; + } + onUpdateProperty("style.backgroundColor", newBgColor); + onUpdateProperty("style.labelColor", "#FFFFFF"); }, 100); }} > diff --git a/frontend/lib/button-icon-map.tsx b/frontend/lib/button-icon-map.tsx index d8c38b25..03b204b6 100644 --- a/frontend/lib/button-icon-map.tsx +++ b/frontend/lib/button-icon-map.tsx @@ -16,11 +16,12 @@ import { Send, Radio, Megaphone, Podcast, BellRing, Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard, SquareMousePointer, + icons as allLucideIcons, type LucideIcon, } from "lucide-react"; // --------------------------------------------------------------------------- -// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import) +// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘은 명시적 import, 나머지는 동적 조회) // --------------------------------------------------------------------------- export const iconMap: Record = { Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck, @@ -106,15 +107,27 @@ export function getIconSizeStyle(size: string | number): React.CSSProperties { // --------------------------------------------------------------------------- // 아이콘 조회 / 동적 등록 +// iconMap에 없으면 lucide-react 전체 아이콘에서 동적 조회 후 캐싱 // --------------------------------------------------------------------------- export function getLucideIcon(name: string): LucideIcon | undefined { - return iconMap[name]; + if (iconMap[name]) return iconMap[name]; + + const found = allLucideIcons[name as keyof typeof allLucideIcons]; + if (found) { + iconMap[name] = found; + return found; + } + + return undefined; } export function addToIconMap(name: string, component: LucideIcon): void { iconMap[name] = component; } +// ButtonConfigPanel 등에서 전체 아이콘 검색용으로 사용 +export { allLucideIcons }; + // --------------------------------------------------------------------------- // SVG 정화 // --------------------------------------------------------------------------- diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index fa0cfaae..4d89f80b 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -502,15 +502,22 @@ export const ButtonPrimaryComponent: React.FC = ({ if (component.style?.backgroundColor) { return component.style.backgroundColor; } - // 4순위: style.labelColor (레거시) + // 4순위: componentConfig.style.backgroundColor + if (componentConfig.style?.backgroundColor) { + return componentConfig.style.backgroundColor; + } + // 5순위: style.labelColor (레거시 호환) if (component.style?.labelColor) { return component.style.labelColor; } - // 기본값: 삭제 버튼이면 빨강, 아니면 파랑 - if (isDeleteAction()) { - return "#ef4444"; // 빨간색 (Tailwind red-500) - } - return "#3b82f6"; // 파란색 (Tailwind blue-500) + // 6순위: 액션별 기본 배경색 + const excelActions = ["excel_download", "excel_upload", "multi_table_excel_upload"]; + const actionType = typeof componentConfig.action === "string" + ? componentConfig.action + : componentConfig.action?.type || ""; + if (actionType === "delete") return "#F04544"; + if (excelActions.includes(actionType)) return "#212121"; + return "#3B83F6"; }; const getButtonTextColor = () => { diff --git a/frontend/lib/registry/components/v2-button-primary/config.ts b/frontend/lib/registry/components/v2-button-primary/config.ts index 06f73556..66ff9173 100644 --- a/frontend/lib/registry/components/v2-button-primary/config.ts +++ b/frontend/lib/registry/components/v2-button-primary/config.ts @@ -6,16 +6,29 @@ import { ButtonPrimaryConfig } from "./types"; * ButtonPrimary 컴포넌트 기본 설정 */ export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = { - text: "버튼", + text: "저장", actionType: "button", - variant: "primary", - - // 공통 기본값 + variant: "default", + size: "md", disabled: false, required: false, readonly: false, - variant: "default", - size: "md", + displayMode: "icon-text", + icon: { + name: "Check", + type: "lucide", + size: "보통", + }, + iconTextPosition: "right", + iconGap: 6, + style: { + borderRadius: "8px", + labelColor: "#FFFFFF", + fontSize: "12px", + fontWeight: "normal", + labelTextAlign: "left", + backgroundColor: "#3B83F6", + }, }; /** diff --git a/frontend/lib/registry/components/v2-button-primary/index.ts b/frontend/lib/registry/components/v2-button-primary/index.ts index 57e57d34..5bf5e193 100644 --- a/frontend/lib/registry/components/v2-button-primary/index.ts +++ b/frontend/lib/registry/components/v2-button-primary/index.ts @@ -28,8 +28,24 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({ successMessage: "저장되었습니다.", errorMessage: "저장 중 오류가 발생했습니다.", }, + displayMode: "icon-text", + icon: { + name: "Check", + type: "lucide", + size: "보통", + }, + iconTextPosition: "right", + iconGap: 6, + style: { + borderRadius: "8px", + labelColor: "#FFFFFF", + fontSize: "12px", + fontWeight: "normal", + labelTextAlign: "left", + backgroundColor: "#3B83F6", + }, }, - defaultSize: { width: 120, height: 40 }, + defaultSize: { width: 140, height: 40 }, configPanel: undefined, // 상세 설정 패널(ButtonConfigPanel)이 대신 사용됨 icon: "MousePointer", tags: ["버튼", "액션", "클릭"], From 7e02fff717055ee86e7e236bdd9cc9248f26e80e Mon Sep 17 00:00:00 2001 From: syc0123 Date: Mon, 16 Mar 2026 11:28:03 +0900 Subject: [PATCH 10/10] fix: update default button size in V2ButtonPrimary component - Changed the default width of the V2ButtonPrimary component from 140 to 100 pixels to improve UI consistency and responsiveness. - This adjustment aligns the button size with design specifications for better user experience. --- frontend/lib/registry/components/v2-button-primary/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/v2-button-primary/index.ts b/frontend/lib/registry/components/v2-button-primary/index.ts index 2e4c53eb..2aa4844c 100644 --- a/frontend/lib/registry/components/v2-button-primary/index.ts +++ b/frontend/lib/registry/components/v2-button-primary/index.ts @@ -43,7 +43,7 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({ backgroundColor: "#3B83F6", }, }, - defaultSize: { width: 140, height: 40 }, + defaultSize: { width: 100, height: 40 }, configPanel: V2ButtonConfigPanel, icon: "MousePointer", tags: ["버튼", "액션", "클릭"],