From df9a539017320d78f74aeeed6dc796234b0bc842 Mon Sep 17 00:00:00 2001 From: johngreen Date: Thu, 30 Apr 2026 08:00:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(template):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=B9=B4=EB=93=9C=EC=97=90=20wireframe=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 템플릿 목록 카드의 정적 📋 아이콘을 실제 view 구조 기반의 미니 와이어프레임으로 교체. 사용자가 카드만 보고도 템플릿이 어떤 화면인지(테이블 위주 / 폼 위주 / 단순 버튼 등) 파악 가능. - backend: getTemplateList SQL 에 VIEWS 컬럼 추가, list 응답 각 row 의 views jsonb 를 객체로 파싱 - frontend: TemplateThumbnail 컴포넌트 신설 — v2(BlockV2.xPct/yPct /wPct/hPct) 정규화 좌표 우선, v1(order/row) 폴백, 컴포넌트 종류별 색상(table=primary, form=cyan, button=pink) - TemplateLibraryModal 카드 아이콘 자리 교체 - dashboard.css 에 .dash-lib-card-thumb / -block 스타일 추가 (v5 토큰 준수 — solid + glow, blur 없음) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/com/erp/service/TemplateService.java | 3 + .../src/main/resources/mapper/template.xml | 1 + .../components/dash/TemplateLibraryModal.tsx | 3 +- .../components/dash/TemplateThumbnail.tsx | 138 ++++++++++++++++++ frontend/styles/dashboard.css | 13 ++ 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 frontend/components/dash/TemplateThumbnail.tsx diff --git a/backend-spring/src/main/java/com/erp/service/TemplateService.java b/backend-spring/src/main/java/com/erp/service/TemplateService.java index 755ae3cc..a4c2896c 100644 --- a/backend-spring/src/main/java/com/erp/service/TemplateService.java +++ b/backend-spring/src/main/java/com/erp/service/TemplateService.java @@ -29,6 +29,9 @@ public class TemplateService extends BaseService { commonService.applyPagination(params); int totalCount = sqlSession.selectOne(NS + "getTemplateListCnt", params); List> list = sqlSession.selectList(NS + "getTemplateList", params); + for (Map row : list) { + parseJsonField(row, "views"); + } return commonService.buildListResponse(list, totalCount, params); } diff --git a/backend-spring/src/main/resources/mapper/template.xml b/backend-spring/src/main/resources/mapper/template.xml index 96b05206..5e56e4a9 100644 --- a/backend-spring/src/main/resources/mapper/template.xml +++ b/backend-spring/src/main/resources/mapper/template.xml @@ -10,6 +10,7 @@ , CATEGORY , DESCRIPTION , PRIMARY_TABLE + , VIEWS , STATUS , VERSION , CREATED_BY diff --git a/frontend/components/dash/TemplateLibraryModal.tsx b/frontend/components/dash/TemplateLibraryModal.tsx index bc23a2d4..436d730d 100644 --- a/frontend/components/dash/TemplateLibraryModal.tsx +++ b/frontend/components/dash/TemplateLibraryModal.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { Search, X } from 'lucide-react'; import { getTemplateList } from '@/lib/api/template'; +import { TemplateThumbnail } from './TemplateThumbnail'; interface TemplateLibraryModalProps { open: boolean; @@ -142,7 +143,7 @@ export function TemplateLibraryModal({ open, onClose, onSelectTemplate }: Templa className="dash-lib-card" onClick={() => onSelectTemplate(t)} > -
📋
+
{name}
{desc &&
{desc}
}
diff --git a/frontend/components/dash/TemplateThumbnail.tsx b/frontend/components/dash/TemplateThumbnail.tsx new file mode 100644 index 00000000..3873d9d1 --- /dev/null +++ b/frontend/components/dash/TemplateThumbnail.tsx @@ -0,0 +1,138 @@ +'use client'; + +/** + * 템플릿 카드용 작은 wireframe 썸네일. + * + * - v2 (BlockV2 + xPct/yPct/wPct/hPct): 정규화 좌표 그대로 절대 배치 + * - v1 (TemplateComponent + order/row): row 별로 묶어 세로 스택, 같은 row 는 가로 분할 + * - 데이터 없음: 빈 placeholder + * + * 컴포넌트 종류는 색상으로만 구분 (table=primary, form=cyan, button=pink, 기타=muted). + */ + +interface TemplateThumbnailProps { + views?: any; +} + +interface MiniBlock { + x: number; + y: number; + w: number; + h: number; + kind: string; +} + +const KIND_COLOR: Record = { + table: 'rgba(var(--v5-primary-rgb), .55)', + form: 'rgba(0, 206, 201, .55)', + search: 'rgba(0, 206, 201, .35)', + button: 'rgba(253, 121, 168, .65)', + 'button-bar': 'rgba(253, 121, 168, .55)', + stats: 'rgba(var(--v5-primary-rgb), .35)', + tabs: 'rgba(var(--v5-primary-rgb), .25)', + title: 'rgba(var(--v5-text-rgb, 100, 100, 120), .35)', + divider: 'rgba(var(--v5-text-rgb, 100, 100, 120), .25)', + pagination: 'rgba(var(--v5-text-rgb, 100, 100, 120), .25)', +}; + +function kindColor(kind: string): string { + const lower = (kind || '').toLowerCase(); + for (const key of Object.keys(KIND_COLOR)) { + if (lower.includes(key)) return KIND_COLOR[key]; + } + return 'rgba(var(--v5-primary-rgb), .25)'; +} + +function inferKind(componentId: string): string { + const id = (componentId || '').toLowerCase(); + if (id.includes('table') || id.includes('grid') || id.includes('list')) return 'table'; + if (id.includes('form') || id.includes('input')) return 'form'; + if (id.includes('search') || id.includes('filter')) return 'search'; + if (id.includes('button')) return 'button'; + if (id.includes('stat') || id.includes('kpi') || id.includes('chart')) return 'stats'; + if (id.includes('tab')) return 'tabs'; + if (id.includes('title') || id.includes('label') || id.includes('text')) return 'title'; + if (id.includes('divider')) return 'divider'; + if (id.includes('pag')) return 'pagination'; + return id || 'unknown'; +} + +function fromV2(views: any): MiniBlock[] | null { + const blocks = views?.list?.blocks; + if (!Array.isArray(blocks) || blocks.length === 0) return null; + return blocks.map((b: any) => ({ + x: clamp01(b.xPct ?? 0), + y: clamp01(b.yPct ?? 0), + w: clamp01(b.wPct ?? 0.2), + h: clamp01(b.hPct ?? 0.1), + kind: inferKind(b.componentId), + })); +} + +function fromV1(views: any): MiniBlock[] | null { + const components = views?.list?.components; + if (!Array.isArray(components) || components.length === 0) return null; + + const sorted = [...components].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + const rowsMap = new Map(); + let nextRowId = 0; + for (const c of sorted) { + const key = c.row != null ? `r${c.row}` : `s${nextRowId++}`; + if (!rowsMap.has(key)) rowsMap.set(key, []); + rowsMap.get(key)!.push(c); + } + + const rows = Array.from(rowsMap.values()); + const totalRows = rows.length; + const blocks: MiniBlock[] = []; + rows.forEach((rowComps, rowIdx) => { + const cols = rowComps.length; + rowComps.forEach((c, colIdx) => { + blocks.push({ + x: colIdx / cols, + y: rowIdx / totalRows, + w: 1 / cols, + h: 1 / totalRows, + kind: inferKind(c.componentId), + }); + }); + }); + return blocks; +} + +function clamp01(v: number): number { + if (typeof v !== 'number' || isNaN(v)) return 0; + if (v < 0) return 0; + if (v > 1) return 1; + return v; +} + +export function TemplateThumbnail({ views }: TemplateThumbnailProps) { + const blocks = fromV2(views) ?? fromV1(views); + + if (!blocks || blocks.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/styles/dashboard.css b/frontend/styles/dashboard.css index 41df72f4..319dfc7f 100644 --- a/frontend/styles/dashboard.css +++ b/frontend/styles/dashboard.css @@ -478,6 +478,19 @@ background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.15), rgba(var(--v5-cyan-rgb),.08)); border: 1px solid rgba(var(--v5-primary-rgb),.15); } +.dash-lib-card-thumb { + position: relative; width: 100%; aspect-ratio: 16 / 10; + border-radius: 8px; overflow: hidden; + background: linear-gradient(135deg, rgba(var(--v5-primary-rgb),.06), rgba(var(--v5-cyan-rgb),.04)); + border: 1px solid var(--v5-border); +} +.dash-lib-card-thumb--empty { + display: flex; align-items: center; justify-content: center; +} +.dash-lib-card-thumb-block { + position: absolute; border-radius: 2px; + outline: 1px solid rgba(255,255,255,.06); +} .dash-lib-card-name { font-size: .78rem; font-weight: 700; color: var(--v5-text); } .dash-lib-card-desc { font-size: .55rem; color: var(--v5-text-muted); line-height: 1.4; } .dash-lib-card-tag {