From 3225a7bb21db8b3f7a81961baf46f75fa87c5131 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Mon, 16 Mar 2026 10:38:12 +0900 Subject: [PATCH] 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: ["버튼", "액션", "클릭"],