From 8b7e31031dfccded90359f6b93b6715d42c96d34 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Thu, 12 Mar 2026 10:12:56 +0900 Subject: [PATCH 01/46] 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/46] 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 8ca1890fc0ef1430684f709edb64007e34d27eb1 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 13 Mar 2026 17:45:12 +0900 Subject: [PATCH 03/46] .. --- docker/dev/docker-compose.backend.mac.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index ed4602dd..4d862d9e 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -12,7 +12,7 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor + - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN=24h - CORS_ORIGIN=http://localhost:9771 From c3a43179e35d6e2bc3cfdc075c0cee7ec8081f19 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:15:44 +0900 Subject: [PATCH 04/46] refactor: update color schemes and improve component styling - Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application. - Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity. - Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience. - Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance. These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface. --- frontend/components/screen/ScreenNode.tsx | 174 +++++++++--------- .../screen/panels/V2PropertiesPanel.tsx | 29 ++- .../v2/config-panels/V2FieldConfigPanel.tsx | 12 +- .../lib/registry/DynamicComponentRenderer.tsx | 155 ++++++++++++---- ...creen-149-field-type-verification-guide.md | 165 +++++++++++++++++ 5 files changed, 404 insertions(+), 131 deletions(-) create mode 100644 test-output/screen-149-field-type-verification-guide.md diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index ff5ade46..70930e21 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -109,14 +109,14 @@ const getScreenTypeIcon = (screenType?: string) => { // 화면 타입별 색상 (헤더) const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { - if (!isMain) return "bg-slate-400"; + if (!isMain) return "bg-muted-foreground"; switch (screenType) { case "grid": - return "bg-violet-500"; + return "bg-primary"; case "dashboard": - return "bg-amber-500"; + return "bg-warning"; case "action": - return "bg-rose-500"; + return "bg-destructive"; default: return "bg-primary"; } @@ -124,25 +124,25 @@ const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { // 화면 역할(screenRole)에 따른 색상 const getScreenRoleColor = (screenRole?: string) => { - if (!screenRole) return "bg-slate-400"; + if (!screenRole) return "bg-muted-foreground"; // 역할명에 포함된 키워드로 색상 결정 const role = screenRole.toLowerCase(); if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) { - return "bg-violet-500"; // 보라색 - 메인 그리드 + return "bg-primary"; // 메인 그리드 } if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) { - return "bg-primary"; // 파란색 - 등록 폼 + return "bg-primary"; // 등록 폼 } if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) { - return "bg-rose-500"; // 빨간색 - 액션/이벤트 + return "bg-destructive"; // 액션/이벤트 } if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) { - return "bg-amber-500"; // 주황색 - 상세/팝업 + return "bg-warning"; // 상세/팝업 } - return "bg-slate-400"; // 기본 회색 + return "bg-muted-foreground"; // 기본 회색 }; // 화면 타입별 라벨 @@ -246,17 +246,17 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { ?.filter(item => item.label && !item.componentKind?.includes('button')) ?.slice(0, 6) ?.map((item, idx) => ( -
+
- {item.label} - {item.componentKind?.split('-')[0] || 'field'} + {item.label} + {item.componentKind?.split('-')[0] || 'field'}
)) || ( -
필드 정보 없음
+
필드 정보 없음
)}
@@ -280,33 +280,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { const getComponentColor = (componentKind: string) => { // 테이블/그리드 관련 if (componentKind === "table-list" || componentKind === "data-grid") { - return "bg-violet-200 border-violet-400"; + return "bg-primary/20 border-primary/40"; } // 검색 필터 if (componentKind === "table-search-widget" || componentKind === "search-filter") { - return "bg-pink-200 border-pink-400"; + return "bg-destructive/20 border-destructive/40"; } // 버튼 관련 if (componentKind?.includes("button")) { - return "bg-blue-300 border-primary"; + return "bg-primary/30 border-primary"; } // 입력 필드 if (componentKind?.includes("input") || componentKind?.includes("text")) { - return "bg-slate-200 border-slate-400"; + return "bg-muted border-border"; } // 셀렉트/드롭다운 if (componentKind?.includes("select") || componentKind?.includes("dropdown")) { - return "bg-amber-200 border-amber-400"; + return "bg-warning/20 border-warning/40"; } // 차트 if (componentKind?.includes("chart")) { - return "bg-emerald-200 border-emerald-400"; + return "bg-success/20 border-success/40"; } // 커스텀 위젯 if (componentKind === "custom") { - return "bg-pink-200 border-pink-400"; + return "bg-destructive/20 border-destructive/40"; } - return "bg-slate-100 border-slate-300"; + return "bg-muted/50 border-border"; }; // ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ========== @@ -322,16 +322,16 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 상단 툴바 */}
-
+
-
+
{/* 테이블 헤더 */} -
+
{[...Array(5)].map((_, i) => ( -
+
))}
{/* 테이블 행들 */} @@ -352,7 +352,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -376,7 +376,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -388,13 +388,13 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: return (
{/* 카드/차트들 */} -
-
-
+
+
+
-
-
-
+
+
+
@@ -402,14 +402,14 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: {[...Array(10)].map((_, i) => (
))}
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -429,7 +429,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
액션 화면
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -438,8 +438,8 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // 기본 (알 수 없는 타입) return ( -
-
+
+
{getScreenTypeIcon(screenType)}
{totalComponents}개 컴포넌트 @@ -575,20 +575,20 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
= ({ data }) => { className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out" title={hasSaveTarget ? "저장 대상 테이블" : undefined} style={{ - background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)', + background: `linear-gradient(to bottom, transparent 0%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`, opacity: hasSaveTarget ? 1 : 0, transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)', transformOrigin: 'top', @@ -616,7 +616,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="target" position={Position.Top} id="top" - className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100" /> {/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */} = ({ data }) => { position={Position.Top} id="top_source" style={{ top: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100" /> {/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */} = ({ data }) => { position={Position.Bottom} id="bottom_target" style={{ bottom: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100" /> - {/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */} + {/* 헤더 (필터 관계: primary, 필터 소스: primary, 메인: primary, 기본: muted-foreground) */}
@@ -679,7 +679,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { {/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */} {/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
= ({ data }) => { {/* 필터 뱃지 */} {filterRefs.length > 0 && ( `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`} > @@ -707,14 +707,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )} {filterRefs.length > 0 && ( - + {filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')} )} {/* 참조 뱃지 */} {lookupRefs.length > 0 && ( `${r.fromTable} → ${r.toColumn}`).join('\n')}`} > {lookupRefs.length}곳 참조 @@ -745,14 +745,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { key={col.name} className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${ isJoinColumn - ? "bg-amber-100 border border-orange-300 shadow-sm" + ? "bg-warning/10 border border-warning/30 shadow-sm" : isFilterColumn || isFilterSourceColumn - ? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색 + ? "bg-primary/10 border border-primary/30 shadow-sm" // 필터 컬럼/필터 소스 : isHighlighted ? "bg-primary/10 border border-primary/40 shadow-sm" : hasActiveColumns - ? "bg-slate-100" - : "bg-slate-50 hover:bg-slate-100" + ? "bg-muted" + : "bg-muted/50 hover:bg-muted" }`} style={{ animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined, @@ -760,18 +760,18 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { }} > {/* PK/FK/조인/필터 아이콘 */} - {isJoinColumn && } - {(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && } + {isJoinColumn && } + {(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && } + {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && } {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && } {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey &&
} {/* 컬럼명 */} {col.name} @@ -781,51 +781,51 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { <> {/* 조인 참조 테이블 표시 (joinColumnRefs에서) */} {joinRefMap.has(colOriginal) && ( - + ← {joinRefMap.get(colOriginal)?.refTableLabel} )} {/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */} {!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && ( - + ← {fieldMappingMap.get(colOriginal)?.sourceDisplayName} )} - 조인 + 조인 )} {isFilterColumn && !isJoinColumn && ( - 필터 + 필터 )} {/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */} {isFilterSourceColumn && !isJoinColumn && !isFilterColumn && ( <> - 필터 + 필터 {isHighlighted && ( - 사용 + 사용 )} )} {isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && ( - 사용 + 사용 )} {/* 타입 */} - {col.type} + {col.type}
); })} {/* 더 많은 컬럼이 있을 경우 표시 */} {remainingCount > 0 && ( -
+
+ {remainingCount}개 더
)}
) : (
- - 컬럼 정보 없음 + + 컬럼 정보 없음
)}
@@ -861,10 +861,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { export const LegacyScreenNode = ScreenNode; export const AggregateNode: React.FC<{ data: any }> = ({ data }) => { return ( -
- - -
+
+ + +
{data.label || "Aggregate"}
diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cf148e6e..ef739b27 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -47,6 +47,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; +import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -207,28 +208,36 @@ export const V2PropertiesPanel: React.FC = ({ onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig }); }; - // 컬럼의 inputType 가져오기 (entity 타입인지 확인용) - const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; - // 현재 화면의 테이블명 가져오기 const currentTableName = tables?.[0]?.tableName; + // DB input_type 가져오기 (columnMetaCache에서 최신값 조회) + const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined; + const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined; + const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; + // 컴포넌트별 추가 props const extraProps: Record = {}; - if (componentId === "v2-select") { + const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + + if (componentId === "v2-input" || componentId === "v2-select") { extraProps.inputType = inputType; - extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + extraProps.tableName = resolvedTableName; + extraProps.columnName = resolvedColumnName; + extraProps.screenTableName = resolvedTableName; + } + if (componentId === "v2-input") { + extraProps.allComponents = allComponents; } if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; - extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - } - if (componentId === "v2-input") { - extraProps.allComponents = allComponents; + extraProps.screenTableName = resolvedTableName; } return ( diff --git a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx index 7dfe8834..2f2b8011 100644 --- a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx @@ -78,7 +78,15 @@ interface CategoryValueOption { } // ─── 하위 호환: 기존 config에서 fieldType 추론 ─── -function resolveFieldType(config: Record, componentType?: string): FieldType { +function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { + // DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용 + if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { + const dbType = metaInputType as FieldType; + if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { + return dbType; + } + } + if (config.fieldType) return config.fieldType as FieldType; // v2-select 계열 @@ -207,7 +215,7 @@ export const V2FieldConfigPanel: React.FC = ({ inputType: metaInputType, componentType, }) => { - const fieldType = resolveFieldType(config, componentType); + const fieldType = resolveFieldType(config, componentType, metaInputType); const isSelectGroup = ["select", "category", "entity"].includes(fieldType); // ─── 채번 관련 상태 (테이블 기반) ─── diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 873b7408..859d136f 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client"; import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; // 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵) -const columnMetaCache: Record> = {}; +export const columnMetaCache: Record> = {}; const columnMetaLoading: Record> = {}; +const columnMetaTimestamp: Record = {}; +const CACHE_TTL_MS = 5000; -async function loadColumnMeta(tableName: string): Promise { - if (columnMetaCache[tableName]) return; +export function invalidateColumnMetaCache(tableName?: string): void { + if (tableName) { + delete columnMetaCache[tableName]; + delete columnMetaLoading[tableName]; + delete columnMetaTimestamp[tableName]; + } else { + for (const key of Object.keys(columnMetaCache)) delete columnMetaCache[key]; + for (const key of Object.keys(columnMetaLoading)) delete columnMetaLoading[key]; + for (const key of Object.keys(columnMetaTimestamp)) delete columnMetaTimestamp[key]; + } +} + +async function loadColumnMeta(tableName: string, forceReload = false): Promise { + const now = Date.now(); + const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); + + if (!forceReload && !isStale && columnMetaCache[tableName]) return; + + if (forceReload || isStale) { + delete columnMetaCache[tableName]; + delete columnMetaLoading[tableName]; + } - // 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지) if (columnMetaLoading[tableName]) { await columnMetaLoading[tableName]; return; @@ -36,6 +57,7 @@ async function loadColumnMeta(tableName: string): Promise { if (name) map[name] = col; } columnMetaCache[tableName] = map; + columnMetaTimestamp[tableName] = Date.now(); } catch (e) { console.error(`[columnMeta] ${tableName} 로드 실패:`, e); columnMetaCache[tableName] = {}; @@ -56,43 +78,59 @@ export function isColumnRequiredByMeta(tableName?: string, columnName?: string): return nullable === "NO" || nullable === "N"; } -// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완) +// table_type_columns 기반 componentConfig 병합 (DB input_type 우선 적용) function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { if (!tableName || !columnName) return componentConfig; const meta = columnMetaCache[tableName]?.[columnName]; if (!meta) return componentConfig; - const inputType = meta.input_type || meta.inputType; - if (!inputType) return componentConfig; - - // 이미 source가 올바르게 설정된 경우 건드리지 않음 - const existingSource = componentConfig?.source; - if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") { - return componentConfig; - } + const rawType = meta.input_type || meta.inputType; + const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType; + if (!dbInputType) return componentConfig; const merged = { ...componentConfig }; + const savedFieldType = merged.fieldType; - // source가 미설정/기본값일 때만 DB 메타데이터로 보완 - if (inputType === "entity") { + // savedFieldType이 있고 DB와 같으면 변경 불필요 + if (savedFieldType && savedFieldType === dbInputType) return merged; + // savedFieldType이 있고 DB와 다르면 — 사용자가 V2FieldConfigPanel에서 설정한 값 존중 + if (savedFieldType) return merged; + + // savedFieldType이 없으면: DB input_type 기준으로 동기화 + // 기존 overrides의 source/inputType이 DB와 불일치하면 덮어씀 + if (dbInputType === "entity") { const refTable = meta.reference_table || meta.referenceTable; const refColumn = meta.reference_column || meta.referenceColumn; const displayCol = meta.display_column || meta.displayColumn; - if (refTable && !merged.entityTable) { + if (refTable) { merged.source = "entity"; merged.entityTable = refTable; merged.entityValueColumn = refColumn || "id"; merged.entityLabelColumn = displayCol || "name"; + merged.fieldType = "entity"; + merged.inputType = "entity"; } - } else if (inputType === "category" && !existingSource) { + } else if (dbInputType === "category") { merged.source = "category"; - } else if (inputType === "select" && !existingSource) { + merged.fieldType = "category"; + merged.inputType = "category"; + } else if (dbInputType === "select") { + if (!merged.source || merged.source === "category" || merged.source === "entity") { + merged.source = "static"; + } const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {}; if (detail.options && !merged.options?.length) { merged.options = detail.options; } + merged.fieldType = "select"; + merged.inputType = "select"; + } else { + // text, number, textarea 등 input 계열 — 카테고리 잔류 속성 제거 + merged.fieldType = dbInputType; + merged.inputType = dbInputType; + delete merged.source; } return merged; @@ -266,15 +304,27 @@ export const DynamicComponentRenderer: React.FC = children, ...props }) => { - // 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드) + // 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신) const screenTableName = props.tableName || (component as any).tableName; - const [, forceUpdate] = React.useState(0); + const [metaVersion, forceUpdate] = React.useState(0); React.useEffect(() => { if (screenTableName) { loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); } }, [screenTableName]); + // table-columns-refresh 이벤트 수신 시 캐시 무효화 후 최신 메타 다시 로드 + React.useEffect(() => { + const handler = () => { + if (screenTableName) { + invalidateColumnMetaCache(screenTableName); + loadColumnMeta(screenTableName, true).then(() => forceUpdate((v) => v + 1)); + } + }; + window.addEventListener("table-columns-refresh", handler); + return () => window.removeEventListener("table-columns-refresh", handler); + }, [screenTableName]); + // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") const extractTypeFromUrl = (url: string | undefined): string | undefined => { @@ -306,12 +356,40 @@ export const DynamicComponentRenderer: React.FC = const mappedComponentType = mapToV2ComponentType(rawComponentType); - // fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값) + // fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값) const componentType = (() => { - const ft = (component as any).componentConfig?.fieldType; - if (!ft) return mappedComponentType; - if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input"; - if (["select", "category", "entity"].includes(ft)) return "v2-select"; + const configFieldType = (component as any).componentConfig?.fieldType; + const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName; + const isEntityJoin = fieldName?.includes("."); + const baseCol = isEntityJoin ? undefined : fieldName; + const rawDbType = baseCol && screenTableName + ? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType) + : undefined; + const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType; + + // 디버그 (division, unit 필드만) - 문제 확인 후 제거 + if (baseCol && (baseCol === "division" || baseCol === "unit")) { + const result = configFieldType + ? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select") + : dbInputType + ? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select") + : mappedComponentType; + const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType); + console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`); + } + + // 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선 + if (configFieldType) { + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input"; + if (["select", "category", "entity"].includes(configFieldType)) return "v2-select"; + } + + // componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시) + if (dbInputType) { + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input"; + if (["select", "category", "entity"].includes(dbInputType)) return "v2-select"; + } + return mappedComponentType; })(); @@ -376,15 +454,24 @@ export const DynamicComponentRenderer: React.FC = // (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리) // 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인) - const inputType = (component as any).componentConfig?.inputType || (component as any).inputType; + // DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀 + const savedInputType = (component as any).componentConfig?.inputType || (component as any).inputType; const webType = (component as any).componentConfig?.webType; const tableName = (component as any).tableName; const columnName = (component as any).columnName; - // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 - // ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원): - // 1. componentType이 "select-basic" 또는 "v2-select"인 경우 - // 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등) + // DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선 + const dbMetaForField = columnName && screenTableName && !columnName.includes(".") + ? columnMetaCache[screenTableName]?.[columnName] + : undefined; + const dbFieldInputType = dbMetaForField + ? (() => { const raw = dbMetaForField.input_type || dbMetaForField.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() + : undefined; + // DB에서 확인된 타입이 있으면 그걸 사용, 없으면 저장된 값 사용 + const inputType = dbFieldInputType || savedInputType; + // webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음) + const effectiveWebType = dbFieldInputType || webType; + const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode; const isMultipleSelect = (component as any).componentConfig?.multiple; const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"]; @@ -392,7 +479,11 @@ export const DynamicComponentRenderer: React.FC = const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect; - if ((inputType === "category" || webType === "category") && tableName && columnName && shouldUseV2Select) { + // DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵 + // dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지 + const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType); + + if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) { // V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드) try { const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); @@ -491,7 +582,7 @@ export const DynamicComponentRenderer: React.FC = } catch (error) { console.error("❌ V2SelectRenderer 로드 실패:", error); } - } else if ((inputType === "category" || webType === "category") && tableName && columnName) { + } else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; diff --git a/test-output/screen-149-field-type-verification-guide.md b/test-output/screen-149-field-type-verification-guide.md new file mode 100644 index 00000000..a18bfb11 --- /dev/null +++ b/test-output/screen-149-field-type-verification-guide.md @@ -0,0 +1,165 @@ +# Screen 149 필드 타입 검증 가이드 + +## 배경 +- **화면 149**: 품목정보 (item_info 테이블) 폼 +- **division 컬럼**: DB에서 `input_type = 'text'`로 변경했으나 화면에는 여전히 SELECT 드롭다운으로 표시 +- **unit 컬럼**: `input_type = 'category'` → SELECT 드롭다운으로 표시되어야 함 + +## DB 현황 (vexplor-dev 조회 결과) + +| column_name | company_code | input_type | +|-------------|--------------|------------| +| division | * | category | +| division | COMPANY_7 | **text** | +| division | COMPANY_8, 9, 10, 18, 19, 20, 21 | category | +| unit | * | text | +| unit | COMPANY_18, 19, 20, 21, 7, 8, 9 | **category** | + +**주의:** `wace` 사용자는 `company_code = '*'` (최고 관리자)입니다. +- division: company * → **category** (text 아님) +- unit: company * → **text** (category 아님) + +**회사별로 다릅니다.** 예: COMPANY_7의 division은 text, unit은 category. + +--- + +## 수동 검증 절차 + +### 1. 로그인 +- URL: `http://localhost:9771/login` +- User ID: `wace` +- Password: `wace0909!!` +- 회사: "탑씰" (해당 회사 코드 확인 필요) + +### 2. 화면 149 접속 +- URL: `http://localhost:9771/screens/149` +- 페이지 로드 대기 + +### 3. 필드 확인 + +#### 구분 (division) +- **예상 (DB 기준):** + - company *: SELECT (category) + - COMPANY_7: TEXT INPUT (text) +- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인 + +#### 단위 (unit) +- **예상 (DB 기준):** + - company *: TEXT INPUT (text) + - COMPANY_18~21, 7~9: SELECT (category) +- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인 + +### 4. 스크린샷 +- 구분, 단위 필드가 함께 보이도록 캡처 + +--- + +## 코드 흐름 (input_type → 렌더링) + +### 1. 컬럼 메타 로드 +``` +DynamicComponentRenderer + → loadColumnMeta(screenTableName) + → GET /api/table-management/tables/item_info/columns?size=1000 + → columnMetaCache[tableName][columnName] = { inputType, ... } +``` + +### 2. 렌더 타입 결정 (357~369행) +```javascript +const dbInputType = columnMetaCache[screenTableName]?.[baseCol]?.inputType; +const ft = dbInputType || componentConfig?.fieldType; + +if (["text", "number", ...].includes(ft)) return "v2-input"; // 텍스트 입력 +if (["select", "category", "entity"].includes(ft)) return "v2-select"; // 드롭다운 +``` + +### 3. mergeColumnMeta (81~130행) +- DB `input_type`이 화면 저장값보다 우선 +- `needsSync`이면 DB 값으로 덮어씀 + +--- + +## 캐시 관련 + +### 1. 프론트엔드 (DynamicComponentRenderer) +- `columnMetaCache`: TTL 5초 +- `table-columns-refresh` 이벤트 시 즉시 무효화 및 재로드 + +### 2. 백엔드 (tableManagementService) +- 컬럼 목록: 5분 TTL +- `updateColumnInputType` 호출 시 해당 테이블 캐시 삭제 + +### 3. 캐시 무효화가 필요한 경우 +- 데이터 타입 관리에서 변경 후 화면이 갱신되지 않을 때 +- **대응:** 페이지 새로고침 또는 `?_t=timestamp`로 API 재요청 + +--- + +## 가능한 원인 + +### 1. 회사 코드 불일치 +- 로그인한 사용자 회사와 DB의 `company_code`가 다를 수 있음 +- `wace`는 `company_code = '*'` → division은 category, unit은 text + +### 2. 화면 레이아웃에 저장된 값 +- `componentConfig.fieldType`이 있으면 DB보다 우선될 수 있음 +- 코드상으로는 `dbInputType`이 우선이므로, DB가 제대로 로드되면 덮어씀 + +### 3. 캐시 +- 백엔드 5분, 프론트 5초 +- 데이터 타입 변경 후 곧바로 화면을 열면 이전 캐시가 사용될 수 있음 + +### 4. API 응답 구조 +- `columnMetaCache`에 넣을 때 `col.column_name || col.columnName` 사용 +- `mergeColumnMeta`는 `meta.input_type || meta.inputType` 사용 +- 백엔드는 `inputType`(camelCase) 반환 → `columnMetaCache`에 `inputType` 유지 + +--- + +## 디버깅용 Console 스크립트 + +화면 149 로드 후 브라우저 Console에서 실행: + +```javascript +// 1. columnMetaCache 조회 (DynamicComponentRenderer 내부) +// React DevTools로 DynamicComponentRenderer 선택 후 +// 또는 전역에 노출해 둔 경우: +const meta = window.__COLUMN_META_CACHE__?.item_info; +if (meta) { + console.log("division:", meta.division?.inputType || meta.division?.input_type); + console.log("unit:", meta.unit?.inputType || meta.unit?.input_type); +} + +// 2. API 직접 호출 +fetch("/api/table-management/tables/item_info/columns?size=1000", { + credentials: "include" +}) + .then(r => r.json()) + .then(d => { + const cols = d.data?.columns || d.columns || []; + const div = cols.find(c => (c.columnName || c.column_name) === "division"); + const unit = cols.find(c => (c.columnName || c.column_name) === "unit"); + console.log("API division:", div?.inputType || div?.input_type); + console.log("API unit:", unit?.inputType || unit?.input_type); + }); +``` + +--- + +## 권장 사항 + +1. **회사 코드 확인** + - 로그인한 사용자의 `company_code` 확인 + - `division`/`unit`을 text/category로 바꾼 회사가 맞는지 확인 + +2. **캐시 우회** + - 데이터 타입 변경 후 페이지 새로고침 + - 또는 5초 이상 대기 후 다시 접속 + +3. **데이터 타입 관리에서 변경 시** + - 저장 후 `table-columns-refresh` 이벤트 발생 여부 확인 + - 화면 디자이너의 V2FieldConfigPanel에서 변경 시에는 이벤트가 발생함 + +4. **테이블 관리 UI에서 변경 시** + - `table-columns-refresh` 이벤트가 발생하는지 확인 + - 없으면 해당 화면에서 수동으로 `window.dispatchEvent(new CustomEvent("table-columns-refresh"))` 호출 후 재검증 From 92cd07074966753b497a2881a3bca6a2f464aacb Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:22:16 +0900 Subject: [PATCH 05/46] [agent-pipeline] pipe-20260315061036-2tnn round-2 --- .../components/screen/ScreenRelationFlow.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index cad4fc1f..a95e05d0 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -35,13 +35,13 @@ import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement"; import { ScreenSettingModal } from "./ScreenSettingModal"; import { TableSettingModal } from "./TableSettingModal"; -// 관계 유형별 색상 정의 +// 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응) const RELATION_COLORS: Record = { - filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색 - hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색 - lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존) - mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색 - join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색) + filter: { stroke: 'hsl(var(--primary))', strokeLight: 'hsl(var(--primary) / 0.4)', label: '마스터-디테일' }, + hierarchy: { stroke: 'hsl(var(--info))', strokeLight: 'hsl(var(--info) / 0.4)', label: '계층 구조' }, + lookup: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '코드 참조' }, + mapping: { stroke: 'hsl(var(--success))', strokeLight: 'hsl(var(--success) / 0.4)', label: '데이터 매핑' }, + join: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '엔티티 조인' }, }; // 노드 타입 등록 @@ -689,12 +689,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId targetHandle: "left", type: "smoothstep", label: `${i + 1}`, - labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 }, + labelStyle: { fontSize: 11, fill: "hsl(var(--info))", fontWeight: 600 }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, labelBgPadding: [4, 2] as [number, number], - markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" }, + markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" }, animated: true, - style: { stroke: "#0ea5e9", strokeWidth: 2 }, + style: { stroke: "hsl(var(--info))", strokeWidth: 2 }, }); } } @@ -712,7 +712,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId type: "smoothstep", animated: true, // 모든 메인 테이블 연결은 애니메이션 style: { - stroke: "#3b82f6", + stroke: "hsl(var(--primary))", strokeWidth: 2, }, }); @@ -751,7 +751,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId type: "smoothstep", animated: true, style: { - stroke: "#3b82f6", + stroke: "hsl(var(--primary))", strokeWidth: 2, strokeDasharray: "5,5", // 점선으로 필터 관계 표시 }, @@ -1006,10 +1006,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId targetHandle: "top", type: "smoothstep", label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "", - labelStyle: { fontSize: 9, fill: "#10b981" }, + labelStyle: { fontSize: 9, fill: "hsl(var(--success))" }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, labelBgPadding: [3, 2] as [number, number], - style: { stroke: "#10b981", strokeWidth: 1.5 }, + style: { stroke: "hsl(var(--success))", strokeWidth: 1.5 }, }); } } @@ -1029,11 +1029,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId type: "smoothstep", animated: true, label: flow.flow_label || flow.flow_type || "이동", - labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 }, + labelStyle: { fontSize: 10, fill: "hsl(var(--primary))", fontWeight: 500 }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, labelBgPadding: [4, 2] as [number, number], - markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" }, - style: { stroke: "#8b5cf6", strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" }, + style: { stroke: "hsl(var(--primary))", strokeWidth: 2 }, }); } }); @@ -1903,7 +1903,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isConnected, style: { ...edge.style, - stroke: isConnected ? "#8b5cf6" : "#d1d5db", + stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))", strokeWidth: isConnected ? 2 : 1, opacity: isConnected ? 1 : 0.3, }, @@ -1920,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isMyConnection, style: { ...edge.style, - stroke: isMyConnection ? "#3b82f6" : "#d1d5db", + stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))", strokeWidth: isMyConnection ? 2 : 1, strokeDasharray: isMyConnection ? undefined : "5,5", opacity: isMyConnection ? 1 : 0.3, @@ -2040,7 +2040,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isConnected, style: { ...edge.style, - stroke: isConnected ? "#8b5cf6" : "#d1d5db", + stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))", strokeWidth: isConnected ? 2 : 1, opacity: isConnected ? 1 : 0.3, }, @@ -2076,7 +2076,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: true, style: { ...edge.style, - stroke: "#3b82f6", + stroke: "hsl(var(--primary))", strokeWidth: 2, strokeDasharray: "5,5", opacity: 1, @@ -2095,7 +2095,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isMyConnection, style: { ...edge.style, - stroke: isMyConnection ? "#3b82f6" : "#d1d5db", + stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))", strokeWidth: isMyConnection ? 2 : 1, strokeDasharray: isMyConnection ? undefined : "5,5", opacity: isMyConnection ? 1 : 0.3, From 501325e4b49574b66d40e23197b3f176c19e4d78 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:27:14 +0900 Subject: [PATCH 06/46] [agent-pipeline] pipe-20260315061036-2tnn round-3 --- .../components/screen/ScreenRelationFlow.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index a95e05d0..a2ad00c6 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -414,7 +414,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId isFaded = focusedScreenId !== null && !isFocused; } else { // 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게 - isFocused = isMain; + isFocused = !!isMain; isFaded = !isMain && screenList.length > 1; } @@ -426,7 +426,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId label: scr.screenName, subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"), type: "screen", - isMain: selectedGroup ? idx === 0 : isMain, + isMain: selectedGroup ? idx === 0 : !!isMain, tableName: scr.tableName, layoutSummary: summary, // 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통) @@ -990,17 +990,18 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId } }); - // 테이블 관계 엣지 (추가 관계) + // 테이블 관계 엣지 (추가 관계) - 참조용 화면(개별 모드: screen, 그룹 모드: screenList[0]) + const refScreen = screen ?? screenList[0]; relations.forEach((rel: any, idx: number) => { - if (rel.table_name && rel.table_name !== screen.tableName) { + if (rel.table_name && rel.table_name !== refScreen.tableName) { // 화면 → 연결 테이블 const edgeExists = newEdges.some( - (e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}` + (e) => e.source === `screen-${refScreen.screenId}` && e.target === `table-${rel.table_name}` ); if (!edgeExists) { newEdges.push({ id: `edge-rel-${idx}`, - source: `screen-${screen.screenId}`, + source: `screen-${refScreen.screenId}`, target: `table-${rel.table_name}`, sourceHandle: "bottom", targetHandle: "top", @@ -1017,12 +1018,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 데이터 흐름 엣지 (화면 간) flows - .filter((flow: any) => flow.source_screen_id === screen.screenId) + .filter((flow: any) => flow.source_screen_id === refScreen.screenId) .forEach((flow: any, idx: number) => { if (flow.target_screen_id) { newEdges.push({ id: `edge-flow-${idx}`, - source: `screen-${screen.screenId}`, + source: `screen-${refScreen.screenId}`, target: `screen-${flow.target_screen_id}`, sourceHandle: "right", targetHandle: "left", @@ -1134,7 +1135,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 화면 노드 우클릭 if (node.id.startsWith("screen-")) { const screenId = parseInt(node.id.replace("screen-", "")); - const nodeData = node.data as ScreenNodeData; + const nodeData = node.data as unknown as ScreenNodeData; const mainTable = screenTableMap[screenId]; // 해당 화면의 서브 테이블 (필터 테이블) 정보 @@ -1248,7 +1249,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 메인 테이블 노드 더블클릭 if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) { const tableName = node.id.replace("table-", ""); - const nodeData = node.data as TableNodeData; + const nodeData = node.data as unknown as TableNodeData; // 이 테이블을 사용하는 화면 찾기 const screenId = Object.entries(screenTableMap).find( @@ -1293,7 +1294,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 서브 테이블 노드 더블클릭 if (node.id.startsWith("subtable-")) { const tableName = node.id.replace("subtable-", ""); - const nodeData = node.data as TableNodeData; + const nodeData = node.data as unknown as TableNodeData; // 이 서브 테이블을 사용하는 화면 찾기 const screenId = Object.entries(screenSubTableMap).find( @@ -2353,7 +2354,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId fieldMappings={settingModalNode.existingConfig?.fieldMappings} componentCount={0} onSaveSuccess={handleRefreshVisualization} - isPop={isPop} /> )} @@ -2367,7 +2367,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId screenId={settingModalNode.screenId} joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs} referencedBy={settingModalNode.existingConfig?.referencedBy} - columns={settingModalNode.existingConfig?.columns} + columns={settingModalNode.existingConfig?.columns?.map((col) => ({ + column: col.originalName ?? col.name, + label: col.name, + type: col.type, + isPK: col.isPrimaryKey, + isFK: col.isForeignKey, + }))} filterColumns={settingModalNode.existingConfig?.filterColumns} onSaveSuccess={handleRefreshVisualization} /> From 245580117e96713699f460bc346dd925a9df6069 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:30:47 +0900 Subject: [PATCH 07/46] [agent-pipeline] pipe-20260315061036-2tnn round-4 --- .../components/screen/ScreenGroupTreeView.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index ead6ddd3..a4400db6 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -1119,9 +1119,9 @@ export function ScreenGroupTreeView({ )} {isExpanded ? ( - + ) : ( - + )} {group.group_name} @@ -1247,9 +1247,9 @@ export function ScreenGroupTreeView({ )} {isGrandExpanded ? ( - + ) : ( - + )} {grandChild.group_name} @@ -2080,7 +2080,7 @@ export function ScreenGroupTreeView({ onClick={() => handleSync("screen-to-menu")} disabled={isSyncing} variant="outline" - className="w-full justify-start gap-2 border-primary/20 bg-primary/10/50 hover:bg-primary/10/70 hover:border-primary/40" + className="w-full justify-start gap-2 border-primary/20 bg-primary/5 hover:bg-primary/10 hover:border-primary/40" > {isSyncing && syncDirection === "screen-to-menu" ? ( @@ -2096,15 +2096,15 @@ export function ScreenGroupTreeView({ onClick={() => handleSync("menu-to-screen")} disabled={isSyncing} variant="outline" - className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300" + className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30" > {isSyncing && syncDirection === "menu-to-screen" ? ( - + ) : ( - + )} - 메뉴 → 화면관리 동기화 - + 메뉴 → 화면관리 동기화 + 메뉴 구조를 폴더에 반영 From 542663e9e66e1eea048bf915d2063adc45cc3b1a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:30:47 +0900 Subject: [PATCH 08/46] [agent-pipeline] rollback to 501325e4 --- .../components/screen/ScreenGroupTreeView.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index a4400db6..ead6ddd3 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -1119,9 +1119,9 @@ export function ScreenGroupTreeView({ )} {isExpanded ? ( - + ) : ( - + )} {group.group_name} @@ -1247,9 +1247,9 @@ export function ScreenGroupTreeView({ )} {isGrandExpanded ? ( - + ) : ( - + )} {grandChild.group_name} @@ -2080,7 +2080,7 @@ export function ScreenGroupTreeView({ onClick={() => handleSync("screen-to-menu")} disabled={isSyncing} variant="outline" - className="w-full justify-start gap-2 border-primary/20 bg-primary/5 hover:bg-primary/10 hover:border-primary/40" + className="w-full justify-start gap-2 border-primary/20 bg-primary/10/50 hover:bg-primary/10/70 hover:border-primary/40" > {isSyncing && syncDirection === "screen-to-menu" ? ( @@ -2096,15 +2096,15 @@ export function ScreenGroupTreeView({ onClick={() => handleSync("menu-to-screen")} disabled={isSyncing} variant="outline" - className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30" + className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300" > {isSyncing && syncDirection === "menu-to-screen" ? ( - + ) : ( - + )} - 메뉴 → 화면관리 동기화 - + 메뉴 → 화면관리 동기화 + 메뉴 구조를 폴더에 반영 From f375252db1e88ee81bd601de6be59517868837f8 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:33:19 +0900 Subject: [PATCH 09/46] [agent-pipeline] pipe-20260315061036-2tnn round-5 --- frontend/components/screen/ScreenGroupTreeView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index ead6ddd3..7172d100 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -37,7 +37,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/useAuth"; -import { getCompanyList, Company } from "@/lib/api/company"; +import { getCompanyList } from "@/lib/api/company"; +import type { Company } from "@/types/company"; import { DropdownMenu, DropdownMenuContent, From e963129e637407ee7c3ee0077a5d27652e9f421d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:36:53 +0900 Subject: [PATCH 10/46] [agent-pipeline] pipe-20260315061036-2tnn round-6 --- frontend/app/(main)/admin/screenMng/screenMngList/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 76d1b91f..43c8b758 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -162,7 +162,7 @@ export default function ScreenManagementPage() {
-

화면 관리

+

화면 관리

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

From ea6aa6921cabbc08fa7f973c73197f6d7167cbd0 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:36:53 +0900 Subject: [PATCH 11/46] [agent-pipeline] rollback to f375252d --- frontend/app/(main)/admin/screenMng/screenMngList/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 43c8b758..76d1b91f 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -162,7 +162,7 @@ export default function ScreenManagementPage() {
-

화면 관리

+

화면 관리

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

From 015cd2c3eda42266536795fb0420e1593656872f Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:54:04 +0900 Subject: [PATCH 12/46] [agent-pipeline] pipe-20260315065015-rei8 round-1 --- frontend/components/screen/ScreenNode.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 70930e21..105eced0 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -214,10 +214,10 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { /> {/* 헤더 (컬러) */} -
+
{label} - {(isMain || isFocused) && } + {(isMain || isFocused) && }
{/* 화면 미리보기 영역 (컴팩트) */} @@ -352,7 +352,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -376,7 +376,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -409,7 +409,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -429,7 +429,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
액션 화면
{/* 컴포넌트 수 */} -
+
{totalComponents}개
@@ -654,7 +654,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { /> {/* 헤더 (필터 관계: primary, 필터 소스: primary, 메인: primary, 기본: muted-foreground) */} -
@@ -670,7 +670,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{hasActiveColumns && ( - + {displayColumns.length}개 활성 )} @@ -699,7 +699,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { {/* 필터 뱃지 */} {filterRefs.length > 0 && ( `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`} > @@ -714,7 +714,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { {/* 참조 뱃지 */} {lookupRefs.length > 0 && ( `${r.fromTable} → ${r.toColumn}`).join('\n')}`} > {lookupRefs.length}곳 참조 From c4db3fbfd4c13cce37638201c2255b4fb48b9a28 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 15:58:02 +0900 Subject: [PATCH 13/46] [agent-pipeline] pipe-20260315065015-rei8 round-2 --- frontend/components/screen/ScreenRelationFlow.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index a2ad00c6..6296c326 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -1799,12 +1799,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId } }); } - - // 디버깅 로그 - console.log(`서브테이블 ${subTableName} (${subTableInfo?.relationType}):`, { - fieldMappings: subTableInfo?.fieldMappings, - extractedJoinColumns: subTableJoinColumns - }); } // 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우) From b1afe1bc8da8d7793f6395e9f322132892301247 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:01:43 +0900 Subject: [PATCH 14/46] [agent-pipeline] pipe-20260315065015-rei8 round-3 --- .../components/screen/ScreenGroupTreeView.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index 7172d100..6b120501 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -1120,9 +1120,9 @@ export function ScreenGroupTreeView({ )} {isExpanded ? ( - + ) : ( - + )} {group.group_name} @@ -1248,9 +1248,9 @@ export function ScreenGroupTreeView({ )} {isGrandExpanded ? ( - + ) : ( - + )} {grandChild.group_name} @@ -2097,15 +2097,15 @@ export function ScreenGroupTreeView({ onClick={() => handleSync("menu-to-screen")} disabled={isSyncing} variant="outline" - className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300" + className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30" > {isSyncing && syncDirection === "menu-to-screen" ? ( - + ) : ( - + )} - 메뉴 → 화면관리 동기화 - + 메뉴 → 화면관리 동기화 + 메뉴 구조를 폴더에 반영 From 0db57fe01af5866f1e921409beb1257785115eed Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:05:06 +0900 Subject: [PATCH 15/46] [agent-pipeline] pipe-20260315065015-rei8 round-4 --- frontend/app/(main)/admin/screenMng/screenMngList/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 76d1b91f..43c8b758 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -162,7 +162,7 @@ export default function ScreenManagementPage() {
-

화면 관리

+

화면 관리

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

From bad3a002f334f4001a925f994689e502c597f41c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:05:07 +0900 Subject: [PATCH 16/46] [agent-pipeline] rollback to b1afe1bc --- frontend/app/(main)/admin/screenMng/screenMngList/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 43c8b758..76d1b91f 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -162,7 +162,7 @@ export default function ScreenManagementPage() {
-

화면 관리

+

화면 관리

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

From 265d79cc5ab58ee5d1637d70ee10f4842a1f7c21 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:07:43 +0900 Subject: [PATCH 17/46] [agent-pipeline] pipe-20260315065015-rei8 round-5 --- frontend/app/(main)/admin/screenMng/screenMngList/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 76d1b91f..c59b7e4c 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -269,9 +269,9 @@ export default function ScreenManagementPage() { {/* 화면 생성 모달 */} setIsCreateOpen(false)} - onSuccess={() => { + open={isCreateOpen} + onOpenChange={setIsCreateOpen} + onCreated={() => { setIsCreateOpen(false); loadScreens(); }} From 21ca0f3a3ce14c43fb11e7878b85488d0296858a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:27:14 +0900 Subject: [PATCH 18/46] [agent-pipeline] pipe-20260315072335-zb1m round-1 --- frontend/components/screen/ScreenNode.tsx | 69 ++++++++++++----------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 105eced0..792dba8e 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -107,42 +107,42 @@ const getScreenTypeIcon = (screenType?: string) => { } }; -// 화면 타입별 색상 (헤더) +// 화면 타입별 색상 (헤더) - 그라데이션 const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { - if (!isMain) return "bg-muted-foreground"; + if (!isMain) return "bg-gradient-to-r from-muted-foreground to-muted-foreground/80"; switch (screenType) { case "grid": - return "bg-primary"; + return "bg-gradient-to-r from-primary to-primary/80"; case "dashboard": - return "bg-warning"; + return "bg-gradient-to-r from-warning to-warning/80"; case "action": - return "bg-destructive"; + return "bg-gradient-to-r from-destructive to-destructive/80"; default: - return "bg-primary"; + return "bg-gradient-to-r from-primary to-primary/80"; } }; -// 화면 역할(screenRole)에 따른 색상 +// 화면 역할(screenRole)에 따른 색상 - 그라데이션 const getScreenRoleColor = (screenRole?: string) => { - if (!screenRole) return "bg-muted-foreground"; + if (!screenRole) return "bg-gradient-to-r from-muted-foreground to-muted-foreground/80"; // 역할명에 포함된 키워드로 색상 결정 const role = screenRole.toLowerCase(); if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) { - return "bg-primary"; // 메인 그리드 + return "bg-gradient-to-r from-primary to-primary/80"; // 메인 그리드 } if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) { - return "bg-primary"; // 등록 폼 + return "bg-gradient-to-r from-primary to-primary/80"; // 등록 폼 } if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) { - return "bg-destructive"; // 액션/이벤트 + return "bg-gradient-to-r from-destructive to-destructive/80"; // 액션/이벤트 } if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) { - return "bg-warning"; // 상세/팝업 + return "bg-gradient-to-r from-warning to-warning/80"; // 상세/팝업 } - return "bg-muted-foreground"; // 기본 회색 + return "bg-gradient-to-r from-muted-foreground to-muted-foreground/80"; // 기본 회색 }; // 화면 타입별 라벨 @@ -169,7 +169,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { let headerColor: string; if (isInGroup) { if (isFaded) { - headerColor = "bg-muted/60"; // 흑백 처리 - 더 확실한 회색 + headerColor = "bg-gradient-to-r from-muted to-muted/60"; // 흑백 처리 - 더 확실한 회색 } else { // 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상 headerColor = getScreenRoleColor(screenRole); @@ -180,17 +180,16 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { return (
{/* Handles */} @@ -214,14 +213,14 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { /> {/* 헤더 (컬러) */} -
+
- {label} + {label} {(isMain || isFocused) && }
{/* 화면 미리보기 영역 (컴팩트) */} -
+
{layoutSummary ? ( ) : ( @@ -262,7 +261,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
{/* 푸터 (테이블 정보) */} -
+
{tableName || "No Table"} @@ -574,7 +573,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
= ({ data }) => { ? "border-2 border-primary ring-4 ring-primary/30 shadow-xl bg-card" // 4. 흐리게 처리 : isFaded - ? "border-border opacity-60 bg-card" + ? "opacity-60 bg-card" // 5. 기본 - : "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20 bg-card" + : "hover:shadow-xl hover:ring-2 hover:ring-primary/20" }`} style={{ filter: isFaded ? "grayscale(80%)" : "none", @@ -653,9 +652,15 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100" /> - {/* 헤더 (필터 관계: primary, 필터 소스: primary, 메인: primary, 기본: muted-foreground) */} + {/* 헤더 (필터 관계: primary, 필터 소스: primary, 메인: primary, 기본: muted-foreground) - 그라데이션 */}
@@ -670,7 +675,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{hasActiveColumns && ( - + {displayColumns.length}개 활성 )} @@ -745,14 +750,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { key={col.name} className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${ isJoinColumn - ? "bg-warning/10 border border-warning/30 shadow-sm" + ? "bg-warning/10 border border-warning/20 shadow-sm" : isFilterColumn || isFilterSourceColumn - ? "bg-primary/10 border border-primary/30 shadow-sm" // 필터 컬럼/필터 소스 + ? "bg-primary/10 border border-primary/20 shadow-sm" // 필터 컬럼/필터 소스 : isHighlighted ? "bg-primary/10 border border-primary/40 shadow-sm" : hasActiveColumns ? "bg-muted" - : "bg-muted/50 hover:bg-muted" + : "bg-muted/50 hover:bg-muted/80 transition-colors" }`} style={{ animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined, From bafc81b2a38f51885a301c9db4c73f22a65f445c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:30:14 +0900 Subject: [PATCH 19/46] [agent-pipeline] pipe-20260315072335-zb1m round-2 --- .../admin/screenMng/screenMngList/page.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index c59b7e4c..63949a44 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -159,11 +159,11 @@ export default function ScreenManagementPage() { return (
{/* 페이지 헤더 */} -
+
-

화면 관리

-

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

+

화면 관리

+

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

{/* V2 컴포넌트 테스트 버튼 */} @@ -177,12 +177,12 @@ export default function ScreenManagementPage() { {/* 뷰 모드 전환 */} setViewMode(v as ViewMode)}> - - + + 트리 - + 테이블 @@ -191,28 +191,29 @@ export default function ScreenManagementPage() { -
+
{/* 메인 콘텐츠 */} {viewMode === "tree" ? (
{/* 왼쪽: 트리 구조 */} -
+
{/* 검색 */} -
+
setSearchTerm(e.target.value)} - className="pl-9 h-9" + className="pl-9 h-9 bg-muted/30 border-border/50 focus:bg-background transition-colors" />
@@ -248,7 +249,7 @@ export default function ScreenManagementPage() {
{/* 오른쪽: 관계 시각화 (React Flow) */} -
+
Date: Sun, 15 Mar 2026 16:32:53 +0900 Subject: [PATCH 20/46] [agent-pipeline] pipe-20260315072335-zb1m round-3 --- frontend/app/(main)/admin/screenMng/screenMngList/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 63949a44..e367e242 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -204,7 +204,7 @@ export default function ScreenManagementPage() { {viewMode === "tree" ? (
{/* 왼쪽: 트리 구조 */} -
+
{/* 검색 */}
@@ -213,7 +213,7 @@ export default function ScreenManagementPage() { placeholder="화면 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} - className="pl-9 h-9 bg-muted/30 border-border/50 focus:bg-background transition-colors" + className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" />
From bf509171db80a81e2d892e7d7990e36e1511f27a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:38:00 +0900 Subject: [PATCH 21/46] [agent-pipeline] pipe-20260315072335-zb1m round-4 --- .../components/screen/ScreenGroupTreeView.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index 6b120501..65375419 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -1120,12 +1120,12 @@ export function ScreenGroupTreeView({ )} {isExpanded ? ( - + ) : ( - + )} {group.group_name} - + {groupScreens.length} {/* 그룹 메뉴 버튼 */} @@ -1186,12 +1186,12 @@ export function ScreenGroupTreeView({ )} {isChildExpanded ? ( - + ) : ( - + )} {childGroup.group_name} - + {childScreens.length} @@ -1248,12 +1248,12 @@ export function ScreenGroupTreeView({ )} {isGrandExpanded ? ( - + ) : ( - + )} {grandChild.group_name} - + {grandScreens.length} @@ -1295,9 +1295,9 @@ export function ScreenGroupTreeView({
handleScreenClickInGroup(screen, grandChild)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1331,9 +1331,9 @@ export function ScreenGroupTreeView({
handleScreenClickInGroup(screen, childGroup)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1367,9 +1367,9 @@ export function ScreenGroupTreeView({
handleScreenClickInGroup(screen, group)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1406,7 +1406,7 @@ export function ScreenGroupTreeView({ )} 미분류 - + {ungroupedScreens.length}
@@ -1417,9 +1417,9 @@ export function ScreenGroupTreeView({
handleScreenClick(screen)} onDoubleClick={() => handleScreenDoubleClick(screen)} From 24b53b5b33b36e1e92108c554b57b4d74602f937 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 16:42:20 +0900 Subject: [PATCH 22/46] [agent-pipeline] pipe-20260315072335-zb1m round-5 --- .../components/screen/ScreenRelationFlow.tsx | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 6296c326..2bdc069d 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -1899,8 +1899,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId style: { ...edge.style, stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))", - strokeWidth: isConnected ? 2 : 1, - opacity: isConnected ? 1 : 0.3, + strokeWidth: isConnected ? 2.5 : 1, + opacity: isConnected ? 1 : 0.2, }, }; } @@ -1916,9 +1916,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId style: { ...edge.style, stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))", - strokeWidth: isMyConnection ? 2 : 1, + strokeWidth: isMyConnection ? 2.5 : 1, strokeDasharray: isMyConnection ? undefined : "5,5", - opacity: isMyConnection ? 1 : 0.3, + opacity: isMyConnection ? 1 : 0.2, }, }; } @@ -1997,7 +1997,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: true, style: { stroke: relationColor.stroke, // 관계 유형별 색상 - strokeWidth: 2, + strokeWidth: 2.5, strokeDasharray: '8,4', }, markerEnd: { @@ -2036,8 +2036,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId style: { ...edge.style, stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))", - strokeWidth: isConnected ? 2 : 1, - opacity: isConnected ? 1 : 0.3, + strokeWidth: isConnected ? 2.5 : 1, + opacity: isConnected ? 1 : 0.2, }, }; } @@ -2072,7 +2072,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId style: { ...edge.style, stroke: "hsl(var(--primary))", - strokeWidth: 2, + strokeWidth: 2.5, strokeDasharray: "5,5", opacity: 1, }, @@ -2091,9 +2091,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId style: { ...edge.style, stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))", - strokeWidth: isMyConnection ? 2 : 1, + strokeWidth: isMyConnection ? 2.5 : 1, strokeDasharray: isMyConnection ? undefined : "5,5", - opacity: isMyConnection ? 1 : 0.3, + opacity: isMyConnection ? 1 : 0.2, }, }; } @@ -2150,7 +2150,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId stroke: isActive ? relationColor.stroke : relationColor.strokeLight, strokeWidth: isActive ? 2.5 : 1.5, strokeDasharray: "8,4", - opacity: isActive ? 1 : 0.3, + opacity: isActive ? 1 : 0.2, }, markerEnd: { type: MarkerType.ArrowClosed, @@ -2174,7 +2174,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId stroke: RELATION_COLORS.join.strokeLight, strokeWidth: 1.5, strokeDasharray: "6,4", - opacity: 0.3, + opacity: 0.2, }, markerEnd: { type: MarkerType.ArrowClosed, @@ -2201,7 +2201,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId style: { ...edge.style, stroke: RELATION_COLORS.join.stroke, - strokeWidth: 2, + strokeWidth: 2.5, strokeDasharray: "6,4", opacity: 1, }, @@ -2317,6 +2317,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId {/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
Date: Sun, 15 Mar 2026 17:10:04 +0900 Subject: [PATCH 23/46] [agent-pipeline] pipe-20260315080636-1tpd round-1 --- .../admin/screenMng/screenMngList/page.tsx | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index e367e242..862ded32 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react"; +import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, ChevronRight, Monitor, Database, FolderOpen } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; @@ -15,6 +15,7 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; import CreateScreenModal from "@/components/screen/CreateScreenModal"; // 단계별 진행을 위한 타입 정의 @@ -34,6 +35,8 @@ export default function ScreenManagementPage() { const [searchTerm, setSearchTerm] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); + const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]); + // 화면 목록 로드 const loadScreens = useCallback(async () => { try { @@ -200,6 +203,33 @@ export default function ScreenManagementPage() {
+ {/* 통계 요약 바 */} +
+
+ + 화면 + {screens.length} +
+
+
+ + 테이블 + {tableCount} +
+ {(selectedGroup || selectedScreen) && ( +
+ 현재: + {selectedGroup && {selectedGroup.name}} + {selectedScreen && ( + <> + + {selectedScreen.screenName} + + )} +
+ )} +
+ {/* 메인 콘텐츠 */} {viewMode === "tree" ? (
@@ -246,6 +276,24 @@ export default function ScreenManagementPage() { }} />
+ {/* 선택 미리보기 */} + {selectedScreen && ( +
+
+ + {selectedScreen.screenName} +
+
+ {selectedScreen.screenCode} + {selectedScreen.tableName || "테이블 없음"} +
+
+ +
+
+ )}
{/* 오른쪽: 관계 시각화 (React Flow) */} From 94a95b7dc1d0fe64c814b87ba581b4798b330c0b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 17:13:37 +0900 Subject: [PATCH 24/46] [agent-pipeline] pipe-20260315080636-1tpd round-2 --- .../components/screen/ScreenRelationFlow.tsx | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 2bdc069d..05a6ed04 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -34,6 +34,7 @@ import { import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement"; import { ScreenSettingModal } from "./ScreenSettingModal"; import { TableSettingModal } from "./TableSettingModal"; +import { Monitor, Database, FolderOpen } from "lucide-react"; // 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응) const RELATION_COLORS: Record = { @@ -2295,10 +2296,38 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 조건부 렌더링 (모든 훅 선언 후에 위치해야 함) if (!screen && !selectedGroup) { return ( -
-
-

그룹 또는 화면을 선택하면

-

데이터 관계가 시각화됩니다

+
+
+
+
+ +
+
+
+ +
+
+
+
+

화면 관계 시각화

+

+ 좌측에서 그룹 또는 화면을 선택하면
+ 테이블 관계가 자동으로 시각화됩니다. +

+
+
+
+ 1 + 그룹 선택 +
+
+ 2 + 관계 확인 +
+
+ 3 + 화면 편집 +
); @@ -2313,7 +2342,25 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId } return ( -
+
+ {/* 선택 정보 바 (캔버스 상단) */} + {(screen || selectedGroup) && ( +
+ {selectedGroup && ( + <> + + {selectedGroup.name} + + )} + {screen && !selectedGroup && ( + <> + + {screen.screenName} + {screen.screenCode} + + )} +
+ )} {/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
+ {/* 관계 범례 */} +
+

관계 유형

+
+
+
+ 메인 테이블 +
+
+
+ 마스터-디테일 +
+
+
+ 코드 참조 +
+
+
+ 엔티티 조인 +
+
+
From 4c19d3a6eb92cdae9e0041335798511ae788a28e Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 17:18:18 +0900 Subject: [PATCH 25/46] [agent-pipeline] pipe-20260315080636-1tpd round-3 --- .../components/screen/ScreenGroupTreeView.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index 65375419..46a96847 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -1107,7 +1107,7 @@ export function ScreenGroupTreeView({ {/* 그룹 헤더 */}
0 && ( -
+
+
{childGroups.map((childGroup) => { const childGroupId = String(childGroup.id); const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장 @@ -1173,7 +1174,7 @@ export function ScreenGroupTreeView({ {/* 중분류 헤더 */}
0 && ( -
+
+
{grandChildGroups.map((grandChild) => { const grandChildId = String(grandChild.id); const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장 @@ -1235,7 +1237,7 @@ export function ScreenGroupTreeView({ {/* 소분류 헤더 */}
handleScreenClickInGroup(screen, grandChild)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1333,7 +1335,7 @@ export function ScreenGroupTreeView({ className={cn( "flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150", "text-xs hover:bg-muted/60", - selectedScreen?.screenId === screen.screenId && "bg-accent shadow-sm" + selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary" )} onClick={() => handleScreenClickInGroup(screen, childGroup)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1369,7 +1371,7 @@ export function ScreenGroupTreeView({ className={cn( "flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150", "text-sm hover:bg-muted/60 group/screen", - selectedScreen?.screenId === screen.screenId && "bg-accent shadow-sm" + selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary" )} onClick={() => handleScreenClickInGroup(screen, group)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1394,7 +1396,7 @@ export function ScreenGroupTreeView({
toggleGroup("ungrouped")} @@ -1419,7 +1421,7 @@ export function ScreenGroupTreeView({ className={cn( "flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150", "text-sm hover:bg-muted/60", - selectedScreen?.screenId === screen.screenId && "bg-accent shadow-sm" + selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary" )} onClick={() => handleScreenClick(screen)} onDoubleClick={() => handleScreenDoubleClick(screen)} From 27ce039fc85d66ce1b6d8bb82ada4615f1b8e8ef Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 17:22:24 +0900 Subject: [PATCH 26/46] [agent-pipeline] pipe-20260315080636-1tpd round-4 --- frontend/components/screen/ScreenNode.tsx | 77 +++++------------------ 1 file changed, 15 insertions(+), 62 deletions(-) diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 792dba8e..119b24e3 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -11,7 +11,6 @@ import { MousePointer2, Key, Link2, - Columns3, } from "lucide-react"; import { ScreenLayoutSummary } from "@/lib/api/screenGroup"; @@ -180,7 +179,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { return (
= ({ data }) => { /> {/* 헤더 (컬러) */} -
- - {label} +
+
+ +
+
+
{label}
+ {tableName &&
{tableName}
} +
{(isMain || isFocused) && }
{/* 화면 미리보기 영역 (컴팩트) */} -
+
{layoutSummary ? ( ) : ( @@ -231,44 +235,10 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { )}
- {/* 필드 매핑 영역 */} -
-
- - 필드 매핑 - - {layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}개 - -
-
- {layoutSummary?.layoutItems - ?.filter(item => item.label && !item.componentKind?.includes('button')) - ?.slice(0, 6) - ?.map((item, idx) => ( -
-
- {item.label} - {item.componentKind?.split('-')[0] || 'field'} -
- )) || ( -
필드 정보 없음
- )} -
-
- - {/* 푸터 (테이블 정보) */} -
-
- - {tableName || "No Table"} -
- - {getScreenTypeLabel(screenType)} - + {/* 푸터 (타입 + 컴포넌트 수) */} +
+ {getScreenTypeLabel(screenType)} + {layoutSummary?.totalComponents ?? 0}개 컴포넌트
); @@ -350,10 +320,6 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
- {/* 컴포넌트 수 */} -
- {totalComponents}개 -
); } @@ -374,10 +340,6 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
- {/* 컴포넌트 수 */} -
- {totalComponents}개 -
); } @@ -406,10 +368,6 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: /> ))}
-
- {/* 컴포넌트 수 */} -
- {totalComponents}개
); @@ -427,10 +385,6 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
액션 화면
- {/* 컴포넌트 수 */} -
- {totalComponents}개 -
); } @@ -836,8 +790,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 푸터 (컴팩트) */} -
- PostgreSQL +
{columns && ( {hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}개 컬럼 From 784dc73abf0edcff95db830938dc69d9467c424d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 18:22:20 +0900 Subject: [PATCH 27/46] [agent-pipeline] pipe-20260315091327-kxyf round-1 --- .../admin/screenMng/screenMngList/page.tsx | 82 ++++++++----------- .../components/screen/AnimatedFlowEdge.tsx | 70 ++++++++++++++++ 2 files changed, 102 insertions(+), 50 deletions(-) create mode 100644 frontend/components/screen/AnimatedFlowEdge.tsx diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 862ded32..3631aa19 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, ChevronRight, Monitor, Database, FolderOpen } from "lucide-react"; +import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, ChevronRight, Monitor, Database, FolderOpen, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; @@ -16,11 +16,17 @@ import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import CreateScreenModal from "@/components/screen/CreateScreenModal"; // 단계별 진행을 위한 타입 정의 type Step = "list" | "design" | "template" | "v2-test"; -type ViewMode = "tree" | "table"; +type ViewMode = "flow" | "card"; export default function ScreenManagementPage() { const searchParams = useSearchParams(); @@ -29,7 +35,7 @@ export default function ScreenManagementPage() { const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); - const [viewMode, setViewMode] = useState("tree"); + const [viewMode, setViewMode] = useState("flow"); const [screens, setScreens] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); @@ -162,32 +168,23 @@ export default function ScreenManagementPage() { return (
{/* 페이지 헤더 */} -
+
-
-

화면 관리

-

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

+
+

화면 관리

+ {screens.length}개 화면
- {/* V2 컴포넌트 테스트 버튼 */} - {/* 뷰 모드 전환 */} setViewMode(v as ViewMode)}> - + - 트리 + 관계도 - + - 테이블 + 카드 @@ -198,40 +195,25 @@ export default function ScreenManagementPage() { 새 화면 + + + + + + goToNextStep("v2-test")}> + + V2 테스트 + + +
-
-
- - {/* 통계 요약 바 */} -
-
- - 화면 - {screens.length} -
-
-
- - 테이블 - {tableCount} -
- {(selectedGroup || selectedScreen) && ( -
- 현재: - {selectedGroup && {selectedGroup.name}} - {selectedScreen && ( - <> - - {selectedScreen.screenName} - - )} -
- )}
{/* 메인 콘텐츠 */} - {viewMode === "tree" ? ( + {viewMode === "flow" ? (
{/* 왼쪽: 트리 구조 */}
@@ -306,7 +288,7 @@ export default function ScreenManagementPage() {
) : ( - // 테이블 뷰 (기존 ScreenList 사용) + // 카드 뷰 (기존 ScreenList 사용)
+ {/* 글로우용 SVG 필터 정의 (엣지별 고유 ID) */} + + + + + + + + + + {/* 글로우 레이어 */} + + {/* 메인 엣지 */} + + {/* 흐르는 파티클 */} + {isActive && ( + <> + + + + + + + + )} + + ); +} From d542e92021e933072fbeee131c0dc024f949d418 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 18:31:12 +0900 Subject: [PATCH 28/46] [agent-pipeline] pipe-20260315091327-kxyf round-2 --- .../admin/screenMng/screenMngList/page.tsx | 147 +++++++++++------- .../components/screen/AnimatedFlowEdge.tsx | 2 +- 2 files changed, 93 insertions(+), 56 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 3631aa19..e5d26eef 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -40,6 +40,7 @@ export default function ScreenManagementPage() { const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]); @@ -215,64 +216,100 @@ export default function ScreenManagementPage() { {/* 메인 콘텐츠 */} {viewMode === "flow" ? (
- {/* 왼쪽: 트리 구조 */} -
- {/* 검색 */} -
-
- - setSearchTerm(e.target.value)} - className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" - /> -
+ {/* 왼쪽: 트리 구조 (접기/펼치기) */} +
+ {/* 사이드바 토글 */} +
+ {!sidebarCollapsed && 탐색} +
- {/* 트리 뷰 */} -
- { - setSelectedGroup(group); - setSelectedScreen(null); // 화면 선택 해제 - setFocusedScreenIdInGroup(null); // 포커스 초기화 - }} - onScreenSelectInGroup={(group, screenId) => { - // 그룹 내 화면 클릭 시 - const isNewGroup = selectedGroup?.id !== group.id; - - if (isNewGroup) { - // 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지) - setSelectedGroup(group); - setFocusedScreenIdInGroup(null); - } else { - // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 - setFocusedScreenIdInGroup(screenId); - } - setSelectedScreen(null); - }} - /> -
- {/* 선택 미리보기 */} - {selectedScreen && ( -
-
- - {selectedScreen.screenName} + + {!sidebarCollapsed && ( + <> + {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" + /> +
-
- {selectedScreen.screenCode} - {selectedScreen.tableName || "테이블 없음"} + {/* 트리 뷰 */} +
+ { + setSelectedGroup(group); + setSelectedScreen(null); + setFocusedScreenIdInGroup(null); + }} + onScreenSelectInGroup={(group, screenId) => { + const isNewGroup = selectedGroup?.id !== group.id; + if (isNewGroup) { + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); + }} + />
-
- + {/* 선택 미리보기 */} + {selectedScreen && ( +
+
+ + {selectedScreen.screenName} +
+
+ {selectedScreen.screenCode} + {selectedScreen.tableName || "테이블 없음"} +
+
+ +
+
+ )} + + )} + + {/* 접힌 상태: 검색 아이콘 + 화면 수 배지 */} + {sidebarCollapsed && ( +
+ +
+ {screens.length}
)} diff --git a/frontend/components/screen/AnimatedFlowEdge.tsx b/frontend/components/screen/AnimatedFlowEdge.tsx index dc33dcfa..f5d8781a 100644 --- a/frontend/components/screen/AnimatedFlowEdge.tsx +++ b/frontend/components/screen/AnimatedFlowEdge.tsx @@ -28,7 +28,7 @@ export function AnimatedFlowEdge({ const strokeColor = (style?.stroke as string) || "hsl(var(--primary))"; const strokeW = (style?.strokeWidth as number) || 2; const isActive = data?.active !== false; - const duration = data?.duration || "3s"; + const duration: string = typeof data?.duration === "string" ? data.duration : "3s"; const filterId = `edge-glow-${id}`; return ( From ffc7cb7933914adde8dddd48e4e26f95e4c32eb3 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 18:31:12 +0900 Subject: [PATCH 29/46] [agent-pipeline] rollback to 784dc73a --- .../admin/screenMng/screenMngList/page.tsx | 147 +++++++----------- .../components/screen/AnimatedFlowEdge.tsx | 2 +- 2 files changed, 56 insertions(+), 93 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index e5d26eef..3631aa19 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -40,7 +40,6 @@ export default function ScreenManagementPage() { const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]); @@ -216,100 +215,64 @@ export default function ScreenManagementPage() { {/* 메인 콘텐츠 */} {viewMode === "flow" ? (
- {/* 왼쪽: 트리 구조 (접기/펼치기) */} -
- {/* 사이드바 토글 */} -
- {!sidebarCollapsed && 탐색} - + {/* 왼쪽: 트리 구조 */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" + /> +
- - {!sidebarCollapsed && ( - <> - {/* 검색 */} -
-
- - setSearchTerm(e.target.value)} - className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" - /> -
+ {/* 트리 뷰 */} +
+ { + setSelectedGroup(group); + setSelectedScreen(null); // 화면 선택 해제 + setFocusedScreenIdInGroup(null); // 포커스 초기화 + }} + onScreenSelectInGroup={(group, screenId) => { + // 그룹 내 화면 클릭 시 + const isNewGroup = selectedGroup?.id !== group.id; + + if (isNewGroup) { + // 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지) + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); + }} + /> +
+ {/* 선택 미리보기 */} + {selectedScreen && ( +
+
+ + {selectedScreen.screenName}
- {/* 트리 뷰 */} -
- { - setSelectedGroup(group); - setSelectedScreen(null); - setFocusedScreenIdInGroup(null); - }} - onScreenSelectInGroup={(group, screenId) => { - const isNewGroup = selectedGroup?.id !== group.id; - if (isNewGroup) { - setSelectedGroup(group); - setFocusedScreenIdInGroup(null); - } else { - setFocusedScreenIdInGroup(screenId); - } - setSelectedScreen(null); - }} - /> +
+ {selectedScreen.screenCode} + {selectedScreen.tableName || "테이블 없음"}
- {/* 선택 미리보기 */} - {selectedScreen && ( -
-
- - {selectedScreen.screenName} -
-
- {selectedScreen.screenCode} - {selectedScreen.tableName || "테이블 없음"} -
-
- -
-
- )} - - )} - - {/* 접힌 상태: 검색 아이콘 + 화면 수 배지 */} - {sidebarCollapsed && ( -
- -
- {screens.length} +
+
)} diff --git a/frontend/components/screen/AnimatedFlowEdge.tsx b/frontend/components/screen/AnimatedFlowEdge.tsx index f5d8781a..dc33dcfa 100644 --- a/frontend/components/screen/AnimatedFlowEdge.tsx +++ b/frontend/components/screen/AnimatedFlowEdge.tsx @@ -28,7 +28,7 @@ export function AnimatedFlowEdge({ const strokeColor = (style?.stroke as string) || "hsl(var(--primary))"; const strokeW = (style?.strokeWidth as number) || 2; const isActive = data?.active !== false; - const duration: string = typeof data?.duration === "string" ? data.duration : "3s"; + const duration = data?.duration || "3s"; const filterId = `edge-glow-${id}`; return ( From 2cb736dac1f9f013c7bdb57ae69c8f0dbdfc1b8e Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 18:46:28 +0900 Subject: [PATCH 30/46] [agent-pipeline] pipe-20260315091327-kxyf round-3 --- .../admin/screenMng/screenMngList/page.tsx | 38 +++++++++++++++---- frontend/components/screen/ScreenNode.tsx | 35 +++++++++++++---- .../components/screen/ScreenRelationFlow.tsx | 37 +++++++++++++----- 3 files changed, 85 insertions(+), 25 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 3631aa19..2ff3096b 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -4,8 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, ChevronRight, Monitor, Database, FolderOpen, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import ScreenList from "@/components/screen/ScreenList"; +import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, Monitor, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; @@ -288,13 +287,36 @@ export default function ScreenManagementPage() {
) : ( - // 카드 뷰 (기존 ScreenList 사용)
- +
+ {filteredScreens.map((screen) => ( +
handleScreenSelect(screen)} + onDoubleClick={() => handleDesignScreen(screen)} + > +
+ +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+ {screen.tableName || "테이블 없음"} +
+
+
+ ))} +
+ {filteredScreens.length === 0 && ( +
+ +

검색 결과가 없습니다

+
+ )}
)} diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 119b24e3..119f6944 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -14,6 +14,22 @@ import { } from "lucide-react"; import { ScreenLayoutSummary } from "@/lib/api/screenGroup"; +// 글로우 펄스 애니메이션 CSS 주입 +if (typeof document !== "undefined") { + const styleId = "glow-pulse-animation"; + if (!document.getElementById(styleId)) { + const style = document.createElement("style"); + style.id = styleId; + style.textContent = ` + @keyframes glow-pulse { + from { filter: drop-shadow(0 0 6px hsl(221.2 83.2% 53.3% / 0.4)) drop-shadow(0 0 14px hsl(221.2 83.2% 53.3% / 0.2)); } + to { filter: drop-shadow(0 0 10px hsl(221.2 83.2% 53.3% / 0.6)) drop-shadow(0 0 22px hsl(221.2 83.2% 53.3% / 0.3)); } + } + `; + document.head.appendChild(style); + } +} + // ========== 타입 정의 ========== // 화면 노드 데이터 인터페이스 @@ -181,14 +197,19 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
{/* Handles */} @@ -196,19 +217,19 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { type="target" position={Position.Left} id="left" - className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2.5 !w-2.5 !border-2 !border-background !bg-primary opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]" /> {/* 헤더 (컬러) */} diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 05a6ed04..59ced0ec 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -34,6 +34,7 @@ import { import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement"; import { ScreenSettingModal } from "./ScreenSettingModal"; import { TableSettingModal } from "./TableSettingModal"; +import { AnimatedFlowEdge } from "./AnimatedFlowEdge"; import { Monitor, Database, FolderOpen } from "lucide-react"; // 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응) @@ -51,6 +52,10 @@ const nodeTypes = { tableNode: TableNode, }; +const edgeTypes = { + animatedFlow: AnimatedFlowEdge, +}; + // 레이아웃 상수 const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단) const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단) @@ -688,7 +693,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `screen-${nextScreen.screenId}`, sourceHandle: "right", targetHandle: "left", - type: "smoothstep", + type: "animatedFlow", label: `${i + 1}`, labelStyle: { fontSize: 11, fill: "hsl(var(--info))", fontWeight: 600 }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, @@ -710,7 +715,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `table-${scr.tableName}`, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", animated: true, // 모든 메인 테이블 연결은 애니메이션 style: { stroke: "hsl(var(--primary))", @@ -749,7 +754,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: targetNodeId, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", animated: true, style: { stroke: "hsl(var(--primary))", @@ -794,7 +799,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: refTargetNodeId, sourceHandle: "bottom", targetHandle: "bottom_target", - type: "smoothstep", + type: "animatedFlow", animated: false, style: { stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색) @@ -902,7 +907,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `table-${referencedTable}`, // 참조당하는 테이블 sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로) targetHandle: "bottom_target", // 하단으로 들어감 - type: "smoothstep", + type: "animatedFlow", animated: false, style: { stroke: relationColor.strokeLight, // 관계 유형별 연한 색상 @@ -945,7 +950,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `subtable-${subTable.tableName}`, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", markerEnd: { type: MarkerType.ArrowClosed, color: relationColor.strokeLight @@ -974,7 +979,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `table-${join.join_table}`, sourceHandle: "bottom", targetHandle: "bottom_target", - type: "smoothstep", + type: "animatedFlow", markerEnd: { type: MarkerType.ArrowClosed, color: RELATION_COLORS.join.strokeLight @@ -1006,7 +1011,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `table-${rel.table_name}`, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "", labelStyle: { fontSize: 9, fill: "hsl(var(--success))" }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, @@ -1028,7 +1033,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `screen-${flow.target_screen_id}`, sourceHandle: "right", targetHandle: "left", - type: "smoothstep", + type: "animatedFlow", animated: true, label: flow.flow_label || flow.flow_type || "이동", labelStyle: { fontSize: 10, fill: "hsl(var(--primary))", fontWeight: 500 }, @@ -1994,7 +1999,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: targetNodeId, sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과 targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과 - type: 'smoothstep', + type: "animatedFlow", animated: true, style: { stroke: relationColor.stroke, // 관계 유형별 색상 @@ -2372,10 +2377,22 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId onNodeClick={handleNodeClick} onNodeContextMenu={handleNodeContextMenu} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} minZoom={0.3} maxZoom={1.5} proOptions={{ hideAttribution: true }} > + + + + + + + + + + + {/* 관계 범례 */} From beb95bf2aa4cda501c5d7df84d5d4bd7e48ef1f8 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 18:57:12 +0900 Subject: [PATCH 31/46] [agent-pipeline] pipe-20260315091327-kxyf round-4 --- .../admin/screenMng/screenMngList/page.tsx | 53 ++++++++++++------- .../components/screen/ScreenRelationFlow.tsx | 23 +++++++- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 2ff3096b..4be1e746 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -22,6 +22,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import CreateScreenModal from "@/components/screen/CreateScreenModal"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; // 단계별 진행을 위한 타입 정의 type Step = "list" | "design" | "template" | "v2-test"; @@ -39,6 +40,7 @@ export default function ScreenManagementPage() { const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isDetailOpen, setIsDetailOpen] = useState(false); const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]); @@ -110,6 +112,7 @@ export default function ScreenManagementPage() { // 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) const handleScreenSelect = (screen: ScreenDefinition) => { setSelectedScreen(screen); + setIsDetailOpen(true); setSelectedGroup(null); // 그룹 선택 해제 }; @@ -257,24 +260,6 @@ export default function ScreenManagementPage() { }} />
- {/* 선택 미리보기 */} - {selectedScreen && ( -
-
- - {selectedScreen.screenName} -
-
- {selectedScreen.screenCode} - {selectedScreen.tableName || "테이블 없음"} -
-
- -
-
- )}
{/* 오른쪽: 관계 시각화 (React Flow) */} @@ -320,6 +305,38 @@ export default function ScreenManagementPage() {
)} + {/* 화면 디테일 Sheet */} + + + + {selectedScreen?.screenName || "화면 상세"} + {selectedScreen?.screenCode} + + {selectedScreen && ( +
+
+
+ 테이블 + {selectedScreen.tableName || "없음"} +
+
+ 화면 ID + {selectedScreen.screenId} +
+
+
+ + +
+
+ )} +
+
+ {/* 화면 생성 모달 */} - - + + + + { + if (node.type === "screenNode") return "hsl(var(--primary))"; + if (node.type === "tableNode") return "hsl(var(--warning))"; + return "hsl(var(--muted-foreground))"; + }} + nodeStrokeWidth={2} + zoomable + pannable + style={{ + background: "hsl(var(--card) / 0.8)", + border: "1px solid hsl(var(--border) / 0.5)", + borderRadius: "8px", + marginBottom: "8px", + }} + /> {/* 관계 범례 */}

관계 유형

From c0be2f352823670458d416c98dc7e4fccd78b2cb Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 19:57:17 +0900 Subject: [PATCH 32/46] =?UTF-8?q?feat:=20=EC=A0=91=EB=8A=94=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=20=EA=B5=AC=ED=98=84=20(v5=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidebarCollapsed 상태 + 조건부 렌더링 - PanelLeftOpen/PanelLeftClose 아이콘 토글 - collapsed 시 아이콘 컬럼 표시 Made-with: Cursor --- .../admin/screenMng/screenMngList/page.tsx | 111 +++++++++++------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 4be1e746..0b74b2ee 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -41,6 +41,7 @@ export default function ScreenManagementPage() { const [searchTerm, setSearchTerm] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]); @@ -217,49 +218,75 @@ export default function ScreenManagementPage() { {/* 메인 콘텐츠 */} {viewMode === "flow" ? (
- {/* 왼쪽: 트리 구조 */} -
- {/* 검색 */} -
-
- - setSearchTerm(e.target.value)} - className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" - /> + {/* 왼쪽: 트리 구조 (접기/펼기 지원) */} +
+ {/* 사이드바 헤더 */} +
+ {!sidebarCollapsed && 탐색} + +
+ {/* 사이드바 접힘 시 아이콘 컬럼 */} + {sidebarCollapsed && ( +
+ +
+ {screens.length} +
-
- {/* 트리 뷰 */} -
- { - setSelectedGroup(group); - setSelectedScreen(null); // 화면 선택 해제 - setFocusedScreenIdInGroup(null); // 포커스 초기화 - }} - onScreenSelectInGroup={(group, screenId) => { - // 그룹 내 화면 클릭 시 - const isNewGroup = selectedGroup?.id !== group.id; - - if (isNewGroup) { - // 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지) - setSelectedGroup(group); - setFocusedScreenIdInGroup(null); - } else { - // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 - setFocusedScreenIdInGroup(screenId); - } - setSelectedScreen(null); - }} - /> -
+ )} + {/* 사이드바 펼침 시 전체 UI */} + {!sidebarCollapsed && ( + <> + {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" + /> +
+
+ {/* 트리 뷰 */} +
+ { + setSelectedGroup(group); + setSelectedScreen(null); + setFocusedScreenIdInGroup(null); + }} + onScreenSelectInGroup={(group, screenId) => { + const isNewGroup = selectedGroup?.id !== group.id; + if (isNewGroup) { + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); + }} + /> +
+ + )}
{/* 오른쪽: 관계 시각화 (React Flow) */} From 558acd1f9b14c9111ad307afca564187ac9fd8b4 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 20:09:41 +0900 Subject: [PATCH 33/46] [agent-pipeline] pipe-20260315110231-zn60 round-1 --- .../admin/screenMng/screenMngList/page.tsx | 41 ++-- frontend/components/screen/ScreenNode.tsx | 186 +++++++----------- 2 files changed, 101 insertions(+), 126 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 0b74b2ee..450e836a 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, Monitor, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; @@ -300,25 +300,42 @@ export default function ScreenManagementPage() {
) : (
-
+
{filteredScreens.map((screen) => (
handleScreenSelect(screen)} onDoubleClick={() => handleDesignScreen(screen)} > -
- -
-
-
{screen.screenName}
-
{screen.screenCode}
-
- {screen.tableName || "테이블 없음"} + {/* 상단: 상태 dot + 이름 + 호버 편집 */} +
+ +
+
{screen.screenName}
+
{screen.screenCode}
+ 편집 +
+ {/* 중단: 메타 정보 */} +
+ + + {screen.tableName || "—"} + +
+ {/* 하단: 타입 칩 + 날짜 */} +
+ + {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} + + + {screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} +
))} diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 119f6944..4e7bfbb5 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -22,8 +22,8 @@ if (typeof document !== "undefined") { style.id = styleId; style.textContent = ` @keyframes glow-pulse { - from { filter: drop-shadow(0 0 6px hsl(221.2 83.2% 53.3% / 0.4)) drop-shadow(0 0 14px hsl(221.2 83.2% 53.3% / 0.2)); } - to { filter: drop-shadow(0 0 10px hsl(221.2 83.2% 53.3% / 0.6)) drop-shadow(0 0 22px hsl(221.2 83.2% 53.3% / 0.3)); } + from { filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.25)) drop-shadow(0 0 10px hsl(var(--primary) / 0.12)); } + to { filter: drop-shadow(0 0 6px hsl(var(--primary) / 0.35)) drop-shadow(0 0 16px hsl(var(--primary) / 0.18)); } } `; document.head.appendChild(style); @@ -122,42 +122,14 @@ const getScreenTypeIcon = (screenType?: string) => { } }; -// 화면 타입별 색상 (헤더) - 그라데이션 -const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { - if (!isMain) return "bg-gradient-to-r from-muted-foreground to-muted-foreground/80"; - switch (screenType) { - case "grid": - return "bg-gradient-to-r from-primary to-primary/80"; - case "dashboard": - return "bg-gradient-to-r from-warning to-warning/80"; - case "action": - return "bg-gradient-to-r from-destructive to-destructive/80"; - default: - return "bg-gradient-to-r from-primary to-primary/80"; - } +// 화면 타입별 색상 (헤더) - 더 이상 그라데이션 미사용 +const getScreenTypeColor = (_screenType?: string, _isMain?: boolean) => { + return ""; }; -// 화면 역할(screenRole)에 따른 색상 - 그라데이션 -const getScreenRoleColor = (screenRole?: string) => { - if (!screenRole) return "bg-gradient-to-r from-muted-foreground to-muted-foreground/80"; - - // 역할명에 포함된 키워드로 색상 결정 - const role = screenRole.toLowerCase(); - - if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) { - return "bg-gradient-to-r from-primary to-primary/80"; // 메인 그리드 - } - if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) { - return "bg-gradient-to-r from-primary to-primary/80"; // 등록 폼 - } - if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) { - return "bg-gradient-to-r from-destructive to-destructive/80"; // 액션/이벤트 - } - if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) { - return "bg-gradient-to-r from-warning to-warning/80"; // 상세/팝업 - } - - return "bg-gradient-to-r from-muted-foreground to-muted-foreground/80"; // 기본 회색 +// 화면 역할(screenRole)에 따른 색상 - 더 이상 그라데이션 미사용 +const getScreenRoleColor = (_screenRole?: string) => { + return ""; }; // 화면 타입별 라벨 @@ -176,31 +148,17 @@ const getScreenTypeLabel = (screenType?: string) => { // ========== 화면 노드 (상단) - 미리보기 표시 ========== export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { - const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data; + const { label, isMain, tableName, layoutSummary, isFocused, isFaded } = data; const screenType = layoutSummary?.screenType || "form"; - - // 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상 - // isFocused일 때 색상 활성화, isFaded일 때 회색 - let headerColor: string; - if (isInGroup) { - if (isFaded) { - headerColor = "bg-gradient-to-r from-muted to-muted/60"; // 흑백 처리 - 더 확실한 회색 - } else { - // 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상 - headerColor = getScreenRoleColor(screenRole); - } - } else { - headerColor = getScreenTypeColor(screenType, isMain); - } return (
= ({ data }) => { type="target" position={Position.Left} id="left" - className="!h-2.5 !w-2.5 !border-2 !border-background !bg-primary opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]" /> - {/* 헤더 (컬러) */} -
-
+ {/* 헤더: 그라디언트 제거, 모노크롬 */} +
+
-
{label}
- {tableName &&
{tableName}
} +
{label}
+ {tableName &&
{tableName}
}
- {(isMain || isFocused) && } + {(isMain || isFocused) && }
{/* 화면 미리보기 영역 (컴팩트) */} -
+
{layoutSummary ? ( ) : ( -
+
{getScreenTypeIcon(screenType)} 화면: {label}
)}
- {/* 푸터 (타입 + 컴포넌트 수) */} -
- {getScreenTypeLabel(screenType)} + {/* 푸터 (타입 칩 + 컴포넌트 수) */} +
+ {getScreenTypeLabel(screenType)} {layoutSummary?.totalComponents ?? 0}개 컴포넌트
@@ -306,114 +264,114 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: }) => { const { totalComponents, widgetCounts } = layoutSummary; - // 그리드 화면 일러스트 + // 그리드 화면 일러스트 (모노크롬) if (screenType === "grid") { - return ( -
+ return ( +
{/* 상단 툴바 */}
-
+
-
-
-
+
+
+
{/* 테이블 헤더 */} -
+
{[...Array(5)].map((_, i) => ( -
+
))}
{/* 테이블 행들 */}
{[...Array(7)].map((_, i) => ( -
+
{[...Array(5)].map((_, j) => ( -
+
))}
))}
{/* 페이지네이션 */}
-
-
-
-
+
+
+
+
); } - // 폼 화면 일러스트 + // 폼 화면 일러스트 (모노크롬) if (screenType === "form") { return ( -
+
{/* 폼 필드들 */} {[...Array(6)].map((_, i) => (
-
-
+
+
))} {/* 버튼 영역 */} -
-
-
+
+
+
); } - // 대시보드 화면 일러스트 + // 대시보드 화면 일러스트 (모노크롬) if (screenType === "dashboard") { return ( -
+
{/* 카드/차트들 */} -
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
{[...Array(10)].map((_, i) => (
))}
-
+
); } - // 액션 화면 일러스트 (버튼 중심) + // 액션 화면 일러스트 (모노크롬) if (screenType === "action") { return ( -
-
+
+
-
+
-
-
-
+
+
+
액션 화면
); } - // 기본 (알 수 없는 타입) + // 기본 (알 수 없는 타입, 모노크롬) return ( -
-
+
+
{getScreenTypeIcon(screenType)}
{totalComponents}개 컴포넌트 From 3ef8cebf1acb461158b620f3eb92deefef3985fb Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 20:14:51 +0900 Subject: [PATCH 34/46] [agent-pipeline] pipe-20260315110231-zn60 round-2 --- frontend/components/screen/ScreenNode.tsx | 102 ++++++++++++---------- 1 file changed, 56 insertions(+), 46 deletions(-) diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 4e7bfbb5..ad124b94 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -506,21 +506,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
= ({ data }) => { type="target" position={Position.Top} id="top" - className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> {/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */} = ({ data }) => { position={Position.Top} id="top_source" style={{ top: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> {/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */} = ({ data }) => { position={Position.Bottom} id="bottom_target" style={{ bottom: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> - {/* 헤더 (필터 관계: primary, 필터 소스: primary, 메인: primary, 기본: muted-foreground) - 그라데이션 */} -
- + {/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */} +
+
+ +
-
{label}
+
{label}
{/* 필터 관계에 따른 문구 변경 */} -
+
{isFilterSource ? "마스터 테이블 (필터 소스)" : hasFilterRelation @@ -608,8 +602,8 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{hasActiveColumns && ( - - {displayColumns.length}개 활성 + + {displayColumns.length} ref )}
@@ -697,18 +691,22 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { opacity: hasActiveColumns ? 0 : 1, }} > - {/* PK/FK/조인/필터 아이콘 */} - {isJoinColumn && } - {(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey &&
} + {/* 3px 세로 마커 (PK/FK/조인/필터) */} +
{/* 컬럼명 */} {col.name} @@ -749,7 +747,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )} {/* 타입 */} - {col.type} + {col.type}
); })} @@ -768,13 +766,25 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )}
- {/* 푸터 (컴팩트) */} -
- {columns && ( - - {hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}개 컬럼 - - )} + {/* 푸터: cols + PK/FK 카운트 */} +
+ + {hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols + +
+ {columns?.some(c => c.isPrimaryKey) && ( + + + PK {columns.filter(c => c.isPrimaryKey).length} + + )} + {columns?.some(c => c.isForeignKey) && ( + + + FK {columns.filter(c => c.isForeignKey).length} + + )} +
{/* CSS 애니메이션 정의 */} From 009607f3f11b48dd909b512dd23dac253b697e2a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 21:18:08 +0900 Subject: [PATCH 35/46] [agent-pipeline] pipe-20260315121506-3c5c round-1 --- .../components/screen/ScreenRelationFlow.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 24ce0ac1..9f75874a 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -47,6 +47,9 @@ const RELATION_COLORS: Record(null); + // 엣지 필터 상태 (유형별 표시/숨김) + const [edgeFilterState, setEdgeFilterState] = useState>({ + main: true, + filter: true, + join: true, + lookup: false, + flow: true, + }); + // 노드 설정 모달 상태 const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); const [settingModalNode, setSettingModalNode] = useState<{ @@ -702,6 +714,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" }, animated: true, style: { stroke: "hsl(var(--info))", strokeWidth: 2 }, + data: { edgeCategory: 'flow' as EdgeCategory }, }); } } @@ -722,6 +735,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId stroke: "hsl(var(--primary))", strokeWidth: 2, }, + data: { edgeCategory: 'main' as EdgeCategory }, }); } }); @@ -764,6 +778,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }, data: { sourceScreenId, + edgeCategory: 'filter' as EdgeCategory, }, }); @@ -816,6 +831,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId sourceScreenId, isFilterJoin: true, visualRelationType: 'join', + edgeCategory: 'join' as EdgeCategory, }, }); }); @@ -926,6 +942,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId referrerTable, referencedTable, visualRelationType, // 관계 유형 저장 + edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory, }, }); } @@ -966,6 +983,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId data: { sourceScreenId, visualRelationType, + edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory, }, }); }); @@ -992,7 +1010,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId strokeDasharray: "8,4", opacity: 0.5, }, - data: { visualRelationType: 'join' }, + data: { visualRelationType: 'join', edgeCategory: 'join' as EdgeCategory }, }); } }); @@ -1018,6 +1036,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, labelBgPadding: [3, 2] as [number, number], style: { stroke: "hsl(var(--success))", strokeWidth: 1.5 }, + data: { edgeCategory: (rel.relation_type === 'lookup' ? 'lookup' : 'join') as EdgeCategory }, }); } } @@ -1042,6 +1061,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId labelBgPadding: [4, 2] as [number, number], markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" }, style: { stroke: "hsl(var(--primary))", strokeWidth: 2 }, + data: { edgeCategory: 'flow' as EdgeCategory }, }); } }); From 8ed7faf5177f6c35b900d09d16eabb379310cef4 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 21:22:36 +0900 Subject: [PATCH 36/46] [agent-pipeline] pipe-20260315121506-3c5c round-2 --- .../components/screen/ScreenRelationFlow.tsx | 100 +++++++++++++----- 1 file changed, 73 insertions(+), 27 deletions(-) diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 9f75874a..09c980df 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -1488,6 +1488,32 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }); } + // lookup 필터 OFF일 때: lookup 연결만 있는 테이블 노드를 dim 처리 + const lookupOnlyNodes = new Set(); + if (!edgeFilterState.lookup) { + const nodeEdgeCategories = new Map>(); + edges.forEach((edge) => { + const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined; + if (!category) return; + [edge.source, edge.target].forEach((nodeId) => { + if (!nodeEdgeCategories.has(nodeId)) { + nodeEdgeCategories.set(nodeId, new Set()); + } + nodeEdgeCategories.get(nodeId)!.add(category); + }); + }); + nodeEdgeCategories.forEach((categories, nodeId) => { + if (nodeId.startsWith("table-") || nodeId.startsWith("subtable-")) { + const hasVisibleCategory = Array.from(categories).some( + (cat) => cat !== "lookup" && edgeFilterState[cat] + ); + if (!hasVisibleCategory) { + lookupOnlyNodes.add(nodeId); + } + } + }); + } + return nodes.map((node) => { // 화면 노드 스타일링 (포커스가 있을 때만) if (node.id.startsWith("screen-")) { @@ -1783,7 +1809,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId ...node.data, isFocused: isFocusedTable, isRelated: isRelatedTable, - isFaded: focusedScreenId !== null && !isActiveTable, + isFaded: (focusedScreenId !== null && !isActiveTable) || lookupOnlyNodes.has(node.id), highlightedColumns: isActiveTable ? highlightedColumns : [], joinColumns: isActiveTable ? joinColumns : [], joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보 @@ -1894,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId data: { ...node.data, isFocused: isActiveSubTable, - isFaded: !isActiveSubTable, + isFaded: !isActiveSubTable || lookupOnlyNodes.has(node.id), highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [], joinColumns: isActiveSubTable ? subTableJoinColumns : [], fieldMappings: isActiveSubTable ? displayFieldMappings : [], @@ -1905,7 +1931,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId return node; }); - }, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]); + }, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns, edgeFilterState, edges]); // 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드) const styledEdges = React.useMemo(() => { @@ -2304,8 +2330,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }); // 기존 엣지 + 조인 관계 엣지 합치기 - return [...styledOriginalEdges, ...joinEdges]; - }, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]); + const allEdges = [...styledOriginalEdges, ...joinEdges]; + // 엣지 필터 적용 (edgeFilterState에 따라 숨김) + return allEdges.map((edge) => { + const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined; + if (category && !edgeFilterState[category]) { + return { + ...edge, + hidden: true, + }; + } + return edge; + }); + }, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap, edgeFilterState]); // 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함 const groupScreensList = React.useMemo(() => { @@ -2385,6 +2422,37 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId {screen.screenCode} )} + +
+ 연결 + + {( + [ + { key: "main" as EdgeCategory, label: "메인", color: "bg-primary", defaultOn: true }, + { key: "filter" as EdgeCategory, label: "마스터-디테일", color: "bg-[hsl(var(--info))]", defaultOn: true }, + { key: "join" as EdgeCategory, label: "엔티티 조인", color: "bg-amber-400", defaultOn: true }, + { key: "lookup" as EdgeCategory, label: "코드 참조", color: "bg-warning", defaultOn: false }, + ] as const + ).map(({ key, label, color, defaultOn }) => { + const isOn = edgeFilterState[key]; + const count = edges.filter((e) => (e.data as any)?.edgeCategory === key).length; + return ( + + ); + })}
)} {/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */} @@ -2434,28 +2502,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId marginBottom: "8px", }} /> - {/* 관계 범례 */} -
-

관계 유형

-
-
-
- 메인 테이블 -
-
-
- 마스터-디테일 -
-
-
- 코드 참조 -
-
-
- 엔티티 조인 -
-
-
From 232650bc0774890ba6343a455b88c45580e68228 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 22:19:35 +0900 Subject: [PATCH 37/46] [agent-pipeline] pipe-20260315131310-l8kw round-1 --- .../src/controllers/screenGroupController.ts | 113 ++++++++++++++++ frontend/components/screen/ScreenNode.tsx | 124 +++++++++--------- 2 files changed, 175 insertions(+), 62 deletions(-) diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index f14f6532..c7c6023c 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2058,6 +2058,119 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons }); }); + // 6. v2-repeater 컴포넌트에서 selectedTable/foreignKey 추출 + const v2RepeaterQuery = ` + SELECT DISTINCT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + comp->'overrides'->>'type' as component_type, + comp->'overrides'->>'selectedTable' as sub_table, + comp->'overrides'->>'foreignKey' as foreign_key, + comp->'overrides'->>'parentTable' as parent_table + FROM screen_definitions sd + JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, + jsonb_array_elements(slv2.layout_data->'components') as comp + WHERE sd.screen_id = ANY($1) + AND comp->'overrides'->>'type' = 'v2-repeater' + AND comp->'overrides'->>'selectedTable' IS NOT NULL + `; + const v2RepeaterResult = await pool.query(v2RepeaterQuery, [screenIds]); + v2RepeaterResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const subTable = row.sub_table; + const foreignKey = row.foreign_key; + if (!subTable || subTable === mainTable) return; + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === subTable + ); + if (!exists) { + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: 'v2-repeater', + relationType: 'rightPanelRelation', + fieldMappings: foreignKey ? [{ + sourceField: 'id', + targetField: foreignKey, + sourceDisplayName: 'ID', + targetDisplayName: foreignKey, + }] : undefined, + }); + } + }); + logger.info("v2-repeater 서브 테이블 추출 완료", { + screenIds, + v2RepeaterCount: v2RepeaterResult.rows.length, + }); + + // 7. rightPanel.components 내부의 componentConfig.detailTable 추출 (v2-bom-tree 등) + const v2DetailTableQuery = ` + SELECT DISTINCT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + inner_comp->>'type' as component_type, + inner_comp->'componentConfig'->>'detailTable' as sub_table, + inner_comp->'componentConfig'->>'foreignKey' as foreign_key + FROM screen_definitions sd + JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, + jsonb_array_elements(slv2.layout_data->'components') as comp, + jsonb_array_elements( + COALESCE( + comp->'overrides'->'rightPanel'->'components', + comp->'overrides'->'leftPanel'->'components', + '[]'::jsonb + ) + ) as inner_comp + WHERE sd.screen_id = ANY($1) + AND inner_comp->'componentConfig'->>'detailTable' IS NOT NULL + `; + const v2DetailTableResult = await pool.query(v2DetailTableQuery, [screenIds]); + v2DetailTableResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const subTable = row.sub_table; + const foreignKey = row.foreign_key; + if (!subTable || subTable === mainTable) return; + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === subTable + ); + if (!exists) { + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: row.component_type || 'v2-bom-tree', + relationType: 'rightPanelRelation', + fieldMappings: foreignKey ? [{ + sourceField: 'id', + targetField: foreignKey, + sourceDisplayName: 'ID', + targetDisplayName: foreignKey, + }] : undefined, + }); + } + }); + logger.info("v2-bom-tree/detailTable 서브 테이블 추출 완료", { + screenIds, + v2DetailTableCount: v2DetailTableResult.rows.length, + }); + // ============================================================ // 저장 테이블 정보 추출 // ============================================================ diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index ad124b94..1e763735 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -153,12 +153,12 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { return (
= ({ data }) => { type="target" position={Position.Left} id="left" - className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]" /> {/* 헤더: 그라디언트 제거, 모노크롬 */} -
+
@@ -199,7 +199,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
{label}
{tableName &&
{tableName}
}
- {(isMain || isFocused) && } + {(isMain || isFocused) && }
{/* 화면 미리보기 영역 (컴팩트) */} @@ -207,7 +207,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { {layoutSummary ? ( ) : ( -
+
{getScreenTypeIcon(screenType)} 화면: {label}
@@ -215,7 +215,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
{/* 푸터 (타입 칩 + 컴포넌트 수) */} -
+
{getScreenTypeLabel(screenType)} {layoutSummary?.totalComponents ?? 0}개 컴포넌트
@@ -267,37 +267,37 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // 그리드 화면 일러스트 (모노크롬) if (screenType === "grid") { return ( -
+
{/* 상단 툴바 */}
-
+
-
-
-
+
+
+
{/* 테이블 헤더 */} -
+
{[...Array(5)].map((_, i) => ( -
+
))}
{/* 테이블 행들 */}
{[...Array(7)].map((_, i) => ( -
+
{[...Array(5)].map((_, j) => ( -
+
))}
))}
{/* 페이지네이션 */}
-
-
-
-
+
+
+
+
); @@ -306,18 +306,18 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // 폼 화면 일러스트 (모노크롬) if (screenType === "form") { return ( -
+
{/* 폼 필드들 */} {[...Array(6)].map((_, i) => (
-
-
+
+
))} {/* 버튼 영역 */} -
-
-
+
+
+
); @@ -326,23 +326,23 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // 대시보드 화면 일러스트 (모노크롬) if (screenType === "dashboard") { return ( -
+
{/* 카드/차트들 */} -
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
{[...Array(10)].map((_, i) => (
))} @@ -355,13 +355,13 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // 액션 화면 일러스트 (모노크롬) if (screenType === "action") { return ( -
-
+
+
-
-
+
+
액션 화면
@@ -370,8 +370,8 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // 기본 (알 수 없는 타입, 모노크롬) return ( -
-
+
+
{getScreenTypeIcon(screenType)}
{totalComponents}개 컴포넌트 @@ -506,7 +506,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
= ({ data }) => { ? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)] bg-card" // 4. 흐리게 처리 : isFaded - ? "opacity-60 bg-card border-border/10" + ? "opacity-60 bg-card border-border/40 dark:border-border/10" // 5. 기본 - : "border-border/10 hover:border-border/20" + : "border-border/40 dark:border-border/10 hover:border-border/50 dark:hover:border-border/20" }`} style={{ filter: isFaded ? "grayscale(80%)" : "none", @@ -548,7 +548,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="target" position={Position.Top} id="top" - className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> {/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */} = ({ data }) => { position={Position.Top} id="top_source" style={{ top: -4 }} - className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> {/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */} = ({ data }) => { position={Position.Bottom} id="bottom_target" style={{ bottom: -4 }} - className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> {/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */} -
+
{label}
{/* 필터 관계에 따른 문구 변경 */} -
+
{isFilterSource ? "마스터 테이블 (필터 소스)" : hasFilterRelation @@ -602,7 +602,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{hasActiveColumns && ( - + {displayColumns.length} ref )} @@ -747,7 +747,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )} {/* 타입 */} - {col.type} + {col.type}
); })} @@ -767,21 +767,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 푸터: cols + PK/FK 카운트 */} -
- +
+ {hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols
{columns?.some(c => c.isPrimaryKey) && ( - PK {columns.filter(c => c.isPrimaryKey).length} + PK {columns.filter(c => c.isPrimaryKey).length} )} {columns?.some(c => c.isForeignKey) && ( - FK {columns.filter(c => c.isForeignKey).length} + FK {columns.filter(c => c.isForeignKey).length} )}
From 015706b95ac67e25a1e1455f5cdbf0de5dbab23c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 22:29:56 +0900 Subject: [PATCH 38/46] [agent-pipeline] pipe-20260315131310-l8kw round-2 --- .../admin/screenMng/screenMngList/page.tsx | 86 +++++++++++++------ .../components/screen/ScreenRelationFlow.tsx | 16 ++-- 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 450e836a..e6bbf480 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; @@ -300,42 +300,76 @@ export default function ScreenManagementPage() {
) : (
+ {/* 카드 뷰 상단: 검색 + 카운트 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9 rounded-xl bg-muted/30 dark:bg-muted/30 border-border/50 dark:border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" + /> + {searchTerm && ( + + )} +
+ {filteredScreens.length}개 화면 +
{filteredScreens.map((screen) => (
handleScreenSelect(screen)} onDoubleClick={() => handleDesignScreen(screen)} > - {/* 상단: 상태 dot + 이름 + 호버 편집 */} -
- -
-
{screen.screenName}
-
{screen.screenCode}
+ {/* 좌측 타입별 컬러 바 */} +
+
+ {/* 상단: 이름 + 호버 편집 */} +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+ 편집 +
+ {/* 설명 (있으면) */} + {screen.description && ( +
{screen.description}
+ )} + {/* 중단: 메타 정보 */} +
+ + + {screen.tableLabel || screen.tableName || "—"} + +
+ {/* 하단: 타입 칩 + 날짜 */} +
+ + {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} + + + {screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} +
- 편집 -
- {/* 중단: 메타 정보 */} -
- - - {screen.tableName || "—"} - -
- {/* 하단: 타입 칩 + 날짜 */} -
- - {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} - - - {screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} -
))} diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 09c980df..87484840 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -2408,7 +2408,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
{/* 선택 정보 바 (캔버스 상단) */} {(screen || selectedGroup) && ( -
+
{selectedGroup && ( <> @@ -2419,12 +2419,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId <> {screen.screenName} - {screen.screenCode} + {screen.screenCode} )} -
- 연결 +
+ 연결 {( [ @@ -2443,13 +2443,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId onClick={() => setEdgeFilterState((prev) => ({ ...prev, [key]: !prev[key] }))} className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium transition-all duration-200 ${ isOn - ? "bg-foreground/5 border border-border/20 text-foreground/80" - : `border text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/20" : "border-border/10"}` + ? "bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/20 text-foreground/80" + : `border text-muted-foreground/70 dark:text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/40 dark:border-border/20" : "border-border/40 dark:border-border/10"}` }`} > - + {label} - {count} + {count} ); })} From fe3c6d3bce61b1dde2af40f4400a587d54ef9670 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sun, 15 Mar 2026 22:29:56 +0900 Subject: [PATCH 39/46] [agent-pipeline] rollback to 232650bc --- .../admin/screenMng/screenMngList/page.tsx | 86 ++++++------------- .../components/screen/ScreenRelationFlow.tsx | 16 ++-- 2 files changed, 34 insertions(+), 68 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index e6bbf480..450e836a 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; @@ -300,76 +300,42 @@ export default function ScreenManagementPage() {
) : (
- {/* 카드 뷰 상단: 검색 + 카운트 */} -
-
- - setSearchTerm(e.target.value)} - className="pl-9 h-9 rounded-xl bg-muted/30 dark:bg-muted/30 border-border/50 dark:border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" - /> - {searchTerm && ( - - )} -
- {filteredScreens.length}개 화면 -
{filteredScreens.map((screen) => (
handleScreenSelect(screen)} onDoubleClick={() => handleDesignScreen(screen)} > - {/* 좌측 타입별 컬러 바 */} -
-
- {/* 상단: 이름 + 호버 편집 */} -
-
-
{screen.screenName}
-
{screen.screenCode}
-
- 편집 -
- {/* 설명 (있으면) */} - {screen.description && ( -
{screen.description}
- )} - {/* 중단: 메타 정보 */} -
- - - {screen.tableLabel || screen.tableName || "—"} - -
- {/* 하단: 타입 칩 + 날짜 */} -
- - {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} - - - {screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} - + {/* 상단: 상태 dot + 이름 + 호버 편집 */} +
+ +
+
{screen.screenName}
+
{screen.screenCode}
+ 편집 +
+ {/* 중단: 메타 정보 */} +
+ + + {screen.tableName || "—"} + +
+ {/* 하단: 타입 칩 + 날짜 */} +
+ + {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} + + + {screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} +
))} diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 87484840..09c980df 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -2408,7 +2408,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
{/* 선택 정보 바 (캔버스 상단) */} {(screen || selectedGroup) && ( -
+
{selectedGroup && ( <> @@ -2419,12 +2419,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId <> {screen.screenName} - {screen.screenCode} + {screen.screenCode} )} -
- 연결 +
+ 연결 {( [ @@ -2443,13 +2443,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId onClick={() => setEdgeFilterState((prev) => ({ ...prev, [key]: !prev[key] }))} className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium transition-all duration-200 ${ isOn - ? "bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/20 text-foreground/80" - : `border text-muted-foreground/70 dark:text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/40 dark:border-border/20" : "border-border/40 dark:border-border/10"}` + ? "bg-foreground/5 border border-border/20 text-foreground/80" + : `border text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/20" : "border-border/10"}` }`} > - + {label} - {count} + {count} ); })} From cbd47184e7b2eac9ebe284726d8f98713a45c6a5 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 09:17:52 +0900 Subject: [PATCH 40/46] Enhance Screen Management UI - Updated the search input to include a clear button for easier user interaction. - Improved the layout with a muted background for better visibility. - Enhanced screen card display with dynamic type color and glow effects based on screen type. - Adjusted text colors for better contrast and readability in dark mode. - Refined connection indicators and button styles for improved UX. Made-with: Cursor --- .../admin/screenMng/screenMngList/page.tsx | 86 +++++++++++++------ .../components/screen/ScreenRelationFlow.tsx | 16 ++-- 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 450e836a..e6bbf480 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; @@ -300,42 +300,76 @@ export default function ScreenManagementPage() {
) : (
+ {/* 카드 뷰 상단: 검색 + 카운트 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9 rounded-xl bg-muted/30 dark:bg-muted/30 border-border/50 dark:border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" + /> + {searchTerm && ( + + )} +
+ {filteredScreens.length}개 화면 +
{filteredScreens.map((screen) => (
handleScreenSelect(screen)} onDoubleClick={() => handleDesignScreen(screen)} > - {/* 상단: 상태 dot + 이름 + 호버 편집 */} -
- -
-
{screen.screenName}
-
{screen.screenCode}
+ {/* 좌측 타입별 컬러 바 */} +
+
+ {/* 상단: 이름 + 호버 편집 */} +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+ 편집 +
+ {/* 설명 (있으면) */} + {screen.description && ( +
{screen.description}
+ )} + {/* 중단: 메타 정보 */} +
+ + + {screen.tableLabel || screen.tableName || "—"} + +
+ {/* 하단: 타입 칩 + 날짜 */} +
+ + {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} + + + {screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} +
- 편집 -
- {/* 중단: 메타 정보 */} -
- - - {screen.tableName || "—"} - -
- {/* 하단: 타입 칩 + 날짜 */} -
- - {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} - - - {screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} -
))} diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 09c980df..87484840 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -2408,7 +2408,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
{/* 선택 정보 바 (캔버스 상단) */} {(screen || selectedGroup) && ( -
+
{selectedGroup && ( <> @@ -2419,12 +2419,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId <> {screen.screenName} - {screen.screenCode} + {screen.screenCode} )} -
- 연결 +
+ 연결 {( [ @@ -2443,13 +2443,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId onClick={() => setEdgeFilterState((prev) => ({ ...prev, [key]: !prev[key] }))} className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium transition-all duration-200 ${ isOn - ? "bg-foreground/5 border border-border/20 text-foreground/80" - : `border text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/20" : "border-border/10"}` + ? "bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/20 text-foreground/80" + : `border text-muted-foreground/70 dark:text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/40 dark:border-border/20" : "border-border/40 dark:border-border/10"}` }`} > - + {label} - {count} + {count} ); })} From 6395f4d032dc3fa9d4b8fe3b8f1c150d8e6d300b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 09:17:59 +0900 Subject: [PATCH 41/46] Implement Card Pulse Animation and UI Enhancements - Added a new pulse animation for screen cards to enhance visual feedback. - Updated the background of the screen management list for improved aesthetics. - Refined the search input styling for better integration with the overall UI. - Enhanced screen card hover effects with dynamic glow based on screen type. - Adjusted layout spacing for a more consistent user experience. Made-with: Cursor --- .../admin/screenMng/screenMngList/page.tsx | 121 +++++++++++------- frontend/app/globals.css | 15 +++ 2 files changed, 87 insertions(+), 49 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index e6bbf480..2978e025 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -299,16 +299,16 @@ export default function ScreenManagementPage() {
) : ( -
+
{/* 카드 뷰 상단: 검색 + 카운트 */} -
+
setSearchTerm(e.target.value)} - className="pl-9 h-9 rounded-xl bg-muted/30 dark:bg-muted/30 border-border/50 dark:border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" + className="pl-9 h-9 rounded-xl bg-card dark:bg-card border-border/50 shadow-sm focus:bg-card focus:ring-2 focus:ring-primary/30 transition-colors" /> {searchTerm && (
- {filteredScreens.map((screen) => ( -
handleScreenSelect(screen)} - onDoubleClick={() => handleDesignScreen(screen)} - > - {/* 좌측 타입별 컬러 바 */} -
-
- {/* 상단: 이름 + 호버 편집 */} -
-
-
{screen.screenName}
-
{screen.screenCode}
-
- 편집 -
- {/* 설명 (있으면) */} - {screen.description && ( -
{screen.description}
+ {filteredScreens.map((screen) => { + const screenType = (screen as { screenType?: string }).screenType || "form"; + const isSelected = selectedScreen?.screenId === screen.screenId; + const isRecentlyModified = screen.updatedDate && (Date.now() - new Date(screen.updatedDate).getTime()) < 7 * 24 * 60 * 60 * 1000; + + const typeColorClass = screenType === "grid" + ? "from-primary to-primary/20" + : screenType === "dashboard" + ? "from-warning to-warning/20" + : "from-success to-success/20"; + + const glowClass = screenType === "grid" + ? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--primary)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--primary)/0.15)]" + : screenType === "dashboard" + ? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--warning)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--warning)/0.12)]" + : "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--success)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--success)/0.12)]"; + + const badgeBgClass = screenType === "grid" + ? "bg-primary/8 dark:bg-primary/15 text-primary" + : screenType === "dashboard" + ? "bg-warning/8 dark:bg-warning/15 text-warning" + : "bg-success/8 dark:bg-success/15 text-success"; + + return ( +
handleScreenSelect(screen)} + onDoubleClick={() => handleDesignScreen(screen)} + > + {/* 좌측 그라데이션 액센트 바 */} +
+ {isSelected && ( +
)} - {/* 중단: 메타 정보 */} -
- - - {screen.tableLabel || screen.tableName || "—"} - -
- {/* 하단: 타입 칩 + 날짜 */} -
- - {(screen as { screenType?: string }).screenType === "grid" ? "그리드" : (screen as { screenType?: string }).screenType === "dashboard" ? "대시보드" : "폼"} - - - {screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { month: "2-digit", day: "2-digit" }) : ""} - +
+ {/* Row 1: 이름 + 타입 뱃지 */} +
+
{screen.screenName}
+ + {screenType === "grid" ? "그리드" : screenType === "dashboard" ? "대시보드" : "폼"} + +
+ {/* Row 2: 스크린 코드 */} +
{screen.screenCode}
+ {/* Row 3: 테이블 칩 + 메타 */} +
+ + + {screen.tableLabel || screen.tableName || "—"} + +
+ {/* Row 4: 날짜 + 수정 상태 */} +
+ + {screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : ""} + + {isRecentlyModified && ( + + + 수정됨 + + )} +
-
- ))} + ); + })}
{filteredScreens.length === 0 && (
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b3dbab89..8bbfd108 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -427,6 +427,21 @@ select { border-spacing: 0 !important; } +/* ===== 카드 펄스 도트 애니메이션 ===== */ +@keyframes screen-card-pulse { + 0%, 100% { opacity: 0; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(2); } +} +.screen-card-pulse-dot::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + background: hsl(var(--success)); + opacity: 0; + animation: screen-card-pulse 2.5s ease-in-out infinite; +} + /* ===== 저장 테이블 막대기 애니메이션 ===== */ @keyframes saveBarDrop { 0% { From 3225a7bb21db8b3f7a81961baf46f75fa87c5131 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Mon, 16 Mar 2026 10:38:12 +0900 Subject: [PATCH 42/46] 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 6505df855555f1ecf3d299a1954ed2ea7604342d Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 16 Mar 2026 10:40:10 +0900 Subject: [PATCH 43/46] feat: enhance v2-timeline-scheduler component functionality - Updated the v2-timeline-scheduler documentation to reflect the latest implementation status and enhancements. - Improved the TimelineSchedulerComponent by integrating conflict detection and milestone rendering features. - Refactored ResourceRow and ScheduleBar components to support new props for handling conflicts and milestones. - Added visual indicators for conflicts and milestones to enhance user experience and clarity in scheduling. These changes aim to improve the functionality and usability of the timeline scheduler within the ERP system. Made-with: Cursor --- .../next-component-development-plan.md | 14 +- .../TimelineSchedulerComponent.tsx | 293 +++++++++++------- .../components/ResourceRow.tsx | 98 +++--- .../components/ScheduleBar.tsx | 198 ++++++++---- .../components/TimelineLegend.tsx | 55 ++++ .../v2-timeline-scheduler/components/index.ts | 1 + .../utils/conflictDetection.ts | 58 ++++ 7 files changed, 493 insertions(+), 224 deletions(-) create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts diff --git a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md index 58c8cd3f..84f6c789 100644 --- a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md +++ b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md @@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] 레지스트리 등록 - [x] 문서화 (README.md) -#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30) +#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13) - [x] 타입 정의 완료 - [x] 기본 구조 생성 @@ -539,12 +539,16 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] TimelineGrid (배경) - [x] ResourceColumn (리소스) - [x] ScheduleBar 기본 렌더링 -- [x] 드래그 이동 (기본) -- [x] 리사이즈 (기본) +- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast) +- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast) - [x] 줌 레벨 전환 - [x] 날짜 네비게이션 -- [ ] 충돌 감지 (향후) -- [ ] 가상 스크롤 (향후) +- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle) +- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커) +- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌) +- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴) +- [x] staticFilters 지원 (커스텀 테이블 필터링) +- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스) - [x] 설정 패널 구현 - [x] API 연동 - [x] 레지스트리 등록 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index e7da45a6..354869bc 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import { ChevronLeft, ChevronRight, @@ -11,17 +11,16 @@ import { ZoomOut, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, - DragEvent, - ResizeEvent, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; -import { TimelineHeader, ResourceRow } from "./components"; +import { TimelineHeader, ResourceRow, TimelineLegend } from "./components"; import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config"; +import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection"; /** * v2-timeline-scheduler 메인 컴포넌트 @@ -45,19 +44,6 @@ export function TimelineSchedulerComponent({ }: TimelineSchedulerComponentProps) { const containerRef = useRef(null); - // 드래그/리사이즈 상태 - const [dragState, setDragState] = useState<{ - schedule: ScheduleItem; - startX: number; - startY: number; - } | null>(null); - - const [resizeState, setResizeState] = useState<{ - schedule: ScheduleItem; - direction: "start" | "end"; - startX: number; - } | null>(null); - // 타임라인 데이터 훅 const { schedules, @@ -78,53 +64,43 @@ export function TimelineSchedulerComponent({ const error = externalError ?? hookError; // 설정값 - const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; - const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; + const rowHeight = + config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; + const headerHeight = + config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; const resourceColumnWidth = - config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!; - const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!; + config.resourceColumnWidth || + defaultTimelineSchedulerConfig.resourceColumnWidth!; + const cellWidthConfig = + config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!; const cellWidth = cellWidthConfig[zoomLevel] || 60; - // 리소스가 없으면 스케줄의 resourceId로 자동 생성 + // 리소스 자동 생성 (리소스 테이블 미설정 시 스케줄 데이터에서 추출) const effectiveResources = useMemo(() => { - if (resources.length > 0) { - return resources; - } + if (resources.length > 0) return resources; - // 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성 const uniqueResourceIds = new Set(); - schedules.forEach((schedule) => { - if (schedule.resourceId) { - uniqueResourceIds.add(schedule.resourceId); - } + schedules.forEach((s) => { + if (s.resourceId) uniqueResourceIds.add(s.resourceId); }); - return Array.from(uniqueResourceIds).map((id) => ({ - id, - name: id, // resourceId를 이름으로 사용 - })); + return Array.from(uniqueResourceIds).map((id) => ({ id, name: id })); }, [resources, schedules]); // 리소스별 스케줄 그룹화 const schedulesByResource = useMemo(() => { const grouped = new Map(); - effectiveResources.forEach((resource) => { - grouped.set(resource.id, []); - }); + effectiveResources.forEach((r) => grouped.set(r.id, [])); schedules.forEach((schedule) => { const list = grouped.get(schedule.resourceId); if (list) { list.push(schedule); } else { - // 리소스가 없는 스케줄은 첫 번째 리소스에 할당 const firstResource = effectiveResources[0]; if (firstResource) { - const firstList = grouped.get(firstResource.id); - if (firstList) { - firstList.push(schedule); - } + grouped.get(firstResource.id)?.push(schedule); } } }); @@ -132,27 +108,31 @@ export function TimelineSchedulerComponent({ return grouped; }, [schedules, effectiveResources]); - // 줌 레벨 변경 + // ────────── 충돌 감지 ────────── + const conflictIds = useMemo(() => { + if (config.showConflicts === false) return new Set(); + return detectConflicts(schedules); + }, [schedules, config.showConflicts]); + + // ────────── 줌 레벨 변경 ────────── const handleZoomIn = useCallback(() => { const levels: ZoomLevel[] = ["month", "week", "day"]; - const currentIdx = levels.indexOf(zoomLevel); - if (currentIdx < levels.length - 1) { - setZoomLevel(levels[currentIdx + 1]); - } + const idx = levels.indexOf(zoomLevel); + if (idx < levels.length - 1) setZoomLevel(levels[idx + 1]); }, [zoomLevel, setZoomLevel]); const handleZoomOut = useCallback(() => { const levels: ZoomLevel[] = ["month", "week", "day"]; - const currentIdx = levels.indexOf(zoomLevel); - if (currentIdx > 0) { - setZoomLevel(levels[currentIdx - 1]); - } + const idx = levels.indexOf(zoomLevel); + if (idx > 0) setZoomLevel(levels[idx - 1]); }, [zoomLevel, setZoomLevel]); - // 스케줄 클릭 핸들러 + // ────────── 스케줄 클릭 ────────── const handleScheduleClick = useCallback( (schedule: ScheduleItem) => { - const resource = effectiveResources.find((r) => r.id === schedule.resourceId); + const resource = effectiveResources.find( + (r) => r.id === schedule.resourceId + ); if (resource && onScheduleClick) { onScheduleClick({ schedule, resource }); } @@ -160,7 +140,7 @@ export function TimelineSchedulerComponent({ [effectiveResources, onScheduleClick] ); - // 빈 셀 클릭 핸들러 + // ────────── 빈 셀 클릭 ────────── const handleCellClick = useCallback( (resourceId: string, date: Date) => { if (onCellClick) { @@ -173,47 +153,111 @@ export function TimelineSchedulerComponent({ [onCellClick] ); - // 드래그 시작 - const handleDragStart = useCallback( - (schedule: ScheduleItem, e: React.MouseEvent) => { - setDragState({ - schedule, - startX: e.clientX, - startY: e.clientY, - }); + // ────────── 드래그 완료 (핵심 로직) ────────── + const handleDragComplete = useCallback( + async (schedule: ScheduleItem, deltaX: number) => { + // 줌 레벨에 따라 1셀당 일수가 달라짐 + let daysPerCell = 1; + if (zoomLevel === "week") daysPerCell = 7; + if (zoomLevel === "month") daysPerCell = 30; + + const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell); + if (deltaDays === 0) return; + + const newStartDate = addDaysToDateString(schedule.startDate, deltaDays); + const newEndDate = addDaysToDateString(schedule.endDate, deltaDays); + + try { + await updateSchedule(schedule.id, { + startDate: newStartDate, + endDate: newEndDate, + }); + + // 외부 이벤트 핸들러 호출 + onDragEnd?.({ + scheduleId: schedule.id, + newStartDate, + newEndDate, + }); + + toast.success("스케줄 이동 완료", { + description: `${schedule.title}: ${newStartDate} ~ ${newEndDate}`, + }); + } catch (err: any) { + toast.error("스케줄 이동 실패", { + description: err.message || "잠시 후 다시 시도해주세요", + }); + } }, - [] + [cellWidth, zoomLevel, updateSchedule, onDragEnd] ); - // 드래그 종료 - const handleDragEnd = useCallback(() => { - if (dragState) { - // TODO: 드래그 결과 계산 및 업데이트 - setDragState(null); - } - }, [dragState]); + // ────────── 리사이즈 완료 (핵심 로직) ────────── + const handleResizeComplete = useCallback( + async ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => { + let daysPerCell = 1; + if (zoomLevel === "week") daysPerCell = 7; + if (zoomLevel === "month") daysPerCell = 30; - // 리사이즈 시작 - const handleResizeStart = useCallback( - (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => { - setResizeState({ - schedule, - direction, - startX: e.clientX, - }); + const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell); + if (deltaDays === 0) return; + + let newStartDate = schedule.startDate; + let newEndDate = schedule.endDate; + + if (direction === "start") { + newStartDate = addDaysToDateString(schedule.startDate, deltaDays); + // 시작일이 종료일을 넘지 않도록 + if (new Date(newStartDate) >= new Date(newEndDate)) { + toast.warning("시작일은 종료일보다 이전이어야 합니다"); + return; + } + } else { + newEndDate = addDaysToDateString(schedule.endDate, deltaDays); + // 종료일이 시작일보다 앞서지 않도록 + if (new Date(newEndDate) <= new Date(newStartDate)) { + toast.warning("종료일은 시작일보다 이후여야 합니다"); + return; + } + } + + try { + await updateSchedule(schedule.id, { + startDate: newStartDate, + endDate: newEndDate, + }); + + onResizeEnd?.({ + scheduleId: schedule.id, + newStartDate, + newEndDate, + direction, + }); + + const days = + Math.round( + (new Date(newEndDate).getTime() - + new Date(newStartDate).getTime()) / + (1000 * 60 * 60 * 24) + ) + 1; + + toast.success("기간 변경 완료", { + description: `${schedule.title}: ${days}일 (${newStartDate} ~ ${newEndDate})`, + }); + } catch (err: any) { + toast.error("기간 변경 실패", { + description: err.message || "잠시 후 다시 시도해주세요", + }); + } }, - [] + [cellWidth, zoomLevel, updateSchedule, onResizeEnd] ); - // 리사이즈 종료 - const handleResizeEnd = useCallback(() => { - if (resizeState) { - // TODO: 리사이즈 결과 계산 및 업데이트 - setResizeState(null); - } - }, [resizeState]); - - // 추가 버튼 클릭 + // ────────── 추가 버튼 클릭 ────────── const handleAddClick = useCallback(() => { if (onAddSchedule && effectiveResources.length > 0) { onAddSchedule( @@ -223,7 +267,13 @@ export function TimelineSchedulerComponent({ } }, [onAddSchedule, effectiveResources]); - // 디자인 모드 플레이스홀더 + // ────────── 하단 영역 높이 계산 (툴바 + 범례) ────────── + const showToolbar = config.showToolbar !== false; + const showLegend = config.showLegend !== false; + const toolbarHeight = showToolbar ? 36 : 0; + const legendHeight = showLegend ? 28 : 0; + + // ────────── 디자인 모드 플레이스홀더 ────────── if (isDesignMode) { return (
@@ -240,7 +290,7 @@ export function TimelineSchedulerComponent({ ); } - // 로딩 상태 + // ────────── 로딩 상태 ────────── if (isLoading) { return (
-

스케줄 데이터가 없습니다

+

+ 스케줄 데이터가 없습니다 +

- 좌측 테이블에서 품목을 선택하거나,
+ 좌측 테이블에서 품목을 선택하거나, +
스케줄 생성 버튼을 눌러 스케줄을 생성하세요

@@ -289,18 +342,19 @@ export function TimelineSchedulerComponent({ ); } + // ────────── 메인 렌더링 ────────── return (
{/* 툴바 */} - {config.showToolbar !== false && ( -
+ {showToolbar && ( +
{/* 네비게이션 */}
{config.showNavigation !== false && ( @@ -332,16 +386,23 @@ export function TimelineSchedulerComponent({ )} - {/* 현재 날짜 범위 표시 */} + {/* 날짜 범위 표시 */} {viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "} - {viewStartDate.getDate()}일 ~{" "} - {viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일 + {viewStartDate.getDate()}일 ~ {viewEndDate.getMonth() + 1}월{" "} + {viewEndDate.getDate()}일
{/* 오른쪽 컨트롤 */}
+ {/* 충돌 카운트 표시 */} + {config.showConflicts !== false && conflictIds.size > 0 && ( + + 충돌 {conflictIds.size}건 + + )} + {/* 줌 컨트롤 */} {config.showZoomControls !== false && (
@@ -355,7 +416,10 @@ export function TimelineSchedulerComponent({ - {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + { + zoomLevelOptions.find((o) => o.value === zoomLevel) + ?.label + }
); } diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx index 75a465a3..4e248cd6 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx @@ -2,54 +2,44 @@ import React, { useMemo } from "react"; import { cn } from "@/lib/utils"; -import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types"; +import { + Resource, + ScheduleItem, + ZoomLevel, + TimelineSchedulerConfig, +} from "../types"; import { ScheduleBar } from "./ScheduleBar"; interface ResourceRowProps { - /** 리소스 */ resource: Resource; - /** 해당 리소스의 스케줄 목록 */ schedules: ScheduleItem[]; - /** 시작 날짜 */ startDate: Date; - /** 종료 날짜 */ endDate: Date; - /** 줌 레벨 */ zoomLevel: ZoomLevel; - /** 행 높이 */ rowHeight: number; - /** 셀 너비 */ cellWidth: number; - /** 리소스 컬럼 너비 */ resourceColumnWidth: number; - /** 설정 */ config: TimelineSchedulerConfig; - /** 스케줄 클릭 */ + /** 충돌 스케줄 ID 목록 */ + conflictIds?: Set; onScheduleClick?: (schedule: ScheduleItem) => void; - /** 빈 셀 클릭 */ onCellClick?: (resourceId: string, date: Date) => void; - /** 드래그 시작 */ - onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void; - /** 드래그 종료 */ - onDragEnd?: () => void; - /** 리사이즈 시작 */ - onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void; - /** 리사이즈 종료 */ - onResizeEnd?: () => void; + /** 드래그 완료: deltaX(픽셀) 전달 */ + onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void; + /** 리사이즈 완료: direction + deltaX(픽셀) 전달 */ + onResizeComplete?: ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => void; } -/** - * 날짜 차이 계산 (일수) - */ const getDaysDiff = (start: Date, end: Date): number => { const startTime = new Date(start).setHours(0, 0, 0, 0); const endTime = new Date(end).setHours(0, 0, 0, 0); return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24)); }; -/** - * 날짜 범위 내의 셀 개수 계산 - */ const getCellCount = (startDate: Date, endDate: Date): number => { return getDaysDiff(startDate, endDate) + 1; }; @@ -64,20 +54,18 @@ export function ResourceRow({ cellWidth, resourceColumnWidth, config, + conflictIds, onScheduleClick, onCellClick, - onDragStart, - onDragEnd, - onResizeStart, - onResizeEnd, + onDragComplete, + onResizeComplete, }: ResourceRowProps) { - // 총 셀 개수 - const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]); - - // 총 그리드 너비 + const totalCells = useMemo( + () => getCellCount(startDate, endDate), + [startDate, endDate] + ); const gridWidth = totalCells * cellWidth; - // 오늘 날짜 const today = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); @@ -92,21 +80,26 @@ export function ResourceRow({ scheduleStart.setHours(0, 0, 0, 0); scheduleEnd.setHours(0, 0, 0, 0); - // 시작 위치 계산 const startOffset = getDaysDiff(startDate, scheduleStart); const left = Math.max(0, startOffset * cellWidth); - // 너비 계산 const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1; const visibleStartOffset = Math.max(0, startOffset); const visibleEndOffset = Math.min( totalCells, startOffset + durationDays ); - const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth); + const width = Math.max( + cellWidth, + (visibleEndOffset - visibleStartOffset) * cellWidth + ); + + // 시작일 = 종료일이면 마일스톤 + const isMilestone = schedule.startDate === schedule.endDate; return { schedule, + isMilestone, position: { left: resourceColumnWidth + left, top: 0, @@ -115,9 +108,15 @@ export function ResourceRow({ }, }; }); - }, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]); + }, [ + schedules, + startDate, + cellWidth, + resourceColumnWidth, + rowHeight, + totalCells, + ]); - // 그리드 셀 클릭 핸들러 const handleGridClick = (e: React.MouseEvent) => { if (!onCellClick) return; @@ -142,7 +141,9 @@ export function ResourceRow({ style={{ width: resourceColumnWidth }} >
-
{resource.name}
+
+ {resource.name} +
{resource.group && (
{resource.group} @@ -162,7 +163,8 @@ export function ResourceRow({ {Array.from({ length: totalCells }).map((_, idx) => { const cellDate = new Date(startDate); cellDate.setDate(cellDate.getDate() + idx); - const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6; + const isWeekend = + cellDate.getDay() === 0 || cellDate.getDay() === 6; const isToday = cellDate.getTime() === today.getTime(); const isMonthStart = cellDate.getDate() === 1; @@ -182,22 +184,22 @@ export function ResourceRow({
{/* 스케줄 바들 */} - {schedulePositions.map(({ schedule, position }) => ( + {schedulePositions.map(({ schedule, position, isMilestone }) => ( onScheduleClick?.(schedule)} - onDragStart={onDragStart} - onDragEnd={onDragEnd} - onResizeStart={onResizeStart} - onResizeEnd={onResizeEnd} + onDragComplete={onDragComplete} + onResizeComplete={onResizeComplete} /> ))}
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx index d547fafc..678dc3ac 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx @@ -2,79 +2,99 @@ import React, { useState, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; -import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types"; +import { AlertTriangle } from "lucide-react"; +import { + ScheduleItem, + ScheduleBarPosition, + TimelineSchedulerConfig, +} from "../types"; import { statusOptions } from "../config"; interface ScheduleBarProps { - /** 스케줄 항목 */ schedule: ScheduleItem; - /** 위치 정보 */ position: ScheduleBarPosition; - /** 설정 */ config: TimelineSchedulerConfig; - /** 드래그 가능 여부 */ draggable?: boolean; - /** 리사이즈 가능 여부 */ resizable?: boolean; - /** 클릭 이벤트 */ + hasConflict?: boolean; + isMilestone?: boolean; onClick?: (schedule: ScheduleItem) => void; - /** 드래그 시작 */ - onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void; - /** 드래그 중 */ - onDrag?: (deltaX: number, deltaY: number) => void; - /** 드래그 종료 */ - onDragEnd?: () => void; - /** 리사이즈 시작 */ - onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void; - /** 리사이즈 중 */ - onResize?: (deltaX: number, direction: "start" | "end") => void; - /** 리사이즈 종료 */ - onResizeEnd?: () => void; + /** 드래그 완료 시 deltaX(픽셀) 전달 */ + onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void; + /** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */ + onResizeComplete?: ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => void; } +// 드래그/리사이즈 판정 최소 이동 거리 (px) +const MIN_MOVE_THRESHOLD = 5; + export function ScheduleBar({ schedule, position, config, draggable = true, resizable = true, + hasConflict = false, + isMilestone = false, onClick, - onDragStart, - onDragEnd, - onResizeStart, - onResizeEnd, + onDragComplete, + onResizeComplete, }: ScheduleBarProps) { const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState(0); + const [resizeOffset, setResizeOffset] = useState(0); + const [resizeDir, setResizeDir] = useState<"start" | "end">("end"); const barRef = useRef(null); + const startXRef = useRef(0); + const movedRef = useRef(false); - // 상태에 따른 색상 - const statusColor = schedule.color || + const statusColor = + schedule.color || config.statusColors?.[schedule.status] || statusOptions.find((s) => s.value === schedule.status)?.color || "#3b82f6"; - // 진행률 바 너비 - const progressWidth = config.showProgress && schedule.progress !== undefined - ? `${schedule.progress}%` - : "0%"; + const progressWidth = + config.showProgress && schedule.progress !== undefined + ? `${schedule.progress}%` + : "0%"; - // 드래그 시작 핸들러 + const isEditable = config.editable !== false; + + // ────────── 드래그 핸들러 ────────── const handleMouseDown = useCallback( (e: React.MouseEvent) => { - if (!draggable || isResizing) return; + if (!draggable || isResizing || !isEditable) return; e.preventDefault(); e.stopPropagation(); + + startXRef.current = e.clientX; + movedRef.current = false; setIsDragging(true); - onDragStart?.(schedule, e); + setDragOffset(0); const handleMouseMove = (moveEvent: MouseEvent) => { - // 드래그 중 로직은 부모에서 처리 + const delta = moveEvent.clientX - startXRef.current; + if (Math.abs(delta) > MIN_MOVE_THRESHOLD) { + movedRef.current = true; + } + setDragOffset(delta); }; - const handleMouseUp = () => { + const handleMouseUp = (upEvent: MouseEvent) => { + const finalDelta = upEvent.clientX - startXRef.current; setIsDragging(false); - onDragEnd?.(); + setDragOffset(0); + + if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) { + onDragComplete?.(schedule, finalDelta); + } + document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -82,25 +102,39 @@ export function ScheduleBar({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [draggable, isResizing, schedule, onDragStart, onDragEnd] + [draggable, isResizing, isEditable, schedule, onDragComplete] ); - // 리사이즈 시작 핸들러 - const handleResizeStart = useCallback( + // ────────── 리사이즈 핸들러 ────────── + const handleResizeMouseDown = useCallback( (direction: "start" | "end", e: React.MouseEvent) => { - if (!resizable) return; + if (!resizable || !isEditable) return; e.preventDefault(); e.stopPropagation(); + + startXRef.current = e.clientX; + movedRef.current = false; setIsResizing(true); - onResizeStart?.(schedule, direction, e); + setResizeOffset(0); + setResizeDir(direction); const handleMouseMove = (moveEvent: MouseEvent) => { - // 리사이즈 중 로직은 부모에서 처리 + const delta = moveEvent.clientX - startXRef.current; + if (Math.abs(delta) > MIN_MOVE_THRESHOLD) { + movedRef.current = true; + } + setResizeOffset(delta); }; - const handleMouseUp = () => { + const handleMouseUp = (upEvent: MouseEvent) => { + const finalDelta = upEvent.clientX - startXRef.current; setIsResizing(false); - onResizeEnd?.(); + setResizeOffset(0); + + if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) { + onResizeComplete?.(schedule, direction, finalDelta); + } + document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -108,19 +142,62 @@ export function ScheduleBar({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [resizable, schedule, onResizeStart, onResizeEnd] + [resizable, isEditable, schedule, onResizeComplete] ); - // 클릭 핸들러 + // ────────── 클릭 핸들러 ────────── const handleClick = useCallback( (e: React.MouseEvent) => { - if (isDragging || isResizing) return; + if (movedRef.current) return; e.stopPropagation(); onClick?.(schedule); }, - [isDragging, isResizing, onClick, schedule] + [onClick, schedule] ); + // ────────── 드래그/리사이즈 중 시각적 위치 계산 ────────── + let visualLeft = position.left; + let visualWidth = position.width; + + if (isDragging) { + visualLeft += dragOffset; + } + + if (isResizing) { + if (resizeDir === "start") { + visualLeft += resizeOffset; + visualWidth -= resizeOffset; + } else { + visualWidth += resizeOffset; + } + } + + visualWidth = Math.max(10, visualWidth); + + // ────────── 마일스톤 렌더링 (단일 날짜 마커) ────────── + if (isMilestone) { + return ( +
+
+
+ ); + } + + // ────────── 일반 스케줄 바 렌더링 ────────── return (
{/* 진행률 바 */} {config.showProgress && schedule.progress !== undefined && ( @@ -162,19 +241,26 @@ export function ScheduleBar({
)} + {/* 충돌 인디케이터 */} + {hasConflict && ( +
+ +
+ )} + {/* 리사이즈 핸들 - 왼쪽 */} - {resizable && ( + {resizable && isEditable && (
handleResizeStart("start", e)} + onMouseDown={(e) => handleResizeMouseDown("start", e)} /> )} {/* 리사이즈 핸들 - 오른쪽 */} - {resizable && ( + {resizable && isEditable && (
handleResizeStart("end", e)} + onMouseDown={(e) => handleResizeMouseDown("end", e)} /> )}
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx new file mode 100644 index 00000000..da70e1b7 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React from "react"; +import { TimelineSchedulerConfig } from "../types"; +import { statusOptions } from "../config"; + +interface TimelineLegendProps { + config: TimelineSchedulerConfig; +} + +export function TimelineLegend({ config }: TimelineLegendProps) { + const colors = config.statusColors || {}; + + return ( +
+ + 범례: + + {statusOptions.map((status) => ( +
+
+ + {status.label} + +
+ ))} + + {/* 마일스톤 범례 */} +
+
+
+
+ + 마일스톤 + +
+ + {/* 충돌 범례 */} + {config.showConflicts && ( +
+
+ + 충돌 + +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts index 4da03f17..4ac2af4b 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts @@ -1,3 +1,4 @@ export { TimelineHeader } from "./TimelineHeader"; export { ScheduleBar } from "./ScheduleBar"; export { ResourceRow } from "./ResourceRow"; +export { TimelineLegend } from "./TimelineLegend"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts new file mode 100644 index 00000000..98b9fbb1 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts @@ -0,0 +1,58 @@ +"use client"; + +import { ScheduleItem } from "../types"; + +/** + * 같은 리소스에서 시간이 겹치는 스케줄을 감지 + * @returns 충돌이 있는 스케줄 ID Set + */ +export function detectConflicts(schedules: ScheduleItem[]): Set { + const conflictIds = new Set(); + + // 리소스별로 그룹화 + const byResource = new Map(); + for (const schedule of schedules) { + if (!byResource.has(schedule.resourceId)) { + byResource.set(schedule.resourceId, []); + } + byResource.get(schedule.resourceId)!.push(schedule); + } + + // 리소스별 충돌 검사 + for (const [, resourceSchedules] of byResource) { + if (resourceSchedules.length < 2) continue; + + // 시작일 기준 정렬 + const sorted = [...resourceSchedules].sort( + (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + ); + + for (let i = 0; i < sorted.length; i++) { + const aEnd = new Date(sorted[i].endDate).getTime(); + + for (let j = i + 1; j < sorted.length; j++) { + const bStart = new Date(sorted[j].startDate).getTime(); + + // 정렬되어 있으므로 aStart <= bStart + // 겹치는 조건: aEnd > bStart + if (aEnd > bStart) { + conflictIds.add(sorted[i].id); + conflictIds.add(sorted[j].id); + } else { + break; + } + } + } + } + + return conflictIds; +} + +/** + * 날짜를 일수만큼 이동 + */ +export function addDaysToDateString(dateStr: string, days: number): string { + const date = new Date(dateStr); + date.setDate(date.getDate() + days); + return date.toISOString().split("T")[0]; +} From 7e02fff717055ee86e7e236bdd9cc9248f26e80e Mon Sep 17 00:00:00 2001 From: syc0123 Date: Mon, 16 Mar 2026 11:28:03 +0900 Subject: [PATCH 44/46] 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: ["버튼", "액션", "클릭"], From 64c9f25f630bdffc1a55dac13b76cf7f28dadc81 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 16 Mar 2026 14:00:07 +0900 Subject: [PATCH 45/46] feat: add schedule preview functionality for production plans - Implemented previewSchedule and previewSemiSchedule functions in the production controller to allow users to preview schedule changes without making actual database modifications. - Added corresponding routes for schedule preview in productionRoutes. - Enhanced productionPlanService with logic to generate schedule previews based on provided items and plan IDs. - Introduced SchedulePreviewDialog component to display the preview results in the frontend, including summary and detailed views of planned schedules. These updates improve the user experience by providing a way to visualize scheduling changes before applying them, ensuring better planning and decision-making. Made-with: Cursor --- .../src/controllers/productionController.ts | 42 ++ backend-node/src/routes/productionRoutes.ts | 6 + .../src/services/productionPlanService.ts | 281 +++++++-- .../next-component-development-plan.md | 4 +- .../schedule-auto-generation-guide.md | 76 +++ .../full-screen-analysis.md | 116 +++- .../v2-component-usage-guide.md | 375 ++++++++++- .../production-plan-test-scenario.md | 451 +++++++++++++ .../v2-table-grouped/hooks/useGroupedData.ts | 28 +- .../TimelineSchedulerComponent.tsx | 594 +++++++++++++++++- .../components/ItemTimelineCard.tsx | 297 +++++++++ .../components/SchedulePreviewDialog.tsx | 282 +++++++++ .../v2-timeline-scheduler/components/index.ts | 2 + .../components/v2-timeline-scheduler/types.ts | 29 + frontend/package-lock.json | 28 + frontend/package.json | 1 + 16 files changed, 2515 insertions(+), 97 deletions(-) create mode 100644 docs/screen-implementation-guide/03_production/production-plan-test-scenario.md create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/SchedulePreviewDialog.tsx diff --git a/backend-node/src/controllers/productionController.ts b/backend-node/src/controllers/productionController.ts index aa3f3a36..582188d6 100644 --- a/backend-node/src/controllers/productionController.ts +++ b/backend-node/src/controllers/productionController.ts @@ -95,6 +95,25 @@ export async function deletePlan(req: AuthenticatedRequest, res: Response) { } } +// ─── 자동 스케줄 미리보기 (실제 INSERT 없이 예상 결과 반환) ─── + +export async function previewSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { items, options } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" }); + } + + const data = await productionService.previewSchedule(companyCode, items, options || {}); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("자동 스케줄 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 자동 스케줄 생성 ─── export async function generateSchedule(req: AuthenticatedRequest, res: Response) { @@ -141,6 +160,29 @@ export async function mergeSchedules(req: AuthenticatedRequest, res: Response) { } } +// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ─── + +export async function previewSemiSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { plan_ids, options } = req.body; + + if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) { + return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" }); + } + + const data = await productionService.previewSemiSchedule( + companyCode, + plan_ids, + options || {} + ); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("반제품 계획 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 반제품 계획 자동 생성 ─── export async function generateSemiSchedule(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/routes/productionRoutes.ts b/backend-node/src/routes/productionRoutes.ts index 120147f0..572674aa 100644 --- a/backend-node/src/routes/productionRoutes.ts +++ b/backend-node/src/routes/productionRoutes.ts @@ -21,12 +21,18 @@ router.get("/plan/:id", productionController.getPlanById); router.put("/plan/:id", productionController.updatePlan); router.delete("/plan/:id", productionController.deletePlan); +// 자동 스케줄 미리보기 (실제 변경 없이 예상 결과) +router.post("/generate-schedule/preview", productionController.previewSchedule); + // 자동 스케줄 생성 router.post("/generate-schedule", productionController.generateSchedule); // 스케줄 병합 router.post("/merge-schedules", productionController.mergeSchedules); +// 반제품 계획 미리보기 +router.post("/generate-semi-schedule/preview", productionController.previewSemiSchedule); + // 반제품 계획 자동 생성 router.post("/generate-semi-schedule", productionController.generateSemiSchedule); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 7c8e69ec..f6b080a0 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -251,6 +251,101 @@ interface GenerateScheduleOptions { product_type?: string; } +/** + * 자동 스케줄 미리보기 (DB 변경 없이 예상 결과만 반환) + */ +export async function previewSchedule( + companyCode: string, + items: GenerateScheduleItem[], + options: GenerateScheduleOptions +) { + const pool = getPool(); + const productType = options.product_type || "완제품"; + const safetyLeadTime = options.safety_lead_time || 1; + + const previews: any[] = []; + const deletedSchedules: any[] = []; + const keptSchedules: any[] = []; + + for (const item of items) { + if (options.recalculate_unstarted) { + // 삭제 대상(planned) 상세 조회 + const deleteResult = await pool.query( + `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status + FROM production_plan_mng + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status = 'planned'`, + [companyCode, item.item_code, productType] + ); + deletedSchedules.push(...deleteResult.rows); + + // 유지 대상(진행중 등) 상세 조회 + const keptResult = await pool.query( + `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty + FROM production_plan_mng + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status NOT IN ('planned', 'completed', 'cancelled')`, + [companyCode, item.item_code, productType] + ); + keptSchedules.push(...keptResult.rows); + } + + const dailyCapacity = item.daily_capacity || 800; + const requiredQty = item.required_qty; + if (requiredQty <= 0) continue; + + const productionDays = Math.ceil(requiredQty / dailyCapacity); + + const dueDate = new Date(item.earliest_due_date); + const endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (startDate < today) { + startDate.setTime(today.getTime()); + endDate.setTime(startDate.getTime()); + endDate.setDate(endDate.getDate() + productionDays); + } + + // 해당 품목의 수주 건수 확인 + const orderCountResult = await pool.query( + `SELECT COUNT(*) AS cnt FROM sales_order_mng + WHERE company_code = $1 AND part_code = $2 AND part_code IS NOT NULL`, + [companyCode, item.item_code] + ); + const orderCount = parseInt(orderCountResult.rows[0].cnt, 10); + + previews.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: requiredQty, + daily_capacity: dailyCapacity, + hourly_capacity: item.hourly_capacity || 100, + production_days: productionDays, + start_date: startDate.toISOString().split("T")[0], + end_date: endDate.toISOString().split("T")[0], + due_date: item.earliest_due_date, + order_count: orderCount, + status: "planned", + }); + } + + const summary = { + total: previews.length + keptSchedules.length, + new_count: previews.length, + kept_count: keptSchedules.length, + deleted_count: deletedSchedules.length, + }; + + logger.info("자동 스케줄 미리보기", { companyCode, summary }); + return { summary, previews, deletedSchedules, keptSchedules }; +} + export async function generateSchedule( companyCode: string, items: GenerateScheduleItem[], @@ -317,14 +412,16 @@ export async function generateSchedule( endDate.setDate(endDate.getDate() + productionDays); } - // 계획번호 생성 + // 계획번호 생성 (YYYYMMDD-NNNN 형식) + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-%`] ); - const nextNo = planNoResult.rows[0].next_no || 1; - const planNo = `PP-${String(nextNo).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( @@ -472,6 +569,123 @@ export async function mergeSchedules( } } +// ─── 반제품 BOM 소요량 조회 (공통) ─── + +async function getBomChildItems( + client: any, + companyCode: string, + itemCode: string +) { + const bomQuery = ` + SELECT + bd.child_item_id, + ii.item_name AS child_item_name, + ii.item_number AS child_item_code, + bd.quantity AS bom_qty, + bd.unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code + WHERE b.company_code = $1 + AND b.item_code = $2 + AND COALESCE(b.status, 'active') = 'active' + `; + const result = await client.query(bomQuery, [companyCode, itemCode]); + return result.rows; +} + +// ─── 반제품 계획 미리보기 (실제 DB 변경 없음) ─── + +export async function previewSemiSchedule( + companyCode: string, + planIds: number[], + options: { considerStock?: boolean; excludeUsed?: boolean } +) { + const pool = getPool(); + + const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", "); + const plansResult = await pool.query( + `SELECT * FROM production_plan_mng + WHERE company_code = $1 AND id IN (${placeholders}) + AND product_type = '완제품'`, + [companyCode, ...planIds] + ); + + const previews: any[] = []; + const existingSemiPlans: any[] = []; + + for (const plan of plansResult.rows) { + // 이미 존재하는 반제품 계획 조회 + const existingResult = await pool.query( + `SELECT * FROM production_plan_mng + WHERE company_code = $1 AND parent_plan_id = $2 AND product_type = '반제품'`, + [companyCode, plan.id] + ); + existingSemiPlans.push(...existingResult.rows); + + const bomItems = await getBomChildItems(pool, companyCode, plan.item_code); + + for (const bomItem of bomItems) { + let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1); + + if (options.considerStock) { + const stockResult = await pool.query( + `SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2`, + [companyCode, bomItem.child_item_code || bomItem.child_item_id] + ); + const stock = parseFloat(stockResult.rows[0].stock) || 0; + requiredQty = Math.max(requiredQty - stock, 0); + } + + if (requiredQty <= 0) continue; + + const semiDueDate = plan.start_date; + const semiStartDate = new Date(plan.start_date); + semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + + previews.push({ + parent_plan_id: plan.id, + parent_plan_no: plan.plan_no, + parent_item_name: plan.item_name, + item_code: bomItem.child_item_code || bomItem.child_item_id, + item_name: bomItem.child_item_name || bomItem.child_item_id, + plan_qty: requiredQty, + bom_qty: parseFloat(bomItem.bom_qty) || 1, + start_date: semiStartDate.toISOString().split("T")[0], + end_date: typeof semiDueDate === "string" + ? semiDueDate.split("T")[0] + : new Date(semiDueDate).toISOString().split("T")[0], + due_date: typeof semiDueDate === "string" + ? semiDueDate.split("T")[0] + : new Date(semiDueDate).toISOString().split("T")[0], + product_type: "반제품", + status: "planned", + }); + } + } + + // 기존 반제품 중 삭제 대상 (status = planned) + const deletedSchedules = existingSemiPlans.filter( + (s) => s.status === "planned" + ); + // 기존 반제품 중 유지 대상 (진행중 등) + const keptSchedules = existingSemiPlans.filter( + (s) => s.status !== "planned" && s.status !== "completed" + ); + + const summary = { + total: previews.length + keptSchedules.length, + new_count: previews.length, + deleted_count: deletedSchedules.length, + kept_count: keptSchedules.length, + parent_count: plansResult.rowCount, + }; + + return { summary, previews, deletedSchedules, keptSchedules }; +} + // ─── 반제품 계획 자동 생성 ─── export async function generateSemiSchedule( @@ -486,41 +700,36 @@ export async function generateSemiSchedule( try { await client.query("BEGIN"); - // 선택된 완제품 계획 조회 const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", "); const plansResult = await client.query( `SELECT * FROM production_plan_mng - WHERE company_code = $1 AND id IN (${placeholders})`, + WHERE company_code = $1 AND id IN (${placeholders}) + AND product_type = '완제품'`, [companyCode, ...planIds] ); + // 기존 planned 상태 반제품 삭제 + for (const plan of plansResult.rows) { + await client.query( + `DELETE FROM production_plan_mng + WHERE company_code = $1 AND parent_plan_id = $2 + AND product_type = '반제품' AND status = 'planned'`, + [companyCode, plan.id] + ); + } + const newSemiPlans: any[] = []; + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); for (const plan of plansResult.rows) { - // BOM에서 해당 품목의 반제품 소요량 조회 - const bomQuery = ` - SELECT - bd.child_item_id, - ii.item_name AS child_item_name, - ii.item_code AS child_item_code, - bd.quantity AS bom_qty, - bd.unit - FROM bom b - JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code - LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code - WHERE b.company_code = $1 - AND b.item_code = $2 - AND COALESCE(b.status, 'active') = 'active' - `; - const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]); + const bomItems = await getBomChildItems(client, companyCode, plan.item_code); - for (const bomItem of bomResult.rows) { + for (const bomItem of bomItems) { let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1); - // 재고 고려 if (options.considerStock) { const stockResult = await client.query( - `SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock + `SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock FROM inventory_stock WHERE company_code = $1 AND item_code = $2`, [companyCode, bomItem.child_item_code || bomItem.child_item_id] @@ -531,18 +740,20 @@ export async function generateSemiSchedule( if (requiredQty <= 0) continue; - // 반제품 납기일 = 완제품 시작일 const semiDueDate = plan.start_date; const semiEndDate = plan.start_date; const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1)); + semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + // plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품) const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-S%`] ); - const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-S${String(nextNo).padStart(3, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( @@ -560,8 +771,8 @@ export async function generateSemiSchedule( bomItem.child_item_name || bomItem.child_item_id, requiredQty, semiStartDate.toISOString().split("T")[0], - typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0], - typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0], + typeof semiEndDate === "string" ? semiEndDate.split("T")[0] : new Date(semiEndDate).toISOString().split("T")[0], + typeof semiDueDate === "string" ? semiDueDate.split("T")[0] : new Date(semiDueDate).toISOString().split("T")[0], plan.id, createdBy, ] diff --git a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md index 84f6c789..55740e97 100644 --- a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md +++ b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md @@ -548,11 +548,11 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌) - [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴) - [x] staticFilters 지원 (커스텀 테이블 필터링) -- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스) +- [x] 가상 스크롤 (@tanstack/react-virtual, 30개 이상 리소스 시 자동 활성화) - [x] 설정 패널 구현 - [x] API 연동 - [x] 레지스트리 등록 -- [ ] 테스트 완료 +- [x] 테스트 완료 (20개 테스트 전체 통과 - 충돌감지 11건 + 날짜계산 9건) - [x] 문서화 (README.md) --- diff --git a/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md b/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md index 02699843..69f9b3d5 100644 --- a/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md +++ b/docs/screen-implementation-guide/00_analysis/schedule-auto-generation-guide.md @@ -892,3 +892,79 @@ if (process.env.NODE_ENV === "development") { }); } ``` + +--- + +## 12. 생산기간(리드타임) 산출 - 현재 상태 및 개선 방안 + +> 작성일: 2026-03-16 | 상태: 검토 대기 (스키마 변경 전 상의 필요) + +### 12.1 현재 구현 상태 + +**생산일수 계산 로직** (`productionPlanService.ts`): + +``` +생산일수 = ceil(계획수량 / 일생산능력) +종료일 = 납기일 - 안전리드타임 +시작일 = 종료일 - 생산일수 +``` + +**현재 기본값 (하드코딩):** + +| 항목 | 현재값 | 위치 | +|------|--------|------| +| 일생산능력 (daily_capacity) | 800 EA/일 | `productionPlanService.ts` 기본값 | +| 시간당 능력 (hourly_capacity) | 100 EA/시간 | `productionPlanService.ts` 기본값 | +| 안전리드타임 (safety_lead_time) | 1일 | 옵션 기본값 | +| 반제품 리드타임 (lead_time) | 1일 | `production_plan_mng` 기본값 | + +**문제점:** +- `item_info`에 생산 파라미터 컬럼이 없음 +- 모든 품목이 동일한 기본값(800EA/일)으로 계산됨 +- 업체별/품목별 생산능력 차이를 반영 불가 + +### 12.2 개선 방향 (상의 후 결정) + +**1단계 (품목 마스터 기반) - 권장:** + +`item_info` 테이블에 컬럼 추가: +- `lead_time_days`: 리드타임 (일) +- `daily_capacity`: 일생산능력 +- `min_lot_size`: 최소 생산 단위 (선택) +- `setup_time`: 셋업시간 (선택) + +자동 스케줄 생성 시 품목 마스터 조회 → 값 없으면 기본값 사용 (하위 호환) + +**2단계 (설비별 능력) - 고객 요청 시:** + +별도 테이블 `item_equipment_capacity`: +- 품목 + 설비 조합별 생산능력 관리 +- 동일 품목이라도 설비에 따라 능력 다를 때 + +**3단계 (공정 라우팅) - 대기업 대응:** + +공정 순서 + 공정별 소요시간 전체 관리 +- 현재 시점에서는 불필요 + +### 12.3 반제품 계획 생성 현황 + +**구현 완료 항목:** +- API: `POST /production/generate-semi-schedule/preview` (미리보기) +- API: `POST /production/generate-semi-schedule` (실제 생성) +- BOM 기반 소요량 자동 계산 +- 타임라인 컴포넌트 내 "반제품 계획 생성" 버튼 (완제품 탭에서만 표시) +- 반제품 탭: linkedFilter 제거, staticFilters만 사용 (전체 반제품 표시) + +**반제품 생산기간 계산:** +- 반제품 납기일 = 완제품 시작일 +- 반제품 시작일 = 완제품 시작일 - lead_time (기본 1일) +- BOM 소요량 = 완제품 계획수량 x BOM 수량 + +**테스트 BOM 데이터:** + +| 완제품 | 반제품 | BOM 수량 | +|--------|--------|----------| +| ITEM-001 (탑씰 Type A) | SEMI-001 (탑씰 필름 A) | 2 EA/개 | +| ITEM-001 (탑씰 Type A) | SEMI-002 (탑씰 접착제) | 0.5 KG/개 | +| ITEM-002 (탑씰 Type B) | SEMI-003 (탑씰 필름 B) | 3 EA/개 | +| ITEM-002 (탑씰 Type B) | SEMI-004 (탑씰 코팅제) | 0.3 KG/개 | diff --git a/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md index 1ba0da01..a648e309 100644 --- a/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md +++ b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md @@ -1,6 +1,6 @@ # WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스 -> **최종 업데이트**: 2026-03-13 +> **최종 업데이트**: 2026-03-16 > **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전 > **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시) @@ -532,15 +532,20 @@ CREATE TABLE "{테이블명}" ( --- -### 3.11 v2-timeline-scheduler (간트차트) +### 3.11 v2-timeline-scheduler (간트차트/타임라인) -**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. +**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. 품목별 그룹 뷰, 자동 스케줄 생성, 반제품 계획 연동 지원. + +**기본 설정**: | 설정 | 타입 | 기본값 | 설명 | |------|------|--------|------| | selectedTable | string | - | 스케줄 데이터 테이블 | +| customTableName | string | - | selectedTable 대신 사용 (useCustomTable=true 시) | +| useCustomTable | boolean | `false` | customTableName 사용 여부 | | resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 | | scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` | +| viewMode | string | - | 뷰 모드: `"itemGrouped"` (품목별 카드 그룹) / 미설정 시 리소스 기반 | | defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` | | editable | boolean | `true` | 편집 가능 | | draggable | boolean | `true` | 드래그 이동 허용 | @@ -548,15 +553,16 @@ CREATE TABLE "{테이블명}" ( | rowHeight | number | `50` | 행 높이(px) | | headerHeight | number | `60` | 헤더 높이(px) | | resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) | -| cellWidth.day | number | `60` | 일 단위 셀 너비 | -| cellWidth.week | number | `120` | 주 단위 셀 너비 | -| cellWidth.month | number | `40` | 월 단위 셀 너비 | | showConflicts | boolean | `true` | 시간 겹침 충돌 표시 | | showProgress | boolean | `true` | 진행률 바 표시 | | showTodayLine | boolean | `true` | 오늘 날짜 표시선 | | showToolbar | boolean | `true` | 상단 툴바 표시 | +| showLegend | boolean | `true` | 범례(상태 색상 안내) 표시 | +| showNavigation | boolean | `true` | 날짜 네비게이션 버튼 표시 | +| showZoomControls | boolean | `true` | 줌 컨트롤 버튼 표시 | | showAddButton | boolean | `true` | 추가 버튼 | | height | number | `500` | 높이(px) | +| maxHeight | number | - | 최대 높이(px) | **fieldMapping (필수)**: @@ -583,10 +589,74 @@ CREATE TABLE "{테이블명}" ( | 상태 | 기본 색상 | |------|----------| | planned | `"#3b82f6"` (파랑) | -| in_progress | `"#f59e0b"` (주황) | -| completed | `"#10b981"` (초록) | +| in_progress | `"#10b981"` (초록) | +| completed | `"#6b7280"` (회색) | | delayed | `"#ef4444"` (빨강) | -| cancelled | `"#6b7280"` (회색) | +| cancelled | `"#9ca3af"` (연회색) | + +**staticFilters (정적 필터)** - DB 조회 시 항상 적용되는 WHERE 조건: + +| 설정 | 타입 | 설명 | +|------|------|------| +| product_type | string | `"완제품"` 또는 `"반제품"` 등 고정 필터 | +| status | string | 상태값 필터 | +| (임의 컬럼) | string | 해당 컬럼으로 필터링 | + +```json +"staticFilters": { + "product_type": "완제품" +} +``` + +**linkedFilter (연결 필터)** - 다른 컴포넌트(주로 테이블)의 선택 이벤트와 연동: + +| 설정 | 타입 | 설명 | +|------|------|------| +| sourceField | string | 소스 컴포넌트(좌측 테이블)의 필터 기준 컬럼 | +| targetField | string | 타임라인 스케줄 데이터에서 매칭할 컬럼 | +| sourceTableName | string | 이벤트 발신 테이블명 (이벤트 필터용) | +| sourceComponentId | string | 이벤트 발신 컴포넌트 ID (선택) | +| emptyMessage | string | 선택 전 빈 상태 메시지 | +| showEmptyWhenNoSelection | boolean | 선택 전 빈 상태 표시 여부 | + +```json +"linkedFilter": { + "sourceField": "part_code", + "targetField": "item_code", + "sourceTableName": "sales_order_mng", + "emptyMessage": "좌측 수주 목록에서 품목을 선택하세요", + "showEmptyWhenNoSelection": true +} +``` + +> **linkedFilter 동작 원리**: v2EventBus의 `TABLE_SELECTION_CHANGE` 이벤트를 구독. +> 좌측 테이블에서 행을 선택하면 해당 행의 `sourceField` 값을 수집하여, +> 타임라인 데이터 중 `targetField`가 일치하는 스케줄만 클라이언트 측에서 필터링 표시. +> `staticFilters`는 서버 측 조회, `linkedFilter`는 클라이언트 측 필터링. + +**viewMode: "itemGrouped" (품목별 그룹 뷰)**: + +리소스(설비) 기반 간트차트 대신, 품목(item_code)별로 카드를 그룹화하여 표시하는 모드. +각 카드 안에 해당 품목의 스케줄 바가 미니 타임라인으로 표시됨. + +설정 시 `viewMode: "itemGrouped"`만 추가하면 됨. 툴바에 자동으로: +- 날짜 네비게이션 (이전/오늘/다음) +- 줌 컨트롤 +- 새로고침 버튼 +- (완제품 탭일 때) **완제품 계획 생성** / **반제품 계획 생성** 버튼 + +**자동 스케줄 생성 (내장 기능)**: + +`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 일 때 자동 활성화. + +- **완제품 계획 생성**: linkedFilter로 선택된 수주 품목 기반, 미리보기 다이얼로그 → 확인 후 생성 + - API: `POST /production/generate-schedule/preview` → `POST /production/generate-schedule` +- **반제품 계획 생성**: 현재 타임라인의 완제품 스케줄 기반, BOM 소요량으로 반제품 계획 미리보기 → 확인 후 생성 + - API: `POST /production/generate-semi-schedule/preview` → `POST /production/generate-semi-schedule` + +> **중요**: 반제품 전용 타임라인에는 `linkedFilter`를 걸지 않는다. +> 반제품 item_code가 수주 품목 코드와 다르므로 매칭 불가. +> `staticFilters: { product_type: "반제품" }`만 설정하여 전체 반제품 계획을 표시. --- @@ -923,16 +993,32 @@ CREATE TABLE "{테이블명}" ( ## 4. 패턴 의사결정 트리 ``` -Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler -Q2. 다차원 피벗 분석? → v2-pivot-grid -Q3. 그룹별 접기/펼치기? → v2-table-grouped -Q4. 카드 형태 표시? → v2-card-display -Q5. 마스터-디테일? +Q1. 좌측 마스터 + 우측 탭(타임라인/테이블) 복합 구성? + → 패턴 F → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler +Q2. 시간축 기반 일정/간트차트? + ├ 품목별 카드 그룹 뷰? → 패턴 E-2 → v2-timeline-scheduler(viewMode:itemGrouped) + └ 리소스(설비) 기반? → 패턴 E → v2-timeline-scheduler +Q3. 다차원 피벗 분석? → v2-pivot-grid +Q4. 그룹별 접기/펼치기? → v2-table-grouped +Q5. 카드 형태 표시? → v2-card-display +Q6. 마스터-디테일? ├ 우측 멀티 탭? → v2-split-panel-layout + additionalTabs └ 단일 디테일? → v2-split-panel-layout -Q6. 단일 테이블? → v2-table-search-widget + v2-table-list +Q7. 단일 테이블? → v2-table-search-widget + v2-table-list ``` +### 패턴 요약표 + +| 패턴 | 대표 화면 | 핵심 컴포넌트 | +|------|----------|-------------| +| A | 거래처관리 | v2-table-search-widget + v2-table-list | +| B | 수주관리 | v2-split-panel-layout | +| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs | +| D | 재고현황 | v2-table-grouped | +| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) | +| E-2 | 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) | +| F | 생산계획 | split(custom) + tabs + timeline | + --- ## 5. 관계(relation) 레퍼런스 diff --git a/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md index 14182a91..6d9f7c8a 100644 --- a/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md +++ b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md @@ -1,6 +1,6 @@ # WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용) -> **최종 업데이트**: 2026-03-13 +> **최종 업데이트**: 2026-03-16 > **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드 > **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다 @@ -533,7 +533,9 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); } ``` -### 8.5 패턴 E: 타임라인/간트차트 +### 8.5 패턴 E: 타임라인/간트차트 (리소스 기반) + +**사용 조건**: 설비/작업자 등 리소스 기준으로 스케줄을 시간축에 표시 ```json { @@ -575,6 +577,246 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); } ``` +### 8.6 패턴 E-2: 타임라인 (품목 그룹 뷰 + 연결 필터) + +**사용 조건**: 좌측 테이블에서 선택한 품목 기반으로 타임라인을 필터링 표시. 품목별 카드 그룹 뷰. + +> 리소스(설비) 기반이 아닌, **품목(item_code)별로 카드 그룹** 형태로 스케줄을 표시한다. +> 좌측 테이블에서 행을 선택하면 `linkedFilter`로 해당 품목의 스케줄만 필터링. +> `staticFilters`로 완제품/반제품 등 데이터 유형을 고정 필터링. + +```json +{ + "id": "timeline_finished", + "url": "@/lib/registry/components/v2-timeline-scheduler", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { + "label": "완제품 생산계획", + "selectedTable": "{스케줄_테이블}", + "viewMode": "itemGrouped", + "fieldMapping": { + "id": "id", + "resourceId": "item_code", + "title": "item_name", + "startDate": "start_date", + "endDate": "end_date", + "status": "status" + }, + "defaultZoomLevel": "day", + "staticFilters": { + "product_type": "완제품" + }, + "linkedFilter": { + "sourceField": "part_code", + "targetField": "item_code", + "sourceTableName": "{좌측_테이블명}", + "emptyMessage": "좌측 목록에서 품목을 선택하세요", + "showEmptyWhenNoSelection": true + } + } +} +``` + +**핵심 설정 설명**: + +| 설정 | 용도 | +|------|------| +| `viewMode: "itemGrouped"` | 리소스 행이 아닌, 품목별 카드 그룹으로 표시 | +| `staticFilters` | DB 조회 시 항상 적용 (서버측 WHERE 조건) | +| `linkedFilter` | 다른 컴포넌트 선택 이벤트로 클라이언트 측 필터링 | +| `linkedFilter.sourceField` | 소스 테이블에서 가져올 값의 컬럼명 | +| `linkedFilter.targetField` | 타임라인 데이터에서 매칭할 컬럼명 | + +> **주의**: `linkedFilter`와 `staticFilters`의 차이 +> - `staticFilters`: DB SELECT 쿼리의 WHERE 절에 포함 → 서버에서 필터링 +> - `linkedFilter`: 전체 데이터를 불러온 후, 선택 이벤트에 따라 클라이언트에서 필터링 + +### 8.7 패턴 F: 복합 화면 (좌측 테이블 + 우측 탭 내 타임라인) + +**사용 조건**: 생산계획처럼 좌측 마스터 테이블 + 우측에 탭으로 여러 타임라인/테이블을 표시하는 복합 화면. +`v2-split-panel-layout`의 `rightPanel.displayMode: "custom"` + `v2-tabs-widget` + `v2-timeline-scheduler` 조합. + +**구조 개요**: + +``` +┌──────────────────────────────────────────────────┐ +│ v2-split-panel-layout │ +│ ┌──────────┬─────────────────────────────────┐ │ +│ │ leftPanel │ rightPanel (displayMode:custom)│ │ +│ │ │ ┌─────────────────────────────┐│ │ +│ │ v2-table- │ │ v2-tabs-widget ││ │ +│ │ grouped │ │ ┌───────┬───────┬─────────┐ ││ │ +│ │ (수주목록) │ │ │완제품 │반제품 │기타 탭 │ ││ │ +│ │ │ │ └───────┴───────┴─────────┘ ││ │ +│ │ │ │ ┌─────────────────────────┐ ││ │ +│ │ │ │ │ v2-timeline-scheduler │ ││ │ +│ │ │ │ │ (품목별 그룹 뷰) │ ││ │ +│ │ │ │ └─────────────────────────┘ ││ │ +│ │ │ └─────────────────────────────┘│ │ +│ └──────────┴─────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +**실제 layout_data 예시** (생산계획 화면 참고): + +```json +{ + "version": "2.0", + "components": [ + { + "id": "split_pp", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 850 }, + "displayOrder": 0, + "overrides": { + "label": "생산계획", + "splitRatio": 25, + "resizable": true, + "autoLoad": true, + "syncSelection": true, + "leftPanel": { + "title": "수주 목록", + "displayMode": "custom", + "components": [ + { + "id": "grouped_orders", + "componentType": "v2-table-grouped", + "label": "수주별 품목", + "position": { "x": 0, "y": 0 }, + "size": { "width": 600, "height": 800 }, + "componentConfig": { + "selectedTable": "sales_order_mng", + "groupConfig": { + "groupByColumn": "order_number", + "groupLabelFormat": "{value}", + "defaultExpanded": true, + "summary": { "showCount": true } + }, + "columns": [ + { "columnName": "part_code", "displayName": "품번", "visible": true, "width": 100 }, + { "columnName": "part_name", "displayName": "품명", "visible": true, "width": 120 }, + { "columnName": "order_qty", "displayName": "수량", "visible": true, "width": 60 }, + { "columnName": "delivery_date", "displayName": "납기일", "visible": true, "width": 90 } + ], + "showCheckbox": true, + "checkboxMode": "multi" + } + } + ] + }, + "rightPanel": { + "title": "생산 계획", + "displayMode": "custom", + "components": [ + { + "id": "tabs_pp", + "componentType": "v2-tabs-widget", + "label": "생산계획 탭", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1400, "height": 800 }, + "componentConfig": { + "tabs": [ + { + "id": "tab_finished", + "label": "완제품", + "order": 1, + "components": [ + { + "id": "timeline_finished", + "componentType": "v2-timeline-scheduler", + "label": "완제품 타임라인", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1380, "height": 750 }, + "componentConfig": { + "selectedTable": "production_plan_mng", + "viewMode": "itemGrouped", + "fieldMapping": { + "id": "id", + "resourceId": "item_code", + "title": "item_name", + "startDate": "start_date", + "endDate": "end_date", + "status": "status" + }, + "defaultZoomLevel": "day", + "staticFilters": { + "product_type": "완제품" + }, + "linkedFilter": { + "sourceField": "part_code", + "targetField": "item_code", + "sourceTableName": "sales_order_mng", + "emptyMessage": "좌측 수주 목록에서 품목을 선택하세요", + "showEmptyWhenNoSelection": true + } + } + } + ] + }, + { + "id": "tab_semi", + "label": "반제품", + "order": 2, + "components": [ + { + "id": "timeline_semi", + "componentType": "v2-timeline-scheduler", + "label": "반제품 타임라인", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1380, "height": 750 }, + "componentConfig": { + "selectedTable": "production_plan_mng", + "viewMode": "itemGrouped", + "fieldMapping": { + "id": "id", + "resourceId": "item_code", + "title": "item_name", + "startDate": "start_date", + "endDate": "end_date", + "status": "status" + }, + "defaultZoomLevel": "day", + "staticFilters": { + "product_type": "반제품" + } + } + } + ] + } + ], + "defaultTab": "tab_finished" + } + } + ] + } + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +**패턴 F 핵심 포인트**: + +| 포인트 | 설명 | +|--------|------| +| `leftPanel.displayMode: "custom"` | 좌측에 v2-table-grouped 등 자유 배치 | +| `rightPanel.displayMode: "custom"` | 우측에 v2-tabs-widget 등 자유 배치 | +| `componentConfig` | custom 내부 컴포넌트는 overrides 대신 componentConfig 사용 | +| `componentType` | custom 내부에서는 url 대신 componentType 사용 | +| 완제품 탭에만 `linkedFilter` | 좌측 테이블과 연동 필터링 | +| 반제품 탭에는 `linkedFilter` 없음 | 반제품 item_code가 수주 품목과 다르므로 전체 표시 | +| 자동 스케줄 생성 버튼 | `staticFilters.product_type === "완제품"` 일 때 자동 표시 | + +> **displayMode: "custom" 내부 컴포넌트 규칙**: +> - `url` 대신 `componentType` 사용 (예: `"v2-timeline-scheduler"`, `"v2-table-grouped"`) +> - `overrides` 대신 `componentConfig` 사용 +> - `position`, `size`는 동일하게 사용 + --- ## 9. Step 7: menu_info INSERT @@ -696,29 +938,47 @@ VALUES 사용자가 화면을 요청하면 이 트리로 패턴을 결정한다. ``` -Q1. 시간축 기반 일정/간트차트가 필요한가? -├─ YES → 패턴 E (타임라인) → v2-timeline-scheduler +Q1. 좌측 마스터 + 우측에 탭으로 타임라인/테이블 등 복합 구성이 필요한가? +├─ YES → 패턴 F (복합 화면) → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler └─ NO ↓ -Q2. 다차원 집계/피벗 분석이 필요한가? +Q2. 시간축 기반 일정/간트차트가 필요한가? +├─ YES → Q2-1. 품목별 카드 그룹 뷰인가? +│ ├─ YES → 패턴 E-2 (품목 그룹 타임라인) → v2-timeline-scheduler(viewMode:itemGrouped) +│ └─ NO → 패턴 E (리소스 기반 타임라인) → v2-timeline-scheduler +└─ NO ↓ + +Q3. 다차원 집계/피벗 분석이 필요한가? ├─ YES → 피벗 → v2-pivot-grid └─ NO ↓ -Q3. 데이터를 그룹별로 접기/펼치기가 필요한가? +Q4. 데이터를 그룹별로 접기/펼치기가 필요한가? ├─ YES → 패턴 D (그룹화) → v2-table-grouped └─ NO ↓ -Q4. 이미지+정보를 카드 형태로 표시하는가? +Q5. 이미지+정보를 카드 형태로 표시하는가? ├─ YES → 카드뷰 → v2-card-display └─ NO ↓ -Q5. 마스터 테이블 선택 시 연관 디테일이 필요한가? -├─ YES → Q5-1. 디테일에 여러 탭이 필요한가? +Q6. 마스터 테이블 선택 시 연관 디테일이 필요한가? +├─ YES → Q6-1. 디테일에 여러 탭이 필요한가? │ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs │ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout └─ NO → 패턴 A (기본 마스터) → v2-table-search-widget + v2-table-list ``` +### 패턴 선택 빠른 참조 + +| 패턴 | 대표 화면 | 핵심 컴포넌트 | +|------|----------|-------------| +| A | 거래처관리, 코드관리 | v2-table-search-widget + v2-table-list | +| B | 수주관리, 발주관리 | v2-split-panel-layout | +| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs | +| D | 재고현황, 그룹별조회 | v2-table-grouped | +| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) | +| E-2 | 단독 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) | +| F | 생산계획, 작업지시 | v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler | + --- ## 13. 화면 간 연결 관계 정의 @@ -1119,7 +1379,8 @@ VALUES ( | 검색 바 | v2-table-search-widget | `autoSelectFirstTable` | | 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` | | 그룹화 테이블 | v2-table-grouped | `groupConfig.groupByColumn`, `summary` | -| 간트차트 | v2-timeline-scheduler | `fieldMapping`, `resourceTable` | +| 간트차트 (리소스 기반) | v2-timeline-scheduler | `fieldMapping`, `resourceTable` | +| 타임라인 (품목 그룹) | v2-timeline-scheduler | `viewMode:"itemGrouped"`, `staticFilters`, `linkedFilter` | | 피벗 분석 | v2-pivot-grid | `fields(area, summaryType)` | | 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` | | 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` | @@ -1144,3 +1405,97 @@ VALUES ( | 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` | | 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` | | 품목 라우팅 | v2-item-routing | `dataSource.itemTable`, `dataSource.routingDetailTable` | + +--- + +## 17. v2-timeline-scheduler 고급 설정 가이드 + +### 17.1 viewMode 선택 기준 + +| viewMode | 용도 | Y축 | +|----------|------|-----| +| (미설정) | 설비별 작업일정, 보전계획 | 설비/작업자 행 | +| `"itemGrouped"` | 생산계획, 출하계획 | 품목별 카드 그룹 | + +### 17.2 staticFilters vs linkedFilter 비교 + +| 구분 | staticFilters | linkedFilter | +|------|--------------|-------------| +| **적용 시점** | DB SELECT 쿼리 시 | 클라이언트 렌더링 시 | +| **위치** | 서버 측 (WHERE 절) | 프론트 측 (JS 필터링) | +| **변경 가능** | 고정 (layout에 하드코딩) | 동적 (이벤트 기반) | +| **용도** | 완제품/반제품 구분 등 | 좌측 테이블 선택 연동 | + +**조합 예시**: +``` +staticFilters: { product_type: "완제품" } → DB에서 완제품만 조회 +linkedFilter: { sourceField: "part_code", targetField: "item_code" } + → 완제품 중 좌측에서 선택한 품목만 표시 +``` + +### 17.3 자동 스케줄 생성 (내장 기능) + +`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 조건 충족 시, +타임라인 툴바에 **완제품 계획 생성** / **반제품 계획 생성** 버튼이 자동 표시됨. + +**완제품 계획 생성 플로우**: +``` +1. linkedFilter로 선택된 수주 품목 수집 +2. POST /production/generate-schedule/preview → 미리보기 다이얼로그 +3. 사용자 확인 → POST /production/generate-schedule → 실제 생성 +4. 타임라인 자동 새로고침 +``` + +**반제품 계획 생성 플로우**: +``` +1. 현재 타임라인의 완제품 스케줄 ID 수집 +2. POST /production/generate-semi-schedule/preview → BOM 기반 소요량 계산 +3. 미리보기 다이얼로그 (기존 반제품 계획 삭제/유지 정보 포함) +4. 사용자 확인 → POST /production/generate-semi-schedule → 실제 생성 +5. 반제품 탭으로 전환 시 새 데이터 표시 +``` + +### 17.4 반제품 탭 주의사항 + +반제품 전용 타임라인에는 `linkedFilter`를 **걸지 않는다**. + +이유: 반제품의 `item_code`(예: `SEMI-001`)와 수주 품목의 `part_code`(예: `ITEM-001`)가 +서로 다른 값이므로 매칭이 불가능하다. `staticFilters: { product_type: "반제품" }`만 설정. + +```json +{ + "id": "timeline_semi", + "componentType": "v2-timeline-scheduler", + "componentConfig": { + "selectedTable": "production_plan_mng", + "viewMode": "itemGrouped", + "staticFilters": { "product_type": "반제품" }, + "fieldMapping": { "..." : "..." } + } +} +``` + +### 17.5 이벤트 연동 (v2EventBus) + +타임라인 컴포넌트는 `v2EventBus`를 통해 다른 컴포넌트와 통신한다. + +| 이벤트 | 방향 | 설명 | +|--------|------|------| +| `TABLE_SELECTION_CHANGE` | 수신 | 좌측 테이블 행 선택 시 linkedFilter 적용 | +| `TIMELINE_REFRESH` | 발신/수신 | 타임라인 데이터 새로고침 | + +**연결 필터 이벤트 페이로드**: +```typescript +{ + eventType: "TABLE_SELECTION_CHANGE", + source: "grouped_orders", + tableName: "sales_order_mng", + selectedRows: [ + { id: "...", part_code: "ITEM-001", ... }, + { id: "...", part_code: "ITEM-002", ... } + ] +} +``` + +타임라인은 `selectedRows`에서 `linkedFilter.sourceField` 값을 추출하여, +자신의 데이터 중 `linkedFilter.targetField`가 일치하는 항목만 표시. diff --git a/docs/screen-implementation-guide/03_production/production-plan-test-scenario.md b/docs/screen-implementation-guide/03_production/production-plan-test-scenario.md new file mode 100644 index 00000000..538f9e1c --- /dev/null +++ b/docs/screen-implementation-guide/03_production/production-plan-test-scenario.md @@ -0,0 +1,451 @@ +# 생산계획 화면 (TOPSEAL_PP_MAIN) 테스트 시나리오 + +> **화면 URL**: `http://localhost:9771/screens/3985` +> **로그인 정보**: `topseal_admin` / `qlalfqjsgh11` +> **작성일**: 2026-03-16 + +--- + +## 사전 조건 + +- 백엔드 서버 (포트 8080) 실행 중 +- 프론트엔드 서버 (포트 9771) 실행 중 +- `topseal_admin` 계정으로 로그인 완료 +- 사이드바 > 생산관리 > 생산계획 메뉴 클릭하여 화면 진입 + +### 현재 테스트 데이터 현황 + +| 구분 | 건수 | 상세 | +|------|:----:|------| +| 완제품 생산계획 | 7건 | planned(3), in_progress(3), completed(1) | +| 반제품 생산계획 | 6건 | planned(2), in_progress(2), completed(1) | +| 설비(리소스) | 10개 | CNC밀링#1~#2, 머시닝센터#1, 레이저절단기, 프레스기#1, 용접기#1, 도장설비#1, 조립라인#1, 검사대#1~#2 | +| 수주 데이터 | 10건 | sales_order_mng | + +--- + +## TC-01. 화면 레이아웃 확인 + +### 목적 +화면이 설계대로 좌/우 분할 패널로 렌더링되는지 확인 + +### 테스트 단계 +1. 생산계획 화면 진입 +2. 좌측 패널에 "수주 데이터" 탭이 보이는지 확인 +3. 우측 패널에 "완제품" / "반제품" 탭이 보이는지 확인 +4. 분할 패널 비율이 약 45:55인지 확인 + +### 예상 결과 +- [ ] 좌측: "수주데이터" 탭 + "안전재고 부족분" 탭 +- [ ] 우측: "완제품" 탭 + "반제품" 탭 +- [ ] 하단에 버튼들 (새로고침, 자동 스케줄, 병합, 반제품계획, 저장) 표시 +- [ ] 좌측 하단에 "선택 품목 불러오기" 버튼 표시 + +--- + +## TC-02. 좌측 패널 - 수주데이터 그룹 테이블 + +### 목적 +v2-table-grouped 컴포넌트의 그룹화 및 접기/펼치기 기능 확인 + +### 테스트 단계 +1. "수주데이터" 탭 선택 +2. 데이터가 품목코드(part_code) 기준으로 그룹화되었는지 확인 +3. 그룹 헤더 행에 품명, 품목코드가 표시되는지 확인 +4. 그룹 헤더 클릭하여 접기/펼치기 토글 +5. "전체 펼치기" / "전체 접기" 버튼 동작 확인 +6. 그룹별 합계(수주량, 출고량, 잔량) 표시 확인 + +### 예상 결과 +- [ ] 데이터가 part_code 기준으로 그룹화되어 표시 +- [ ] 그룹 헤더에 `{품명} ({품목코드})` 형식으로 표시 +- [ ] 그룹 헤더 클릭 시 하위 행 접기/펼치기 동작 +- [ ] 전체 펼치기/접기 버튼 정상 동작 +- [ ] 그룹별 수주량/출고량/잔량 합계 표시 + +--- + +## TC-03. 좌측 패널 - 체크박스 선택 + +### 목적 +그룹 테이블에서 체크박스 선택이 정상 동작하는지 확인 + +### 테스트 단계 +1. 개별 행 체크박스 선택/해제 +2. 그룹 헤더 체크박스로 그룹 전체 선택/해제 +3. 다른 그룹의 행도 동시 선택 가능한지 확인 +4. 선택된 행이 하이라이트되는지 확인 + +### 예상 결과 +- [ ] 개별 행 체크박스 선택/해제 정상 +- [ ] 그룹 체크박스로 하위 전체 선택/해제 +- [ ] 여러 그룹에서 동시 선택 가능 +- [ ] 선택된 행 시각적 구분 (하이라이트) + +--- + +## TC-04. 우측 패널 - 완제품 타임라인 기본 표시 + +### 목적 +v2-timeline-scheduler의 기본 렌더링 및 데이터 표시 확인 + +### 테스트 단계 +1. "완제품" 탭 선택 (기본 선택) +2. 타임라인 헤더에 날짜가 표시되는지 확인 +3. 리소스(설비) 목록이 좌측에 표시되는지 확인 +4. 스케줄 바가 해당 설비/날짜에 표시되는지 확인 +5. 스케줄 바에 품명이 표시되는지 확인 +6. 오늘 날짜 라인(빨간 세로선)이 표시되는지 확인 + +### 예상 결과 +- [ ] 타임라인 헤더에 날짜 표시 (월 그룹 + 일별) +- [ ] 좌측 리소스 열에 설비명 표시 (프레스기#1, CNC밀링머신#1 등) +- [ ] 7건의 완제품 스케줄 바가 올바른 위치에 표시 +- [ ] 스케줄 바에 item_name 표시 +- [ ] 오늘 날짜 (2026-03-16) 위치에 빨간 세로선 표시 +- [ ] "반제품" 데이터는 보이지 않음 (staticFilters 적용 확인) + +--- + +## TC-05. 타임라인 - 상태별 색상 표시 + +### 목적 +스케줄 상태에 따른 색상 구분 확인 + +### 테스트 단계 +1. 완제품 탭에서 스케줄 바 색상 확인 +2. 각 상태별 색상이 다른지 확인 + +### 예상 결과 +- [ ] `planned` (계획): 파란색 (#3b82f6) +- [ ] `in_progress` (진행): 초록색 (#10b981) +- [ ] `completed` (완료): 회색 (#6b7280) +- [ ] `delayed` (지연): 빨간색 (#ef4444) - 해당 데이터 있으면 +- [ ] 상태별 색상이 명확히 구분됨 + +--- + +## TC-06. 타임라인 - 진행률 표시 + +### 목적 +스케줄 바 내부에 진행률이 시각적으로 표시되는지 확인 + +### 테스트 단계 +1. 진행률이 있는 스케줄 바 확인 +2. 바 내부에 진행률 비율만큼 채워진 영역 확인 +3. 진행률 퍼센트 텍스트 표시 확인 + +### 예상 결과 +- [ ] `탑씰 Type A` (id:103): 40% 진행률 표시 +- [ ] `탑씰 Type B` (id:2): 25% 진행률 표시 +- [ ] `탑씰 Type C` (id:105): 25% 진행률 표시 +- [ ] `탑씰 Type A` (id:4): 100% 진행률 표시 (완료) +- [ ] 바 내부에 진행 영역이 색이 다르게 채워짐 + +--- + +## TC-07. 타임라인 - 줌 레벨 전환 + +### 목적 +일/주/월 줌 레벨 전환이 정상 동작하는지 확인 + +### 테스트 단계 +1. 툴바에서 "주" (기본) 줌 레벨 확인 +2. "일" 줌 레벨로 전환 -> 날짜 간격 변화 확인 +3. "월" 줌 레벨로 전환 -> 날짜 간격 변화 확인 +4. 다시 "주" 줌 레벨로 복귀 + +### 예상 결과 +- [ ] "일" 모드: 날짜 셀이 넓어지고, 하루 단위로 상세 표시 +- [ ] "주" 모드: 기본 크기, 주 단위 표시 +- [ ] "월" 모드: 날짜 셀이 좁아지고, 월 단위로 축소 표시 +- [ ] 줌 레벨 전환 시 스케줄 바 위치/크기가 자동 조정 + +--- + +## TC-08. 타임라인 - 날짜 네비게이션 + +### 목적 +이전/다음/오늘 버튼으로 타임라인 이동이 정상 동작하는지 확인 + +### 테스트 단계 +1. 툴바에서 현재 표시 날짜 확인 +2. "다음" 버튼 클릭 -> 다음 주(또는 기간)로 이동 +3. "이전" 버튼 클릭 -> 이전 주로 이동 +4. "오늘" 버튼 클릭 -> 현재 날짜 영역으로 이동 +5. 2월 초 데이터가 있으므로 충분히 이전으로 이동하여 과거 데이터 확인 + +### 예상 결과 +- [ ] "다음" 클릭 시 타임라인이 오른쪽(미래)으로 이동 +- [ ] "이전" 클릭 시 타임라인이 왼쪽(과거)으로 이동 +- [ ] "오늘" 클릭 시 2026-03-16 부근으로 이동 +- [ ] 날짜 헤더의 표시 날짜가 변경됨 +- [ ] 이동 후에도 스케줄 바가 올바른 위치에 표시 + +--- + +## TC-09. 타임라인 - 드래그 이동 + +### 목적 +스케줄 바를 드래그하여 날짜를 변경하는 기능 확인 + +### 테스트 단계 +1. 완제품 탭에서 `planned` 상태의 스케줄 바 선택 (예: 탑씰 Type A, id:106) +2. 스케줄 바를 마우스로 클릭하고 좌/우로 드래그 +3. 드래그 중 바가 마우스를 따라 이동하는지 확인 (시각적 피드백) +4. 마우스 놓기 후 결과 확인 +5. 성공 시 토스트 알림 확인 +6. DB에 start_date/end_date가 변경되었는지 확인 + +### 예상 결과 +- [ ] 스케줄 바 드래그 시 시각적으로 이동 (opacity 변화) +- [ ] 드래그 완료 후 "스케줄이 이동되었습니다" 토스트 표시 +- [ ] 날짜가 드래그 거리만큼 변경 (시작일/종료일 동일 간격 유지) +- [ ] 실패 시 "스케줄 이동 실패" 에러 토스트 표시 후 원래 위치로 복귀 + +--- + +## TC-10. 타임라인 - 리사이즈 (기간 조정) + +### 목적 +스케줄 바의 시작/종료 핸들을 드래그하여 기간을 변경하는 기능 확인 + +### 테스트 단계 +1. 완제품 탭에서 스케줄 바에 마우스 호버 +2. 바 좌측/우측에 리사이즈 핸들이 나타나는지 확인 +3. 우측 핸들을 오른쪽으로 드래그 -> 종료일 연장 +4. 좌측 핸들을 오른쪽으로 드래그 -> 시작일 변경 +5. 성공 시 토스트 알림 확인 + +### 예상 결과 +- [ ] 바 호버 시 좌/우측에 리사이즈 핸들(세로 바) 표시 +- [ ] 우측 핸들 드래그 시 종료일만 변경 (시작일 유지) +- [ ] 좌측 핸들 드래그 시 시작일만 변경 (종료일 유지) +- [ ] 리사이즈 완료 후 "스케줄 기간이 변경되었습니다" 토스트 표시 +- [ ] 바 크기가 변경된 기간에 맞게 조정 + +--- + +## TC-11. 타임라인 - 충돌 감지 + +### 목적 +같은 설비에 시간이 겹치는 스케줄이 있을 때 충돌 표시가 되는지 확인 + +### 테스트 단계 +1. 충돌 데이터 확인: + - 프레스기#1 (equipment_id=11): id:103 (03/10~03/17), id:4 (01/28~01/30) → 겹치지 않아서 충돌 없음 + - 조립라인#1 (equipment_id=14): id:5 (02/01~02/02), id:6 (02/01~02/02) → 기간 겹침! (반제품) +2. 반제품 탭으로 이동하여 조립라인#1의 충돌 확인 +3. 또는 드래그로 충돌 상황을 만들어서 확인 + +### 예상 결과 +- [ ] 충돌 스케줄 바에 빨간 외곽선 (`ring-destructive`) 표시 +- [ ] 충돌 스케줄 바에 경고 아이콘 (AlertTriangle) 표시 +- [ ] 툴바에 "충돌 N건" 배지 표시 (빨간색) +- [ ] 충돌이 없는 경우 배지 미표시 + +--- + +## TC-12. 타임라인 - 범례 (Legend) + +### 목적 +하단 범례가 정상 표시되는지 확인 + +### 테스트 단계 +1. 타임라인 하단에 범례 영역이 표시되는지 확인 +2. 상태별 색상 스와치가 표시되는지 확인 +3. 마일스톤 아이콘이 표시되는지 확인 +4. 충돌 표시 범례가 표시되는지 확인 + +### 예상 결과 +- [ ] "계획" (파란색), "진행" (초록색), "완료" (회색), "지연" (빨간색), "취소" (연회색) 표시 +- [ ] "마일스톤" 다이아몬드 아이콘 표시 +- [ ] "충돌" 빨간 테두리 아이콘 표시 (showConflicts 설정 시) +- [ ] 범례가 타임라인 하단에 깔끔하게 배치 + +--- + +## TC-13. 반제품 탭 전환 + +### 목적 +반제품 탭으로 전환 시 반제품 데이터만 필터링되어 표시되는지 확인 (staticFilters) + +### 테스트 단계 +1. 우측 패널에서 "반제품" 탭 클릭 +2. 표시되는 스케줄이 반제품만인지 확인 +3. 완제품 데이터가 보이지 않는지 확인 +4. 다시 "완제품" 탭 클릭하여 전환 확인 + +### 예상 결과 +- [ ] "반제품" 탭 클릭 시 반제품 스케줄만 표시 (4건) +- [ ] 반제품 리소스: 조립라인#1, 용접기#1, 레이저절단기 +- [ ] 완제품 데이터는 표시되지 않음 +- [ ] "완제품" 탭 복귀 시 완제품 데이터만 표시 + +--- + +## TC-14. 버튼 - 새로고침 + +### 목적 +"새로고침" 버튼 클릭 시 데이터가 다시 로드되는지 확인 + +### 테스트 단계 +1. 우측 패널 하단의 "새로고침" 버튼 클릭 +2. 타임라인 데이터가 다시 로드되는지 확인 +3. 토스트 알림 확인 + +### 예상 결과 +- [ ] 클릭 시 API 호출 (GET /api/production/order-summary) +- [ ] 성공 시 "데이터를 새로고침했습니다." 토스트 표시 +- [ ] 타임라인 데이터 갱신 + +--- + +## TC-15. 버튼 - 자동 스케줄 + +### 목적 +좌측 테이블에서 수주 데이터를 선택한 후 자동 스케줄 생성이 되는지 확인 + +### 테스트 단계 +1. 좌측 패널에서 수주 데이터 행 1개 이상 체크박스 선택 +2. "자동 스케줄" 버튼 클릭 +3. 확인 다이얼로그 표시 확인 ("선택한 품목의 자동 스케줄을 생성하시겠습니까?") +4. "확인" 클릭 +5. 결과 확인 + +### 예상 결과 +- [ ] 확인 다이얼로그 표시 +- [ ] 성공 시 "자동 스케줄이 생성되었습니다." 토스트 표시 +- [ ] 우측 타임라인에 새로운 스케줄 바 추가 +- [ ] 실패 시 에러 메시지 표시 +- [ ] 선택 없이 클릭 시 적절한 안내 메시지 + +--- + +## TC-16. 버튼 - 선택 품목 불러오기 + +### 목적 +좌측 수주 데이터에서 선택한 품목을 생산계획으로 불러오는 기능 확인 + +### 테스트 단계 +1. 좌측 수주데이터 탭에서 품목 선택 (체크박스) +2. "선택 품목 불러오기" 버튼 클릭 +3. 확인 다이얼로그 ("선택한 품목의 생산계획을 생성하시겠습니까?") +4. 결과 확인 + +### 예상 결과 +- [ ] 확인 다이얼로그 표시 +- [ ] 성공 시 "선택 품목이 불러와졌습니다." 토스트 표시 +- [ ] 타임라인 자동 새로고침 + +--- + +## TC-17. 버튼 - 저장 + +### 목적 +변경된 생산계획 데이터가 저장되는지 확인 + +### 테스트 단계 +1. 타임라인에서 스케줄 바 드래그 또는 리사이즈로 데이터 변경 +2. "저장" 버튼 클릭 +3. 저장 결과 확인 + +### 예상 결과 +- [ ] 성공 시 "생산계획이 저장되었습니다." 토스트 표시 +- [ ] 변경 사항이 DB에 반영 + +--- + +## TC-18. 반응형 CSS 확인 + +### 목적 +공통 반응형 CSS가 올바르게 적용되었는지 확인 + +### 테스트 단계 +1. 브라우저 창 너비를 640px 이하로 줄이기 (모바일) +2. 텍스트 크기, 버튼 크기, 패딩 변화 확인 +3. 브라우저 창 너비를 1280px 이상으로 늘리기 (데스크톱) +4. 원래 크기로 복귀 확인 + +### 예상 결과 +- [ ] 모바일(~640px): 텍스트 `text-[10px]`, 작은 버튼, 좁은 패딩 +- [ ] 데스크톱(640px~): 텍스트 `text-sm`, 기본 버튼, 넓은 패딩 +- [ ] 줌 버튼, 네비게이션 버튼, 리소스명, 날짜 헤더 모두 반응형 적용 +- [ ] 스케줄 바 내부 텍스트도 반응형 (text-[10px] sm:text-xs) +- [ ] 범례 텍스트도 반응형 + +--- + +## TC-19. 마일스톤 표시 + +### 목적 +시작일과 종료일이 같은 스케줄이 마일스톤(다이아몬드)으로 표시되는지 확인 + +### 테스트 단계 +1. DB에 마일스톤 테스트 데이터 추가: + ```sql + INSERT INTO production_plan_mng (id, item_name, product_type, status, start_date, end_date, equipment_id, progress_rate, company_code) + VALUES (200, '마일스톤 테스트', '완제품', 'planned', '2026-03-20', '2026-03-20', 9, '0', 'COMPANY_7'); + ``` +2. 새로고침 후 해당 날짜에 다이아몬드 마커가 표시되는지 확인 +3. 호버 시 정보 표시 확인 + +### 예상 결과 +- [ ] 시작일 = 종료일인 스케줄은 바 대신 다이아몬드 마커로 표시 +- [ ] 다이아몬드가 45도 회전된 정사각형으로 표시 +- [ ] 호버 시 효과 적용 + +--- + +## TC-20. 안전재고 부족분 탭 + +### 목적 +좌측 패널의 "안전재고 부족분" 탭이 정상 동작하는지 확인 + +### 테스트 단계 +1. 좌측 패널에서 "안전재고 부족분" 탭 클릭 +2. inventory_stock 테이블 데이터가 표시되는지 확인 +3. 빈 데이터인 경우 빈 상태 메시지 확인 + +### 예상 결과 +- [ ] 탭 전환 정상 동작 +- [ ] 데이터 있으면: 품목코드, 현재고, 안전재고, 창고, 최근입고일 표시 +- [ ] 데이터 없으면: "안전재고 부족분 데이터가 없습니다" 메시지 + +--- + +## 알려진 이슈 / 참고 사항 + +| 번호 | 내용 | 심각도 | +|:----:|------|:------:| +| 1 | "1 Issue" 배지가 화면 좌측 하단에 표시됨 (원인 미확인) | 낮음 | +| 2 | 생산계획 화면 URL 직접 접근 시 회사정보 화면(138)이 먼저 보일 수 있음 → 사이드바 메뉴를 통해 접근 권장 | 중간 | +| 3 | 설비(equipment_info)의 equipment_group이 null → 리소스 그룹핑 미표시 | 낮음 | +| 4 | 가상 스크롤은 리소스(설비) 30개 이상일 때 자동 활성화 (현재 10개라 비활성) | 참고 | + +--- + +## 테스트 결과 요약 + +| TC | 항목 | 결과 | 비고 | +|:--:|------|:----:|------| +| 01 | 화면 레이아웃 | | | +| 02 | 수주데이터 그룹 테이블 | | | +| 03 | 체크박스 선택 | | | +| 04 | 완제품 타임라인 기본 표시 | | | +| 05 | 상태별 색상 | | | +| 06 | 진행률 표시 | | | +| 07 | 줌 레벨 전환 | | | +| 08 | 날짜 네비게이션 | | | +| 09 | 드래그 이동 | | | +| 10 | 리사이즈 | | | +| 11 | 충돌 감지 | | | +| 12 | 범례 | | | +| 13 | 반제품 탭 전환 | | | +| 14 | 새로고침 버튼 | | | +| 15 | 자동 스케줄 버튼 | | | +| 16 | 선택 품목 불러오기 | | | +| 17 | 저장 버튼 | | | +| 18 | 반응형 CSS | | | +| 19 | 마일스톤 표시 | | | +| 20 | 안전재고 부족분 탭 | | | diff --git a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts index d9f40aca..e2f415e7 100644 --- a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts +++ b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts @@ -153,15 +153,37 @@ export function useGroupedData( } ); - const responseData = response.data?.data?.data || response.data?.data || []; - setRawData(Array.isArray(responseData) ? responseData : []); + let responseData = response.data?.data?.data || response.data?.data || []; + responseData = Array.isArray(responseData) ? responseData : []; + + // dataFilter 적용 (클라이언트 사이드 필터링) + if (config.dataFilter && config.dataFilter.length > 0) { + responseData = responseData.filter((item: any) => { + return config.dataFilter!.every((f) => { + const val = item[f.column]; + switch (f.operator) { + case "eq": return val === f.value; + case "ne": return f.value === null ? (val !== null && val !== undefined && val !== "") : val !== f.value; + case "gt": return Number(val) > Number(f.value); + case "lt": return Number(val) < Number(f.value); + case "gte": return Number(val) >= Number(f.value); + case "lte": return Number(val) <= Number(f.value); + case "like": return String(val ?? "").includes(String(f.value)); + case "in": return Array.isArray(f.value) ? f.value.includes(val) : false; + default: return true; + } + }); + }); + } + + setRawData(responseData); } catch (err: any) { setError(err.message || "데이터 로드 중 오류 발생"); setRawData([]); } finally { setIsLoading(false); } - }, [tableName, externalData, searchFilters]); + }, [tableName, externalData, searchFilters, config.dataFilter]); // 초기 데이터 로드 useEffect(() => { diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index 354869bc..3a69da65 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, @@ -9,18 +9,27 @@ import { Loader2, ZoomIn, ZoomOut, + Package, + Zap, + RefreshCw, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; -import { TimelineHeader, ResourceRow, TimelineLegend } from "./components"; -import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config"; +import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components"; +import { zoomLevelOptions, defaultTimelineSchedulerConfig, statusOptions } from "./config"; import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection"; +import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; +import { apiClient } from "@/lib/api/client"; + +// 가상 스크롤 활성화 임계값 (리소스 수) +const VIRTUAL_THRESHOLD = 30; /** * v2-timeline-scheduler 메인 컴포넌트 @@ -44,9 +53,59 @@ export function TimelineSchedulerComponent({ }: TimelineSchedulerComponentProps) { const containerRef = useRef(null); + // ────────── 자동 스케줄 생성 상태 ────────── + const [showPreviewDialog, setShowPreviewDialog] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewApplying, setPreviewApplying] = useState(false); + const [previewSummary, setPreviewSummary] = useState(null); + const [previewItems, setPreviewItems] = useState([]); + const [previewDeleted, setPreviewDeleted] = useState([]); + const [previewKept, setPreviewKept] = useState([]); + const linkedFilterValuesRef = useRef([]); + + // ────────── 반제품 계획 생성 상태 ────────── + const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false); + const [semiPreviewLoading, setSemiPreviewLoading] = useState(false); + const [semiPreviewApplying, setSemiPreviewApplying] = useState(false); + const [semiPreviewSummary, setSemiPreviewSummary] = useState(null); + const [semiPreviewItems, setSemiPreviewItems] = useState([]); + const [semiPreviewDeleted, setSemiPreviewDeleted] = useState([]); + const [semiPreviewKept, setSemiPreviewKept] = useState([]); + + // ────────── linkedFilter 상태 ────────── + const linkedFilter = config.linkedFilter; + const hasLinkedFilter = !!linkedFilter; + const [linkedFilterValues, setLinkedFilterValues] = useState([]); + const [hasReceivedSelection, setHasReceivedSelection] = useState(false); + + // linkedFilter 이벤트 수신 + useEffect(() => { + if (!hasLinkedFilter) return; + + const handler = (event: any) => { + if (linkedFilter!.sourceTableName && event.tableName !== linkedFilter!.sourceTableName) return; + if (linkedFilter!.sourceComponentId && event.componentId !== linkedFilter!.sourceComponentId) return; + + const selectedRows: any[] = event.selectedRows || []; + const sourceField = linkedFilter!.sourceField; + + const values = selectedRows + .map((row: any) => String(row[sourceField] ?? "")) + .filter((v: string) => v !== "" && v !== "undefined" && v !== "null"); + + const uniqueValues = [...new Set(values)]; + setLinkedFilterValues(uniqueValues); + setHasReceivedSelection(true); + linkedFilterValuesRef.current = selectedRows; + }; + + const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, handler); + return unsubscribe; + }, [hasLinkedFilter, linkedFilter]); + // 타임라인 데이터 훅 const { - schedules, + schedules: rawSchedules, resources, isLoading: hookLoading, error: hookError, @@ -58,8 +117,21 @@ export function TimelineSchedulerComponent({ goToNext, goToToday, updateSchedule, + refresh: refreshTimeline, } = useTimelineData(config, externalSchedules, externalResources); + // linkedFilter 적용: 선택된 값으로 스케줄 필터링 + const schedules = useMemo(() => { + if (!hasLinkedFilter) return rawSchedules; + if (linkedFilterValues.length === 0) return []; + + const targetField = linkedFilter!.targetField; + return rawSchedules.filter((s) => { + const val = String((s.data as any)?.[targetField] ?? (s as any)[targetField] ?? ""); + return linkedFilterValues.includes(val); + }); + }, [rawSchedules, hasLinkedFilter, linkedFilterValues, linkedFilter]); + const isLoading = externalLoading ?? hookLoading; const error = externalError ?? hookError; @@ -267,11 +339,212 @@ export function TimelineSchedulerComponent({ } }, [onAddSchedule, effectiveResources]); + // ────────── 자동 스케줄 생성: 미리보기 요청 ────────── + const handleAutoSchedulePreview = useCallback(async () => { + const selectedRows = linkedFilterValuesRef.current; + if (!selectedRows || selectedRows.length === 0) { + toast.warning("좌측에서 품목을 선택해주세요"); + return; + } + + const sourceField = config.linkedFilter?.sourceField || "part_code"; + const grouped = new Map(); + selectedRows.forEach((row: any) => { + const key = row[sourceField] || ""; + if (!key) return; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(row); + }); + + const items = Array.from(grouped.entries()).map(([itemCode, rows]) => { + const totalBalanceQty = rows.reduce((sum: number, r: any) => sum + (Number(r.balance_qty) || 0), 0); + const earliestDueDate = rows + .map((r: any) => r.due_date) + .filter(Boolean) + .sort()[0] || new Date().toISOString().split("T")[0]; + const first = rows[0]; + + return { + item_code: itemCode, + item_name: first.part_name || first.item_name || itemCode, + required_qty: totalBalanceQty, + earliest_due_date: typeof earliestDueDate === "string" ? earliestDueDate.split("T")[0] : earliestDueDate, + hourly_capacity: Number(first.hourly_capacity) || undefined, + daily_capacity: Number(first.daily_capacity) || undefined, + }; + }).filter((item) => item.required_qty > 0); + + if (items.length === 0) { + toast.warning("선택된 품목의 잔량이 없습니다"); + return; + } + + setShowPreviewDialog(true); + setPreviewLoading(true); + + try { + const response = await apiClient.post("/production/generate-schedule/preview", { + items, + options: { + product_type: config.staticFilters?.product_type || "완제품", + safety_lead_time: 1, + recalculate_unstarted: true, + }, + }); + + if (response.data?.success) { + setPreviewSummary(response.data.data.summary); + setPreviewItems(response.data.data.previews); + setPreviewDeleted(response.data.data.deletedSchedules || []); + setPreviewKept(response.data.data.keptSchedules || []); + } else { + toast.error("미리보기 생성 실패"); + setShowPreviewDialog(false); + } + } catch (err: any) { + toast.error("미리보기 요청 실패", { description: err.message }); + setShowPreviewDialog(false); + } finally { + setPreviewLoading(false); + } + }, [config.linkedFilter, config.staticFilters]); + + // ────────── 자동 스케줄 생성: 확인 및 적용 ────────── + const handleAutoScheduleApply = useCallback(async () => { + if (!previewItems || previewItems.length === 0) return; + + setPreviewApplying(true); + + const items = previewItems.map((p: any) => ({ + item_code: p.item_code, + item_name: p.item_name, + required_qty: p.required_qty, + earliest_due_date: p.due_date, + hourly_capacity: p.hourly_capacity, + daily_capacity: p.daily_capacity, + })); + + try { + const response = await apiClient.post("/production/generate-schedule", { + items, + options: { + product_type: config.staticFilters?.product_type || "완제품", + safety_lead_time: 1, + recalculate_unstarted: true, + }, + }); + + if (response.data?.success) { + const summary = response.data.data.summary; + toast.success("생산계획 업데이트 완료", { + description: `신규: ${summary.new_count}건, 유지: ${summary.kept_count}건, 삭제: ${summary.deleted_count}건`, + }); + setShowPreviewDialog(false); + refreshTimeline(); + } else { + toast.error("생산계획 생성 실패"); + } + } catch (err: any) { + toast.error("생산계획 생성 실패", { description: err.message }); + } finally { + setPreviewApplying(false); + } + }, [previewItems, config.staticFilters, refreshTimeline]); + + // ────────── 반제품 계획 생성: 미리보기 요청 ────────── + const handleSemiSchedulePreview = useCallback(async () => { + // 현재 타임라인에 표시된 완제품 스케줄의 plan ID 수집 + const finishedSchedules = schedules.filter((s) => { + const productType = (s.data as any)?.product_type || ""; + return productType === "완제품"; + }); + + if (finishedSchedules.length === 0) { + toast.warning("완제품 스케줄이 없습니다. 먼저 완제품 계획을 생성해주세요."); + return; + } + + const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); + if (planIds.length === 0) { + toast.warning("유효한 완제품 계획 ID가 없습니다"); + return; + } + + setShowSemiPreviewDialog(true); + setSemiPreviewLoading(true); + + try { + const response = await apiClient.post("/production/generate-semi-schedule/preview", { + plan_ids: planIds, + options: { considerStock: true }, + }); + + if (response.data?.success) { + setSemiPreviewSummary(response.data.data.summary); + setSemiPreviewItems(response.data.data.previews || []); + setSemiPreviewDeleted(response.data.data.deletedSchedules || []); + setSemiPreviewKept(response.data.data.keptSchedules || []); + } else { + toast.error("반제품 미리보기 실패", { description: response.data?.message }); + setShowSemiPreviewDialog(false); + } + } catch (err: any) { + toast.error("반제품 미리보기 요청 실패", { description: err.message }); + setShowSemiPreviewDialog(false); + } finally { + setSemiPreviewLoading(false); + } + }, [schedules]); + + // ────────── 반제품 계획 생성: 확인 및 적용 ────────── + const handleSemiScheduleApply = useCallback(async () => { + const finishedSchedules = schedules.filter((s) => { + const productType = (s.data as any)?.product_type || ""; + return productType === "완제품"; + }); + const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); + + if (planIds.length === 0) return; + + setSemiPreviewApplying(true); + + try { + const response = await apiClient.post("/production/generate-semi-schedule", { + plan_ids: planIds, + options: { considerStock: true }, + }); + + if (response.data?.success) { + const data = response.data.data; + toast.success("반제품 계획 생성 완료", { + description: `${data.count}건의 반제품 계획이 생성되었습니다`, + }); + setShowSemiPreviewDialog(false); + refreshTimeline(); + } else { + toast.error("반제품 계획 생성 실패"); + } + } catch (err: any) { + toast.error("반제품 계획 생성 실패", { description: err.message }); + } finally { + setSemiPreviewApplying(false); + } + }, [schedules, refreshTimeline]); + // ────────── 하단 영역 높이 계산 (툴바 + 범례) ────────── const showToolbar = config.showToolbar !== false; const showLegend = config.showLegend !== false; - const toolbarHeight = showToolbar ? 36 : 0; - const legendHeight = showLegend ? 28 : 0; + + // ────────── 가상 스크롤 ────────── + const scrollContainerRef = useRef(null); + const useVirtual = effectiveResources.length >= VIRTUAL_THRESHOLD; + + const virtualizer = useVirtualizer({ + count: effectiveResources.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => rowHeight, + overscan: 5, + }); // ────────── 디자인 모드 플레이스홀더 ────────── if (isDesignMode) { @@ -320,8 +593,49 @@ export function TimelineSchedulerComponent({ ); } - // ────────── 데이터 없음 ────────── - if (schedules.length === 0) { + // ────────── linkedFilter 빈 상태 (itemGrouped가 아닌 경우만 early return) ────────── + // itemGrouped 모드에서는 툴바를 항상 보여주기 위해 여기서 return하지 않음 + if (config.viewMode !== "itemGrouped") { + if (hasLinkedFilter && !hasReceivedSelection) { + const emptyMsg = linkedFilter?.emptyMessage || "좌측 목록에서 품목 또는 수주를 선택하세요"; + return ( +
+
+ +

{emptyMsg}

+

+ 선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다 +

+
+
+ ); + } + + if (hasLinkedFilter && hasReceivedSelection && schedules.length === 0) { + return ( +
+
+ +

+ 선택한 항목에 대한 스케줄이 없습니다 +

+

+ 다른 품목을 선택하거나 스케줄을 생성해 주세요 +

+
+
+ ); + } + } + + // ────────── 데이터 없음 (linkedFilter 없고 itemGrouped가 아닌 경우) ────────── + if (schedules.length === 0 && config.viewMode !== "itemGrouped") { return (
+ {/* 툴바: 액션 버튼 + 네비게이션 */} + {showToolbar && ( +
+ {/* 네비게이션 */} +
+ {config.showNavigation !== false && ( + <> + + + + + )} + + {viewStartDate.toLocaleDateString("ko-KR")} ~ {viewEndDate.toLocaleDateString("ko-KR")} + +
+ + {/* 줌 + 액션 버튼 */} +
+ {config.showZoomControls !== false && ( + <> + + + {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + + +
+ + )} + + + {config.staticFilters?.product_type === "완제품" && ( + <> + + + + )} +
+
+ )} + + {/* 범례 */} + {showLegend && ( +
+ 생산 상태: + {statusOptions.map((s) => ( +
+
+ {s.label} +
+ ))} + 납기: +
+
+ 납기일 +
+
+ )} + + {/* 품목별 카드 목록 또는 빈 상태 */} + {itemGroups.length > 0 ? ( +
+ {itemGroups.map((group) => ( + + ))} +
+ ) : ( +
+
+ {hasLinkedFilter && !hasReceivedSelection ? ( + <> + +

+ {linkedFilter?.emptyMessage || "좌측 목록에서 품목을 선택하세요"} +

+

+ 선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다 +

+ + ) : hasLinkedFilter && hasReceivedSelection ? ( + <> + +

+ 선택한 항목에 대한 스케줄이 없습니다 +

+

+ 위 "자동 스케줄 생성" 버튼으로 생산계획을 생성하세요 +

+ + ) : ( + <> + +

스케줄 데이터가 없습니다

+ + )} +
+
+ )} + + {/* 완제품 스케줄 생성 미리보기 다이얼로그 */} + + + {/* 반제품 계획 생성 미리보기 다이얼로그 */} + +
+ ); + } + + // ────────── 메인 렌더링 (리소스 기반) ────────── return (
+
{/* 헤더 */} - {/* 리소스 행들 */} -
- {effectiveResources.map((resource) => ( - - ))} -
+ {/* 리소스 행들 - 30개 이상이면 가상 스크롤 */} + {useVirtual ? ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const resource = effectiveResources[virtualRow.index]; + return ( +
+ +
+ ); + })} +
+ ) : ( +
+ {effectiveResources.map((resource) => ( + + ))} +
+ )}
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx new file mode 100644 index 00000000..01e72a1c --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React, { useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { Flame } from "lucide-react"; +import { ScheduleItem, TimelineSchedulerConfig, ZoomLevel } from "../types"; +import { statusOptions, dayLabels } from "../config"; + +interface ItemScheduleGroup { + itemCode: string; + itemName: string; + hourlyCapacity: number; + dailyCapacity: number; + schedules: ScheduleItem[]; + totalPlanQty: number; + totalCompletedQty: number; + remainingQty: number; + dueDates: { date: string; isUrgent: boolean }[]; +} + +interface ItemTimelineCardProps { + group: ItemScheduleGroup; + viewStartDate: Date; + viewEndDate: Date; + zoomLevel: ZoomLevel; + cellWidth: number; + config: TimelineSchedulerConfig; + onScheduleClick?: (schedule: ScheduleItem) => void; +} + +const toDateString = (d: Date) => d.toISOString().split("T")[0]; + +const addDays = (d: Date, n: number) => { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; +}; + +const diffDays = (a: Date, b: Date) => + Math.round((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)); + +function generateDateCells(start: Date, end: Date) { + const cells: { date: Date; label: string; dayLabel: string; isWeekend: boolean; isToday: boolean; dateStr: string }[] = []; + const today = toDateString(new Date()); + let cur = new Date(start); + while (cur <= end) { + const d = new Date(cur); + const dow = d.getDay(); + cells.push({ + date: d, + label: String(d.getDate()), + dayLabel: dayLabels[dow], + isWeekend: dow === 0 || dow === 6, + isToday: toDateString(d) === today, + dateStr: toDateString(d), + }); + cur = addDays(cur, 1); + } + return cells; +} + +export function ItemTimelineCard({ + group, + viewStartDate, + viewEndDate, + zoomLevel, + cellWidth, + config, + onScheduleClick, +}: ItemTimelineCardProps) { + const scrollRef = useRef(null); + + const dateCells = useMemo( + () => generateDateCells(viewStartDate, viewEndDate), + [viewStartDate, viewEndDate] + ); + + const totalWidth = dateCells.length * cellWidth; + + const dueDateSet = useMemo(() => { + const set = new Set(); + group.dueDates.forEach((d) => set.add(d.date)); + return set; + }, [group.dueDates]); + + const urgentDateSet = useMemo(() => { + const set = new Set(); + group.dueDates.filter((d) => d.isUrgent).forEach((d) => set.add(d.date)); + return set; + }, [group.dueDates]); + + const statusColor = (status: string) => + config.statusColors?.[status as keyof typeof config.statusColors] || + statusOptions.find((s) => s.value === status)?.color || + "#3b82f6"; + + const isUrgentItem = group.dueDates.some((d) => d.isUrgent); + const hasRemaining = group.remainingQty > 0; + + return ( +
+ {/* 품목 헤더 */} +
+
+ +
+

{group.itemCode}

+

{group.itemName}

+
+
+
+

+ 시간당: {group.hourlyCapacity.toLocaleString()} EA +

+

+ 일일: {group.dailyCapacity.toLocaleString()} EA +

+
+
+ + {/* 타임라인 영역 */} +
+
+ {/* 날짜 헤더 */} +
+ {dateCells.map((cell) => { + const isDueDate = dueDateSet.has(cell.dateStr); + const isUrgentDate = urgentDateSet.has(cell.dateStr); + return ( +
+ + {cell.label} + + + {cell.dayLabel} + +
+ ); + })} +
+ + {/* 스케줄 바 영역 */} +
+ {group.schedules.map((schedule) => { + const schedStart = new Date(schedule.startDate); + const schedEnd = new Date(schedule.endDate); + + const startOffset = diffDays(schedStart, viewStartDate); + const endOffset = diffDays(schedEnd, viewStartDate); + + const left = Math.max(0, startOffset * cellWidth); + const right = Math.min(totalWidth, (endOffset + 1) * cellWidth); + const width = Math.max(cellWidth * 0.5, right - left); + + if (right < 0 || left > totalWidth) return null; + + const qty = Number(schedule.data?.plan_qty) || 0; + const color = statusColor(schedule.status); + + return ( +
onScheduleClick?.(schedule)} + title={`${schedule.title} (${schedule.startDate} ~ ${schedule.endDate})`} + > +
+ {qty > 0 ? `${qty.toLocaleString()} EA` : schedule.title} +
+
+ ); + })} + + {/* 납기일 마커 */} + {group.dueDates.map((dueDate, idx) => { + const d = new Date(dueDate.date); + const offset = diffDays(d, viewStartDate); + if (offset < 0 || offset > dateCells.length) return null; + const left = offset * cellWidth + cellWidth / 2; + return ( +
+
+
+ ); + })} +
+
+
+ + {/* 하단 잔량 영역 */} +
+ + {hasRemaining && ( +
+ {isUrgentItem && } + {group.remainingQty.toLocaleString()} EA +
+ )} + {/* 스크롤 인디케이터 */} +
+
+
+
+
+ ); +} + +/** + * 스케줄 데이터를 품목별로 그룹화 + */ +export function groupSchedulesByItem(schedules: ScheduleItem[]): ItemScheduleGroup[] { + const grouped = new Map(); + + schedules.forEach((s) => { + const key = s.data?.item_code || "unknown"; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(s); + }); + + const result: ItemScheduleGroup[] = []; + + grouped.forEach((items, itemCode) => { + const first = items[0]; + const hourlyCapacity = Number(first.data?.hourly_capacity) || 0; + const dailyCapacity = Number(first.data?.daily_capacity) || 0; + const totalPlanQty = items.reduce((sum, s) => sum + (Number(s.data?.plan_qty) || 0), 0); + const totalCompletedQty = items.reduce((sum, s) => sum + (Number(s.data?.completed_qty) || 0), 0); + + const dueDates: { date: string; isUrgent: boolean }[] = []; + const seenDueDates = new Set(); + items.forEach((s) => { + const dd = s.data?.due_date; + if (dd) { + const dateStr = typeof dd === "string" ? dd.split("T")[0] : ""; + if (dateStr && !seenDueDates.has(dateStr)) { + seenDueDates.add(dateStr); + const isUrgent = s.data?.priority === "urgent" || s.data?.priority === "high"; + dueDates.push({ date: dateStr, isUrgent }); + } + } + }); + + result.push({ + itemCode, + itemName: first.data?.item_name || first.title || itemCode, + hourlyCapacity, + dailyCapacity, + schedules: items.sort( + (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + ), + totalPlanQty, + totalCompletedQty, + remainingQty: totalPlanQty - totalCompletedQty, + dueDates: dueDates.sort((a, b) => a.date.localeCompare(b.date)), + }); + }); + + return result.sort((a, b) => a.itemCode.localeCompare(b.itemCode)); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/SchedulePreviewDialog.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/SchedulePreviewDialog.tsx new file mode 100644 index 00000000..ab130659 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/SchedulePreviewDialog.tsx @@ -0,0 +1,282 @@ +"use client"; + +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Loader2, AlertTriangle, Check, X, Trash2, Play } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { statusOptions } from "../config"; + +interface PreviewItem { + item_code: string; + item_name: string; + required_qty: number; + daily_capacity: number; + hourly_capacity: number; + production_days: number; + start_date: string; + end_date: string; + due_date: string; + order_count: number; + status: string; +} + +interface ExistingSchedule { + id: string; + plan_no: string; + item_code: string; + item_name: string; + plan_qty: string; + start_date: string; + end_date: string; + status: string; + completed_qty?: string; +} + +interface PreviewSummary { + total: number; + new_count: number; + kept_count: number; + deleted_count: number; +} + +interface SchedulePreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + isLoading: boolean; + summary: PreviewSummary | null; + previews: PreviewItem[]; + deletedSchedules: ExistingSchedule[]; + keptSchedules: ExistingSchedule[]; + onConfirm: () => void; + isApplying: boolean; + title?: string; + description?: string; +} + +const summaryCards = [ + { key: "total", label: "총 계획", color: "bg-primary/10 text-primary" }, + { key: "new_count", label: "신규 입력", color: "bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400" }, + { key: "deleted_count", label: "삭제될", color: "bg-destructive/10 text-destructive" }, + { key: "kept_count", label: "유지(진행중)", color: "bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-400" }, +]; + +function formatDate(d: string | null | undefined): string { + if (!d) return "-"; + const s = typeof d === "string" ? d : String(d); + return s.split("T")[0]; +} + +export function SchedulePreviewDialog({ + open, + onOpenChange, + isLoading, + summary, + previews, + deletedSchedules, + keptSchedules, + onConfirm, + isApplying, + title, + description, +}: SchedulePreviewDialogProps) { + return ( + + + + + {title || "생산계획 변경사항 확인"} + + + {description || "변경사항을 확인해주세요"} + + + + {isLoading ? ( +
+ + 미리보기 생성 중... +
+ ) : summary ? ( +
+ {/* 경고 배너 */} +
+ +
+

변경사항을 확인해주세요

+

+ 아래 변경사항을 검토하신 후 확인 버튼을 눌러주시면 생산계획이 업데이트됩니다. +

+
+
+ + {/* 요약 카드 */} +
+ {summaryCards.map((card) => ( +
+

+ {(summary as any)[card.key] ?? 0} +

+

{card.label}

+
+ ))} +
+ + {/* 신규 생성 목록 */} + {previews.length > 0 && ( +
+

+ + 신규 생성되는 계획 ({previews.length}건) +

+
+ {previews.map((item, idx) => { + const statusInfo = statusOptions.find((s) => s.value === item.status); + return ( +
+
+

+ {item.item_code} - {item.item_name} +

+ + {statusInfo?.label || item.status} + +
+

+ 수량: {(item.required_qty || (item as any).plan_qty || 0).toLocaleString()} EA +

+
+ 시작일: {formatDate(item.start_date)} + 종료일: {formatDate(item.end_date)} +
+ {item.order_count ? ( +

+ {item.order_count}건 수주 통합 (총 {item.required_qty.toLocaleString()} EA) +

+ ) : (item as any).parent_item_name ? ( +

+ 상위: {(item as any).parent_plan_no} ({(item as any).parent_item_name}) | BOM 수량: {(item as any).bom_qty || 1} +

+ ) : null} +
+ ); + })} +
+
+ )} + + {/* 삭제될 목록 */} + {deletedSchedules.length > 0 && ( +
+

+ + 삭제될 기존 계획 ({deletedSchedules.length}건) +

+
+ {deletedSchedules.map((item, idx) => ( +
+
+

+ {item.item_code} - {item.item_name} +

+ + 삭제 예정 + +
+

+ {item.plan_no} | 수량: {Number(item.plan_qty || 0).toLocaleString()} EA +

+
+ 시작일: {formatDate(item.start_date)} + 종료일: {formatDate(item.end_date)} +
+
+ ))} +
+
+ )} + + {/* 유지될 목록 (진행중) */} + {keptSchedules.length > 0 && ( +
+

+ + 유지되는 진행중 계획 ({keptSchedules.length}건) +

+
+ {keptSchedules.map((item, idx) => { + const statusInfo = statusOptions.find((s) => s.value === item.status); + return ( +
+
+

+ {item.item_code} - {item.item_name} +

+ + {statusInfo?.label || item.status} + +
+

+ {item.plan_no} | 수량: {Number(item.plan_qty || 0).toLocaleString()} EA + {item.completed_qty ? ` (완료: ${Number(item.completed_qty).toLocaleString()} EA)` : ""} +

+
+ 시작일: {formatDate(item.start_date)} + 종료일: {formatDate(item.end_date)} +
+
+ ); + })} +
+
+ )} +
+ ) : ( +
+ 미리보기 데이터를 불러올 수 없습니다 +
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts index 4ac2af4b..e9064267 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts @@ -2,3 +2,5 @@ export { TimelineHeader } from "./TimelineHeader"; export { ScheduleBar } from "./ScheduleBar"; export { ResourceRow } from "./ResourceRow"; export { TimelineLegend } from "./TimelineLegend"; +export { ItemTimelineCard, groupSchedulesByItem } from "./ItemTimelineCard"; +export { SchedulePreviewDialog } from "./SchedulePreviewDialog"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts index afcc9f5e..aa5c4edd 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -225,6 +225,35 @@ export interface TimelineSchedulerConfig extends ComponentConfig { /** 최대 높이 */ maxHeight?: number | string; + + /** + * 표시 모드 + * - "resource": 기존 설비(리소스) 기반 간트 차트 (기본값) + * - "itemGrouped": 품목별 카드형 타임라인 (참고 이미지 스타일) + */ + viewMode?: "resource" | "itemGrouped"; + + /** 범례 표시 여부 */ + showLegend?: boolean; + + /** + * 연결 필터 설정: 다른 컴포넌트의 선택에 따라 데이터를 필터링 + * 설정 시 초기 상태는 빈 화면, 선택 이벤트 수신 시 필터링된 데이터 표시 + */ + linkedFilter?: { + /** 소스 컴포넌트 ID (선택 이벤트를 발생시키는 컴포넌트) */ + sourceComponentId?: string; + /** 소스 테이블명 (이벤트의 tableName과 매칭) */ + sourceTableName?: string; + /** 소스 필드 (선택된 행에서 추출할 필드) */ + sourceField: string; + /** 타겟 필드 (타임라인 데이터에서 필터링할 필드) */ + targetField: string; + /** 선택 없을 때 빈 상태 표시 여부 (기본: true) */ + showEmptyWhenNoSelection?: boolean; + /** 빈 상태 메시지 */ + emptyMessage?: string; + }; } /** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f38af595..230d3139 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,6 +35,7 @@ "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.22", "@tiptap/core": "^2.27.1", "@tiptap/extension-placeholder": "^2.27.1", "@tiptap/pm": "^2.27.1", @@ -3756,6 +3757,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.22", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.22.tgz", + "integrity": "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -3769,6 +3787,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.22", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.22.tgz", + "integrity": "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 81de41a1..76773512 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.22", "@tiptap/core": "^2.27.1", "@tiptap/extension-placeholder": "^2.27.1", "@tiptap/pm": "^2.27.1", From 1a319d178585abfc570a7768e073c9d8842a2424 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 16 Mar 2026 14:51:34 +0900 Subject: [PATCH 46/46] feat: enhance V2TimelineSchedulerConfigPanel with filter and view mode options - Added new filter and linking settings section to the V2TimelineSchedulerConfigPanel, allowing users to manage static filters and linked filters more effectively. - Introduced view mode options to switch between different display modes in the timeline scheduler. - Updated the configuration types and added new toolbar action settings to support custom actions in the timeline toolbar. - Enhanced the overall user experience by providing more flexible filtering and display options. These updates aim to improve the functionality and usability of the timeline scheduler within the ERP system, enabling better data management and visualization. Made-with: Cursor --- .../V2TimelineSchedulerConfigPanel.tsx | 651 +++++++++++++++++- .../TimelineSchedulerComponent.tsx | 400 +++++------ .../v2-timeline-scheduler/config.ts | 35 +- .../components/v2-timeline-scheduler/types.ts | 55 ++ 4 files changed, 918 insertions(+), 223 deletions(-) diff --git a/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx index 11815db8..44065912 100644 --- a/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx @@ -15,11 +15,11 @@ import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers } from "lucide-react"; +import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react"; import { cn } from "@/lib/utils"; import { tableTypeApi } from "@/lib/api/screen"; -import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel } from "@/lib/registry/components/v2-timeline-scheduler/types"; -import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config"; +import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types"; +import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config"; interface V2TimelineSchedulerConfigPanelProps { config: TimelineSchedulerConfig; @@ -49,10 +49,16 @@ export const V2TimelineSchedulerConfigPanel: React.FC(null); useEffect(() => { const loadTables = async () => { @@ -225,6 +231,31 @@ export const V2TimelineSchedulerConfigPanel: React.FC
+ {/* 뷰 모드 */} +
+
+

표시 모드

+

+ {viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description} +

+
+ +
+ {/* 커스텀 테이블 사용 여부 */}
@@ -470,6 +501,210 @@ export const V2TimelineSchedulerConfigPanel: React.FC + {/* ─── 필터 & 연동 설정 ─── */} + + + + + +
+ {/* 정적 필터 */} +
+

정적 필터 (staticFilters)

+

데이터 조회 시 항상 적용되는 고정 필터 조건

+ + {Object.entries(config.staticFilters || {}).map(([key, value]) => ( +
+ + = + + +
+ ))} + +
+ setNewFilterKey(e.target.value)} + placeholder="필드명 (예: product_type)" + className="h-7 flex-1 text-xs" + /> + = + setNewFilterValue(e.target.value)} + placeholder="값 (예: 완제품)" + className="h-7 flex-1 text-xs" + /> + +
+
+ + {/* 구분선 */} +
+ + {/* 연결 필터 */} +
+
+
+

+ + 연결 필터 (linkedFilter) +

+

다른 컴포넌트 선택에 따라 데이터를 필터링

+
+ { + if (v) { + updateConfig({ + linkedFilter: { + sourceField: "", + targetField: "", + showEmptyWhenNoSelection: true, + emptyMessage: "좌측 목록에서 항목을 선택하세요", + }, + }); + } else { + updateConfig({ linkedFilter: undefined }); + } + }} + /> +
+ + {config.linkedFilter && ( +
+
+
+ 소스 테이블명 +

선택 이벤트의 tableName 매칭

+
+ + + + + + value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}> + + + 없음 + + {tables.map((table) => ( + { + updateConfig({ + linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName }, + }); + setLinkedFilterTableOpen(false); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+ 소스 필드 (sourceField) * + updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })} + placeholder="예: part_code" + className="h-7 w-[140px] text-xs" + /> +
+ +
+ 타겟 필드 (targetField) * + updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })} + placeholder="예: item_code" + className="h-7 w-[140px] text-xs" + /> +
+ +
+ 빈 상태 메시지 + updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })} + placeholder="선택 안내 문구" + className="h-7 w-[180px] text-xs" + /> +
+ +
+ 선택 없을 때 빈 화면 + updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })} + /> +
+
+ )} +
+
+ + + {/* ─── 2단계: 소스 데이터 설정 ─── */} @@ -1038,6 +1273,17 @@ export const V2TimelineSchedulerConfigPanel: React.FC updateConfig({ showAddButton: v })} />
+ +
+
+

범례 표시

+

상태별 색상 범례를 보여줘요

+
+ updateConfig({ showLegend: v })} + /> +
@@ -1114,6 +1360,405 @@ export const V2TimelineSchedulerConfigPanel: React.FC + {/* ─── 6단계: 툴바 액션 설정 ─── */} + + + + + +
+

+ 툴바에 커스텀 버튼을 추가하여 API 호출 (미리보기 → 확인 → 적용) 워크플로우를 구성해요 +

+ + {/* 기존 액션 목록 */} + {(config.toolbarActions || []).map((action, index) => ( + setExpandedActionId(open ? action.id : null)} + > +
+ + + +
+ + + +
+ {/* 기본 설정 */} +
+
+ 버튼명 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], label: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + className="h-7 text-xs" + /> +
+
+ 아이콘 + +
+
+ +
+ 버튼 색상 (Tailwind 클래스) + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], color: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="예: bg-emerald-600 hover:bg-emerald-700" + className="h-7 text-xs" + /> +
+ + {/* API 설정 */} +
+

API 설정

+
+
+ 미리보기 API * + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], previewApi: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="/production/generate-schedule/preview" + className="h-7 text-xs" + /> +
+
+ 적용 API * + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], applyApi: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="/production/generate-schedule" + className="h-7 text-xs" + /> +
+
+
+ + {/* 다이얼로그 설정 */} +
+

다이얼로그

+
+
+ 제목 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], dialogTitle: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="자동 생성" + className="h-7 text-xs" + /> +
+
+ 설명 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], dialogDescription: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="미리보기 후 확인하여 적용합니다" + className="h-7 text-xs" + /> +
+
+
+ + {/* 데이터 소스 설정 */} +
+

데이터 소스

+
+
+ 데이터 소스 유형 * + +
+ + {action.dataSource === "linkedSelection" && ( +
+
+
+ 그룹 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="linkedFilter.sourceField 사용" + className="h-7 text-xs" + /> +
+
+ 수량 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="balance_qty" + className="h-7 text-xs" + /> +
+
+
+
+ 기준일 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="due_date" + className="h-7 text-xs" + /> +
+
+ 표시명 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="part_name" + className="h-7 text-xs" + /> +
+
+
+ )} + + {action.dataSource === "currentSchedules" && ( +
+
+
+ 필터 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="product_type" + className="h-7 text-xs" + /> +
+
+ 필터 값 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="완제품" + className="h-7 text-xs" + /> +
+
+
+ )} +
+
+ + {/* 표시 조건 */} +
+

표시 조건 (showWhen)

+

staticFilters 값과 비교하여 일치할 때만 버튼 표시

+ {Object.entries(action.showWhen || {}).map(([key, value]) => ( +
+ + = + + +
+ ))} +
+ + = + + +
+
+
+
+
+
+ ))} + + {/* 액션 추가 버튼 */} + +
+ +
); }; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index 3a69da65..075e8eca 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -12,7 +12,15 @@ import { Package, Zap, RefreshCw, + Download, + Upload, + Play, + FileText, + Send, + Sparkles, + Wand2, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -20,6 +28,7 @@ import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, + ToolbarAction, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components"; @@ -53,24 +62,24 @@ export function TimelineSchedulerComponent({ }: TimelineSchedulerComponentProps) { const containerRef = useRef(null); - // ────────── 자동 스케줄 생성 상태 ────────── - const [showPreviewDialog, setShowPreviewDialog] = useState(false); - const [previewLoading, setPreviewLoading] = useState(false); - const [previewApplying, setPreviewApplying] = useState(false); - const [previewSummary, setPreviewSummary] = useState(null); - const [previewItems, setPreviewItems] = useState([]); - const [previewDeleted, setPreviewDeleted] = useState([]); - const [previewKept, setPreviewKept] = useState([]); + // ────────── 툴바 액션 다이얼로그 상태 (통합) ────────── + const [actionDialog, setActionDialog] = useState<{ + actionId: string; + action: ToolbarAction; + isLoading: boolean; + isApplying: boolean; + summary: any; + previews: any[]; + deletedSchedules: any[]; + keptSchedules: any[]; + preparedPayload: any; + } | null>(null); const linkedFilterValuesRef = useRef([]); - // ────────── 반제품 계획 생성 상태 ────────── - const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false); - const [semiPreviewLoading, setSemiPreviewLoading] = useState(false); - const [semiPreviewApplying, setSemiPreviewApplying] = useState(false); - const [semiPreviewSummary, setSemiPreviewSummary] = useState(null); - const [semiPreviewItems, setSemiPreviewItems] = useState([]); - const [semiPreviewDeleted, setSemiPreviewDeleted] = useState([]); - const [semiPreviewKept, setSemiPreviewKept] = useState([]); + // ────────── 아이콘 맵 ────────── + const TOOLBAR_ICONS: Record> = useMemo(() => ({ + Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2, + }), []); // ────────── linkedFilter 상태 ────────── const linkedFilter = config.linkedFilter; @@ -339,197 +348,153 @@ export function TimelineSchedulerComponent({ } }, [onAddSchedule, effectiveResources]); - // ────────── 자동 스케줄 생성: 미리보기 요청 ────────── - const handleAutoSchedulePreview = useCallback(async () => { - const selectedRows = linkedFilterValuesRef.current; - if (!selectedRows || selectedRows.length === 0) { - toast.warning("좌측에서 품목을 선택해주세요"); - return; + // ────────── 유효 툴바 액션 (config 기반 또는 하위호환 자동생성) ────────── + const effectiveToolbarActions: ToolbarAction[] = useMemo(() => { + if (config.toolbarActions && config.toolbarActions.length > 0) { + return config.toolbarActions; } + return []; + }, [config.toolbarActions]); - const sourceField = config.linkedFilter?.sourceField || "part_code"; - const grouped = new Map(); - selectedRows.forEach((row: any) => { - const key = row[sourceField] || ""; - if (!key) return; - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key)!.push(row); - }); + // ────────── 범용 액션: 미리보기 요청 ────────── + const handleActionPreview = useCallback(async (action: ToolbarAction) => { + let payload: any; - const items = Array.from(grouped.entries()).map(([itemCode, rows]) => { - const totalBalanceQty = rows.reduce((sum: number, r: any) => sum + (Number(r.balance_qty) || 0), 0); - const earliestDueDate = rows - .map((r: any) => r.due_date) - .filter(Boolean) - .sort()[0] || new Date().toISOString().split("T")[0]; - const first = rows[0]; + if (action.dataSource === "linkedSelection") { + const selectedRows = linkedFilterValuesRef.current; + if (!selectedRows || selectedRows.length === 0) { + toast.warning("좌측에서 항목을 선택해주세요"); + return; + } - return { - item_code: itemCode, - item_name: first.part_name || first.item_name || itemCode, - required_qty: totalBalanceQty, - earliest_due_date: typeof earliestDueDate === "string" ? earliestDueDate.split("T")[0] : earliestDueDate, - hourly_capacity: Number(first.hourly_capacity) || undefined, - daily_capacity: Number(first.daily_capacity) || undefined, - }; - }).filter((item) => item.required_qty > 0); + const groupField = action.payloadConfig?.groupByField || config.linkedFilter?.sourceField || "part_code"; + const qtyField = action.payloadConfig?.quantityField || config.sourceConfig?.quantityField || "balance_qty"; + const dateField = action.payloadConfig?.dueDateField || config.sourceConfig?.dueDateField || "due_date"; + const nameField = action.payloadConfig?.nameField || config.sourceConfig?.groupNameField || "part_name"; - if (items.length === 0) { - toast.warning("선택된 품목의 잔량이 없습니다"); - return; - } - - setShowPreviewDialog(true); - setPreviewLoading(true); - - try { - const response = await apiClient.post("/production/generate-schedule/preview", { - items, - options: { - product_type: config.staticFilters?.product_type || "완제품", - safety_lead_time: 1, - recalculate_unstarted: true, - }, + const grouped = new Map(); + selectedRows.forEach((row: any) => { + const key = row[groupField] || ""; + if (!key) return; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(row); }); + const items = Array.from(grouped.entries()).map(([code, rows]) => { + const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0); + const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort(); + const earliestDate = dates[0] || new Date().toISOString().split("T")[0]; + const first = rows[0]; + return { + item_code: code, + item_name: first[nameField] || first.item_name || code, + required_qty: totalQty, + earliest_due_date: typeof earliestDate === "string" ? earliestDate.split("T")[0] : earliestDate, + hourly_capacity: Number(first.hourly_capacity) || undefined, + daily_capacity: Number(first.daily_capacity) || undefined, + }; + }).filter((item) => item.required_qty > 0); + + if (items.length === 0) { + toast.warning("선택된 항목의 잔량이 없습니다"); + return; + } + + payload = { + items, + options: { + ...(config.staticFilters || {}), + ...(action.payloadConfig?.extraOptions || {}), + }, + }; + } else if (action.dataSource === "currentSchedules") { + let targetSchedules = schedules; + const filterField = action.payloadConfig?.scheduleFilterField; + const filterValue = action.payloadConfig?.scheduleFilterValue; + + if (filterField && filterValue) { + targetSchedules = schedules.filter((s) => { + const val = (s.data as any)?.[filterField] || ""; + return val === filterValue; + }); + } + + if (targetSchedules.length === 0) { + toast.warning("대상 스케줄이 없습니다"); + return; + } + + const planIds = targetSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); + if (planIds.length === 0) { + toast.warning("유효한 스케줄 ID가 없습니다"); + return; + } + + payload = { + plan_ids: planIds, + options: action.payloadConfig?.extraOptions || {}, + }; + } + + setActionDialog({ + actionId: action.id, + action, + isLoading: true, + isApplying: false, + summary: null, + previews: [], + deletedSchedules: [], + keptSchedules: [], + preparedPayload: payload, + }); + + try { + const response = await apiClient.post(action.previewApi, payload); if (response.data?.success) { - setPreviewSummary(response.data.data.summary); - setPreviewItems(response.data.data.previews); - setPreviewDeleted(response.data.data.deletedSchedules || []); - setPreviewKept(response.data.data.keptSchedules || []); + setActionDialog((prev) => prev ? { + ...prev, + isLoading: false, + summary: response.data.data.summary, + previews: response.data.data.previews || [], + deletedSchedules: response.data.data.deletedSchedules || [], + keptSchedules: response.data.data.keptSchedules || [], + } : null); } else { - toast.error("미리보기 생성 실패"); - setShowPreviewDialog(false); + toast.error("미리보기 생성 실패", { description: response.data?.message }); + setActionDialog(null); } } catch (err: any) { toast.error("미리보기 요청 실패", { description: err.message }); - setShowPreviewDialog(false); - } finally { - setPreviewLoading(false); + setActionDialog(null); } - }, [config.linkedFilter, config.staticFilters]); + }, [config.linkedFilter, config.staticFilters, config.sourceConfig, schedules]); - // ────────── 자동 스케줄 생성: 확인 및 적용 ────────── - const handleAutoScheduleApply = useCallback(async () => { - if (!previewItems || previewItems.length === 0) return; + // ────────── 범용 액션: 확인 및 적용 ────────── + const handleActionApply = useCallback(async () => { + if (!actionDialog) return; + const { action, preparedPayload } = actionDialog; - setPreviewApplying(true); - - const items = previewItems.map((p: any) => ({ - item_code: p.item_code, - item_name: p.item_name, - required_qty: p.required_qty, - earliest_due_date: p.due_date, - hourly_capacity: p.hourly_capacity, - daily_capacity: p.daily_capacity, - })); + setActionDialog((prev) => prev ? { ...prev, isApplying: true } : null); try { - const response = await apiClient.post("/production/generate-schedule", { - items, - options: { - product_type: config.staticFilters?.product_type || "완제품", - safety_lead_time: 1, - recalculate_unstarted: true, - }, - }); - - if (response.data?.success) { - const summary = response.data.data.summary; - toast.success("생산계획 업데이트 완료", { - description: `신규: ${summary.new_count}건, 유지: ${summary.kept_count}건, 삭제: ${summary.deleted_count}건`, - }); - setShowPreviewDialog(false); - refreshTimeline(); - } else { - toast.error("생산계획 생성 실패"); - } - } catch (err: any) { - toast.error("생산계획 생성 실패", { description: err.message }); - } finally { - setPreviewApplying(false); - } - }, [previewItems, config.staticFilters, refreshTimeline]); - - // ────────── 반제품 계획 생성: 미리보기 요청 ────────── - const handleSemiSchedulePreview = useCallback(async () => { - // 현재 타임라인에 표시된 완제품 스케줄의 plan ID 수집 - const finishedSchedules = schedules.filter((s) => { - const productType = (s.data as any)?.product_type || ""; - return productType === "완제품"; - }); - - if (finishedSchedules.length === 0) { - toast.warning("완제품 스케줄이 없습니다. 먼저 완제품 계획을 생성해주세요."); - return; - } - - const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); - if (planIds.length === 0) { - toast.warning("유효한 완제품 계획 ID가 없습니다"); - return; - } - - setShowSemiPreviewDialog(true); - setSemiPreviewLoading(true); - - try { - const response = await apiClient.post("/production/generate-semi-schedule/preview", { - plan_ids: planIds, - options: { considerStock: true }, - }); - - if (response.data?.success) { - setSemiPreviewSummary(response.data.data.summary); - setSemiPreviewItems(response.data.data.previews || []); - setSemiPreviewDeleted(response.data.data.deletedSchedules || []); - setSemiPreviewKept(response.data.data.keptSchedules || []); - } else { - toast.error("반제품 미리보기 실패", { description: response.data?.message }); - setShowSemiPreviewDialog(false); - } - } catch (err: any) { - toast.error("반제품 미리보기 요청 실패", { description: err.message }); - setShowSemiPreviewDialog(false); - } finally { - setSemiPreviewLoading(false); - } - }, [schedules]); - - // ────────── 반제품 계획 생성: 확인 및 적용 ────────── - const handleSemiScheduleApply = useCallback(async () => { - const finishedSchedules = schedules.filter((s) => { - const productType = (s.data as any)?.product_type || ""; - return productType === "완제품"; - }); - const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); - - if (planIds.length === 0) return; - - setSemiPreviewApplying(true); - - try { - const response = await apiClient.post("/production/generate-semi-schedule", { - plan_ids: planIds, - options: { considerStock: true }, - }); - + const response = await apiClient.post(action.applyApi, preparedPayload); if (response.data?.success) { const data = response.data.data; - toast.success("반제품 계획 생성 완료", { - description: `${data.count}건의 반제품 계획이 생성되었습니다`, + const summary = data.summary || data; + toast.success(action.dialogTitle || "완료", { + description: `신규: ${summary.new_count || summary.count || 0}건${summary.kept_count ? `, 유지: ${summary.kept_count}건` : ""}${summary.deleted_count ? `, 삭제: ${summary.deleted_count}건` : ""}`, }); - setShowSemiPreviewDialog(false); + setActionDialog(null); refreshTimeline(); } else { - toast.error("반제품 계획 생성 실패"); + toast.error("실행 실패", { description: response.data?.message }); } } catch (err: any) { - toast.error("반제품 계획 생성 실패", { description: err.message }); + toast.error("실행 실패", { description: err.message }); } finally { - setSemiPreviewApplying(false); + setActionDialog((prev) => prev ? { ...prev, isApplying: false } : null); } - }, [schedules, refreshTimeline]); + }, [actionDialog, refreshTimeline]); // ────────── 하단 영역 높이 계산 (툴바 + 범례) ────────── const showToolbar = config.showToolbar !== false; @@ -713,18 +678,26 @@ export function TimelineSchedulerComponent({ 새로고침 - {config.staticFilters?.product_type === "완제품" && ( - <> - - - - )} + ); + })}
)} @@ -796,33 +769,22 @@ export function TimelineSchedulerComponent({
)} - {/* 완제품 스케줄 생성 미리보기 다이얼로그 */} - - - {/* 반제품 계획 생성 미리보기 다이얼로그 */} - + {/* 범용 액션 미리보기 다이얼로그 */} + {actionDialog && ( + { if (!open) setActionDialog(null); }} + isLoading={actionDialog.isLoading} + summary={actionDialog.summary} + previews={actionDialog.previews} + deletedSchedules={actionDialog.deletedSchedules} + keptSchedules={actionDialog.keptSchedules} + onConfirm={handleActionApply} + isApplying={actionDialog.isApplying} + title={actionDialog.action.dialogTitle} + description={actionDialog.action.dialogDescription} + /> + )}
); } diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts index 17c31991..57409191 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts @@ -1,6 +1,6 @@ "use client"; -import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types"; +import { TimelineSchedulerConfig, ZoomLevel, ScheduleType, ToolbarAction } from "./types"; /** * 기본 타임라인 스케줄러 설정 @@ -94,6 +94,39 @@ export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [ { value: "WORK_ASSIGN", label: "작업배정" }, ]; +/** + * 뷰 모드 옵션 + */ +export const viewModeOptions: { value: string; label: string; description: string }[] = [ + { value: "resource", label: "리소스 기반", description: "설비/작업자 행 기반 간트차트" }, + { value: "itemGrouped", label: "품목별 그룹", description: "품목별 카드형 타임라인" }, +]; + +/** + * 데이터 소스 옵션 + */ +export const dataSourceOptions: { value: string; label: string; description: string }[] = [ + { value: "linkedSelection", label: "연결 필터 선택값", description: "좌측 테이블에서 선택된 행 데이터 사용" }, + { value: "currentSchedules", label: "현재 스케줄", description: "타임라인에 표시 중인 스케줄 ID 사용" }, +]; + +/** + * 아이콘 옵션 + */ +export const toolbarIconOptions: { value: string; label: string }[] = [ + { value: "Zap", label: "Zap (번개)" }, + { value: "Package", label: "Package (박스)" }, + { value: "Plus", label: "Plus (추가)" }, + { value: "Download", label: "Download (다운로드)" }, + { value: "Upload", label: "Upload (업로드)" }, + { value: "RefreshCw", label: "RefreshCw (새로고침)" }, + { value: "Play", label: "Play (재생)" }, + { value: "FileText", label: "FileText (문서)" }, + { value: "Send", label: "Send (전송)" }, + { value: "Sparkles", label: "Sparkles (반짝)" }, + { value: "Wand2", label: "Wand2 (마법봉)" }, +]; + /** * 줌 레벨별 표시 일수 */ diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts index aa5c4edd..5c0ef953 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -128,6 +128,58 @@ export interface SourceDataConfig { groupNameField?: string; } +/** + * 툴바 액션 설정 (커스텀 버튼) + * 타임라인 툴바에 표시되는 커스텀 액션 버튼을 정의 + * preview -> confirm -> apply 워크플로우 지원 + */ +export interface ToolbarAction { + /** 고유 ID */ + id: string; + /** 버튼 텍스트 */ + label: string; + /** lucide-react 아이콘명 */ + icon?: "Zap" | "Package" | "Plus" | "Download" | "Upload" | "RefreshCw" | "Play" | "FileText" | "Send" | "Sparkles" | "Wand2"; + /** 버튼 색상 클래스 (예: "bg-emerald-600 hover:bg-emerald-700") */ + color?: string; + /** 미리보기 API 엔드포인트 (예: "/production/generate-schedule/preview") */ + previewApi: string; + /** 적용 API 엔드포인트 (예: "/production/generate-schedule") */ + applyApi: string; + /** 다이얼로그 제목 */ + dialogTitle?: string; + /** 다이얼로그 설명 */ + dialogDescription?: string; + /** + * 데이터 소스 유형 + * - linkedSelection: 연결 필터(좌측 테이블)에서 선택된 행 사용 + * - currentSchedules: 현재 타임라인의 스케줄 ID 사용 + */ + dataSource: "linkedSelection" | "currentSchedules"; + /** 페이로드 구성 설정 */ + payloadConfig?: { + /** linkedSelection: 선택된 행을 그룹화할 필드 (기본: linkedFilter.sourceField) */ + groupByField?: string; + /** linkedSelection: 수량 합계 필드 (예: "balance_qty") */ + quantityField?: string; + /** linkedSelection: 기준일 필드 (예: "due_date") */ + dueDateField?: string; + /** linkedSelection: 표시명 필드 (예: "part_name") */ + nameField?: string; + /** currentSchedules: 스케줄 필터 조건 필드명 (예: "product_type") */ + scheduleFilterField?: string; + /** currentSchedules: 스케줄 필터 값 (예: "완제품") */ + scheduleFilterValue?: string; + /** API 호출 시 추가 옵션 (예: { "safety_lead_time": 1 }) */ + extraOptions?: Record; + }; + /** + * 표시 조건: staticFilters와 비교하여 모든 조건이 일치할 때만 버튼 표시 + * 예: { "product_type": "완제품" } → staticFilters.product_type === "완제품"일 때만 표시 + */ + showWhen?: Record; +} + /** * 타임라인 스케줄러 설정 */ @@ -254,6 +306,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig { /** 빈 상태 메시지 */ emptyMessage?: string; }; + + /** 툴바 커스텀 액션 버튼 설정 */ + toolbarActions?: ToolbarAction[]; } /**