From 960b1c9946c1bfddb05a6ccb4be7f1da0133027e Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Feb 2026 14:23:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-dashboard):=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20+=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20+=20=EC=B0=A8=ED=8A=B8?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라벨 정렬(좌/중/우) 기능 추가 (KPI, 차트, 게이지, 통계카드) - 글자 크기 커스텀 제거 (컨테이너 반응형 자동 적용) - 페이지별 미리보기 버튼 추가 (디자이너 캔버스에 즉시 반영) - 아이템 스타일 에디터 접기/펼치기 지원 - 차트 디자인: CartesianGrid, 대각선 X축 라벨, 숫자 약어(K/M), 축 여백 - handleUpdateComponent stale closure 버그 수정 (함수적 setState) - 디버그 console.log 전량 제거 Co-authored-by: Cursor --- STATUS.md | 36 ++--- .../components/pop/designer/PopCanvas.tsx | 4 + .../components/pop/designer/PopDesigner.tsx | 38 +++-- .../designer/panels/ComponentEditorPanel.tsx | 14 +- .../pop/designer/renderers/PopRenderer.tsx | 12 +- .../pop-dashboard/PopDashboardComponent.tsx | 54 +++++-- .../pop-dashboard/PopDashboardConfig.tsx | 150 +++++++++++++++++- .../pop-dashboard/items/ChartItem.tsx | 50 ++++-- .../pop-dashboard/items/GaugeItem.tsx | 33 +++- .../pop-dashboard/items/KpiCard.tsx | 14 +- .../pop-dashboard/items/StatCard.tsx | 14 +- frontend/lib/registry/pop-components/types.ts | 11 ++ 12 files changed, 347 insertions(+), 83 deletions(-) diff --git a/STATUS.md b/STATUS.md index 5e83ff11..3aa75278 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,22 +1,25 @@ # 프로젝트 상태 추적 -> **최종 업데이트**: 2026-02-10 +> **최종 업데이트**: 2026-02-11 --- ## 현재 진행 중 -### pop-dashboard 4가지 아이템 모드 완성 -**상태**: 코딩 완료, 브라우저 테스트 대기 -**계획서**: [PLAN.MD](./PLAN.MD) +### pop-dashboard 스타일 정리 +**상태**: 코딩 완료, 브라우저 확인 대기 +**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md) +**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정 --- ## 다음 작업 -| 순서 | 작업 | 파일 | 상태 | -|------|------|------|------| -| 7 | 브라우저 테스트 (차트 groupBy / 통계카드 카테고리) | - | [ ] 대기 | +| 순서 | 작업 | 상태 | +|------|------|------| +| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 | +| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 | +| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 | --- @@ -24,12 +27,10 @@ | 날짜 | 작업 | 비고 | |------|------|------| -| 2026-02-10 | A-1: groupBy 설정 UI 추가 | DataSourceEditor에 Combobox 방식 그룹핑 컬럼 선택 UI | -| 2026-02-10 | A-2: 차트 xAxisColumn/yAxisColumn 입력 UI | 차트 설정 섹션에 X/Y축 컬럼 입력 필드 | -| 2026-02-10 | A-3: 통계 카드 카테고리 설정 UI | 카테고리 추가/삭제/편집 인라인 에디터 | -| 2026-02-10 | B-1: 차트 xAxisColumn 자동 보정 | groupBy 있으면 xAxisColumn 자동 설정 | -| 2026-02-10 | B-2: 통계 카드 카테고리별 필터 적용 | rows 필터링으로 카테고리별 독립 건수 표시 버그 수정 | -| 2026-02-10 | fetchTableColumns 폴백 추가 | tableManagementApi 우선 사용으로 컬럼 로딩 안정화 | +| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 | +| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 | +| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 | +| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 | | 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | | 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | | 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | @@ -40,9 +41,6 @@ | # | 이슈 | 심각도 | 상태 | |---|------|--------|------| -| 1 | ~~차트 groupBy 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-1) | -| 2 | ~~차트 xAxisColumn 미설정 시 빈 차트~~ | ~~높음~~ | 수정 완료 (A-2, B-1) | -| 3 | ~~통계 카드 카테고리 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-3) | -| 4 | ~~통계 카드 카테고리별 필터 미적용 버그~~ | ~~높음~~ | 수정 완료 (B-2) | -| 5 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | -| 6 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | +| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | +| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | +| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 | diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 7753a992..6ac2cea9 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -112,6 +112,8 @@ interface PopCanvasProps { onLockLayout?: () => void; onResetOverride?: (mode: GridMode) => void; onChangeGapPreset?: (preset: GapPreset) => void; + /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ + previewPageIndex?: number; } // ======================================== @@ -135,6 +137,7 @@ export default function PopCanvas({ onLockLayout, onResetOverride, onChangeGapPreset, + previewPageIndex, }: PopCanvasProps) { // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); @@ -690,6 +693,7 @@ export default function PopCanvas({ onComponentResizeEnd={onResizeEnd} overrideGap={adjustedGap} overridePadding={adjustedPadding} + previewPageIndex={previewPageIndex} /> )} diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index f4dfd3fa..16fa0da8 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -69,6 +69,9 @@ export default function PopDesigner({ // 선택 상태 const [selectedComponentId, setSelectedComponentId] = useState(null); + // 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드) + const [previewPageIndex, setPreviewPageIndex] = useState(-1); + // 그리드 모드 (4개 프리셋) const [currentMode, setCurrentMode] = useState("tablet_landscape"); @@ -217,24 +220,28 @@ export default function PopDesigner({ const handleUpdateComponent = useCallback( (componentId: string, updates: Partial) => { - const existingComponent = layout.components[componentId]; - if (!existingComponent) return; + // 함수적 업데이트로 stale closure 방지 + setLayout((prev) => { + const existingComponent = prev.components[componentId]; + if (!existingComponent) return prev; - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...existingComponent, - ...updates, + const newComponent = { + ...existingComponent, + ...updates, + }; + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: newComponent, }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); + }; + saveToHistory(newLayout); + return newLayout; + }); setHasChanges(true); }, - [layout, saveToHistory] + [saveToHistory] ); const handleDeleteComponent = useCallback( @@ -637,6 +644,7 @@ export default function PopDesigner({ onLockLayout={handleLockLayout} onResetOverride={handleResetOverride} onChangeGapPreset={handleChangeGapPreset} + previewPageIndex={previewPageIndex} /> @@ -655,6 +663,8 @@ export default function PopDesigner({ allComponents={Object.values(layout.components)} onSelectComponent={setSelectedComponentId} selectedComponentId={selectedComponentId} + previewPageIndex={previewPageIndex} + onPreviewPage={setPreviewPageIndex} /> diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 743574ad..e1c1eed5 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -42,6 +42,10 @@ interface ComponentEditorPanelProps { onSelectComponent?: (componentId: string) => void; /** 현재 선택된 컴포넌트 ID */ selectedComponentId?: string | null; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; + /** 페이지 미리보기 요청 콜백 */ + onPreviewPage?: (pageIndex: number) => void; } // ======================================== @@ -73,6 +77,8 @@ export default function ComponentEditorPanel({ allComponents, onSelectComponent, selectedComponentId, + previewPageIndex, + onPreviewPage, }: ComponentEditorPanelProps) { const breakpoint = GRID_BREAKPOINTS[currentMode]; @@ -182,6 +188,8 @@ export default function ComponentEditorPanel({ @@ -362,9 +370,11 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate interface ComponentSettingsFormProps { component: PopComponentDefinitionV5; onUpdate?: (updates: Partial) => void; + previewPageIndex?: number; + onPreviewPage?: (pageIndex: number) => void; } -function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -393,6 +403,8 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro ) : (
diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 64ac63e6..a8559c36 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -54,6 +54,8 @@ interface PopRendererProps { overridePadding?: number; /** 추가 className */ className?: string; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; } // ======================================== @@ -85,6 +87,7 @@ export default function PopRenderer({ overrideGap, overridePadding, className, + previewPageIndex, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; @@ -250,6 +253,7 @@ export default function PopRenderer({ onComponentMove={onComponentMove} onComponentResize={onComponentResize} onComponentResizeEnd={onComponentResizeEnd} + previewPageIndex={previewPageIndex} /> ); } @@ -293,6 +297,7 @@ interface DraggableComponentProps { onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResizeEnd?: (componentId: string) => void; + previewPageIndex?: number; } function DraggableComponent({ @@ -310,6 +315,7 @@ function DraggableComponent({ onComponentMove, onComponentResize, onComponentResizeEnd, + previewPageIndex, }: DraggableComponentProps) { const [{ isDragging }, drag] = useDrag( () => ({ @@ -348,6 +354,7 @@ function DraggableComponent({ effectivePosition={position} isDesignMode={isDesignMode} isSelected={isSelected} + previewPageIndex={previewPageIndex} /> {/* 리사이즈 핸들 (선택된 컴포넌트만) */} @@ -498,9 +505,10 @@ interface ComponentContentProps { effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; + previewPageIndex?: number; } -function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) { +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 @@ -515,7 +523,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect if (ActualComp) { return (
- +
); } diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 97c4df97..8c9ce861 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -159,8 +159,11 @@ async function loadItemData(item: DashboardItem): Promise { export function PopDashboardComponent({ config, + previewPageIndex, }: { config?: PopDashboardConfig; + /** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */ + previewPageIndex?: number; }) { const [dataMap, setDataMap] = useState>({}); const [loading, setLoading] = useState(true); @@ -362,20 +365,22 @@ export function PopDashboardComponent({ const displayMode = migrated.displayMode; // 페이지 하나를 GridModeComponent로 렌더링 - const renderPageContent = (page: DashboardPage) => ( - { - const item = visibleItems.find((i) => i.id === itemId); - if (!item) return null; - return renderSingleItem(item); - }} - /> - ); + const renderPageContent = (page: DashboardPage) => { + return ( + { + const item = visibleItems.find((i) => i.id === itemId); + if (!item) return null; + return renderSingleItem(item); + }} + /> + ); + }; // 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작) const slideCount = pages.length > 0 ? pages.length : visibleItems.length; @@ -392,6 +397,27 @@ export function PopDashboardComponent({ return null; }; + // 페이지 미리보기 모드: 특정 페이지만 단독 렌더링 (디자이너에서 사용) + if ( + typeof previewPageIndex === "number" && + previewPageIndex >= 0 && + pages[previewPageIndex] + ) { + return ( +
+ {renderPageContent(pages[previewPageIndex])} +
+ ); + } + // 표시 모드별 렌더링 return (
void; + /** 페이지 미리보기 요청 (-1이면 해제) */ + onPreviewPage?: (pageIndex: number) => void; + /** 현재 미리보기 중인 페이지 인덱스 */ + previewPageIndex?: number; } // ===== 기본값 ===== @@ -1528,18 +1537,92 @@ function generateDefaultCells( return cells; } +// ===================================================== +// 아이템 스타일 에디터 (접기/펼치기 지원) +// ===================================================== +function ItemStyleEditor({ + item, + onUpdate, +}: { + item: DashboardItem; + onUpdate: (updatedItem: DashboardItem) => void; +}) { + const [expanded, setExpanded] = useState(false); + + const updateStyle = (partial: Partial) => { + const updatedItem = { + ...item, + itemStyle: { ...item.itemStyle, ...partial }, + }; + onUpdate(updatedItem); + }; + + return ( +
+ {/* 헤더 - 클릭으로 접기/펼치기 */} + + + {/* 내용 - 접기/펼치기 */} + {expanded && ( +
+ {/* 라벨 정렬 */} +
+ + 라벨 정렬 + +
+ {(["left", "center", "right"] as const).map((align) => ( + + ))} +
+
+
+ )} +
+ ); +} + function GridLayoutEditor({ cells, gridColumns, gridRows, items, onChange, + onUpdateItem, }: { cells: DashboardCell[]; gridColumns: number; gridRows: number; items: DashboardItem[]; onChange: (cells: DashboardCell[], cols: number, rows: number) => void; + /** 아이템 스타일 업데이트 콜백 */ + onUpdateItem?: (updatedItem: DashboardItem) => void; }) { const ensuredCells = cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows); @@ -1710,6 +1793,34 @@ function GridLayoutEditor({ 각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을 추가/삭제할 수 있습니다.

+ + {/* 배정된 아이템별 스타일 설정 */} + {onUpdateItem && (() => { + const assignedItemIds = ensuredCells + .map((c) => c.itemId) + .filter((id): id is string => !!id); + const uniqueIds = [...new Set(assignedItemIds)]; + const assignedItems = uniqueIds + .map((id) => items.find((i) => i.id === id)) + .filter((i): i is DashboardItem => !!i); + + if (assignedItems.length === 0) return null; + + return ( +
+ + 아이템 스타일 + + {assignedItems.map((item) => ( + + ))} +
+ ); + })()}
); } @@ -1722,12 +1833,18 @@ function PageEditor({ items, onChange, onDelete, + onPreview, + isPreviewing, + onUpdateItem, }: { page: DashboardPage; pageIndex: number; items: DashboardItem[]; onChange: (updatedPage: DashboardPage) => void; onDelete: () => void; + onPreview?: () => void; + isPreviewing?: boolean; + onUpdateItem?: (updatedItem: DashboardItem) => void; }) { const [expanded, setExpanded] = useState(true); @@ -1741,6 +1858,15 @@ function PageEditor({ {page.gridColumns}x{page.gridRows} +
)} @@ -1802,10 +1929,8 @@ function PageEditor({ // ===== 메인 설정 패널 ===== -export function PopDashboardConfigPanel({ - config, - onUpdate: onChange, -}: ConfigPanelProps) { +export function PopDashboardConfigPanel(props: ConfigPanelProps) { + const { config, onUpdate: onChange } = props; // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 const merged = { ...DEFAULT_CONFIG, ...(config || {}) }; @@ -2068,6 +2193,23 @@ export function PopDashboardConfigPanel({ ); updateConfig({ pages: newPages }); }} + onPreview={() => { + if (props.onPreviewPage) { + // 같은 페이지를 다시 누르면 미리보기 해제 + props.onPreviewPage(props.previewPageIndex === pageIdx ? -1 : pageIdx); + } + }} + isPreviewing={props.previewPageIndex === pageIdx} + onUpdateItem={(updatedItem) => { + const newItems = cfg.items.map((i) => + i.id === updatedItem.id ? updatedItem : i + ); + updateConfig({ items: newItems }); + // 스타일 변경 시 자동으로 해당 페이지 미리보기 활성화 + if (props.onPreviewPage && props.previewPageIndex !== pageIdx) { + props.onPreviewPage(pageIdx); + } + }} /> ))} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx index 93f29b1c..fc828925 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -21,8 +21,11 @@ import { Tooltip, Legend, ResponsiveContainer, + CartesianGrid, } from "recharts"; import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; // ===== Props ===== @@ -58,7 +61,7 @@ export function ChartItemComponent({ rows, containerWidth, }: ChartItemProps) { - const { chartConfig, visibility } = item; + const { chartConfig, visibility, itemStyle } = item; const chartType = chartConfig?.chartType ?? "bar"; const colors = chartConfig?.colors?.length ? chartConfig.colors @@ -66,6 +69,9 @@ export function ChartItemComponent({ const xKey = chartConfig?.xAxisColumn ?? "name"; const yKey = chartConfig?.yAxisColumn ?? "value"; + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + // 컨테이너가 너무 작으면 메시지 표시 if (containerWidth < MIN_CHART_WIDTH) { return ( @@ -84,11 +90,23 @@ export function ChartItemComponent({ ); } + // X축 라벨이 긴지 판정 (7자 이상이면 대각선) + const hasLongLabels = rows.some( + (r) => String(r[xKey] ?? "").length > 7 + ); + const xAxisTickProps = hasLongLabels + ? { fontSize: 10, angle: -45, textAnchor: "end" as const } + : { fontSize: 10 }; + // 긴 라벨이 있으면 하단 여백 확보 + const chartMargin = hasLongLabels + ? { top: 5, right: 10, bottom: 40, left: 10 } + : { top: 5, right: 10, bottom: 5, left: 10 }; + return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -97,24 +115,34 @@ export function ChartItemComponent({
{chartType === "bar" ? ( - []}> + []} margin={chartMargin}> + - + abbreviateNumber(v)} + /> ) : chartType === "line" ? ( - []}> + []} margin={chartMargin}> + - + abbreviateNumber(v)} + /> 250 ? ({ name, value, percent }: { name: string; value: number; percent: number }) => - `${name} ${value} (${(percent * 100).toFixed(0)}%)` + `${name} ${abbreviateNumber(value)} (${(percent * 100).toFixed(0)}%)` : false } labelLine={containerWidth > 250} @@ -150,7 +178,7 @@ export function ChartItemComponent({ ))} [value, name]} + formatter={(value: number, name: string) => [abbreviateNumber(value), name]} /> {containerWidth > 300 && ( SVG 직접 fontSize(px) 매핑 */ +const SVG_FONT_SIZE_MAP: Record = { + xs: 14, + sm: 18, + base: 24, + lg: 32, + xl: 48, +}; + // ===== Props ===== export interface GaugeItemProps { @@ -43,12 +53,19 @@ export function GaugeItemComponent({ data, targetValue, }: GaugeItemProps) { - const { visibility, gaugeConfig } = item; + const { visibility, gaugeConfig, itemStyle } = item; const current = data ?? 0; const min = gaugeConfig?.min ?? 0; const max = gaugeConfig?.max ?? 100; const target = targetValue ?? gaugeConfig?.target ?? max; + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + + // SVG 내부 텍스트는 기본값 고정 (사용자 설정 연동 제거) + const svgValueFontSize = SVG_FONT_SIZE_MAP["base"]; // 24 + const svgSubFontSize = SVG_FONT_SIZE_MAP["xs"]; // 14 + // 달성률 계산 (0~100) const range = max - min; const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0; @@ -70,9 +87,9 @@ export function GaugeItemComponent({ return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -110,8 +127,8 @@ export function GaugeItemComponent({ x={cx} y={cy - 10} textAnchor="middle" - className="fill-foreground text-2xl font-bold" - fontSize="24" + className="fill-foreground font-bold" + fontSize={svgValueFontSize} > {abbreviateNumber(current)} @@ -122,8 +139,8 @@ export function GaugeItemComponent({ x={cx} y={cy + 10} textAnchor="middle" - className="fill-muted-foreground text-xs" - fontSize="12" + className="fill-muted-foreground" + fontSize={svgSubFontSize} > {percentage.toFixed(1)}% diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx index 13086587..9e309a7b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -9,6 +9,7 @@ import React from "react"; import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; import { abbreviateNumber } from "../utils/formula"; // ===== Props ===== @@ -61,20 +62,23 @@ export function KpiCardComponent({ trendValue, formulaDisplay, }: KpiCardProps) { - const { visibility, kpiConfig } = item; + const { visibility, kpiConfig, itemStyle } = item; const displayValue = data ?? 0; const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); + // 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} - {/* 메인 값 */} + {/* 메인 값 - @container 반응형 */} {visibility.showValue && (
+

{item.formula?.values.map((v) => v.label).join(" / ")}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx index 93cc1305..eeae4dcb 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -9,6 +9,7 @@ import React from "react"; import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; import { abbreviateNumber } from "../utils/formula"; // ===== Props ===== @@ -32,20 +33,23 @@ const DEFAULT_STAT_COLORS = [ // ===== 메인 컴포넌트 ===== export function StatCardComponent({ item, categoryData }: StatCardProps) { - const { visibility, statConfig } = item; + const { visibility, statConfig, itemStyle } = item; const categories = statConfig?.categories ?? []; const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} - {/* 총합 */} + {/* 총합 - @container 반응형 */} {visibility.showValue && (

{abbreviateNumber(total)} @@ -80,7 +84,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { {/* 보조 라벨 (단위 등) */} {visibility.showSubLabel && ( -

+

{visibility.showUnit && item.kpiConfig?.unit ? `단위: ${item.kpiConfig.unit}` : ""} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index e5927787..cee5fb06 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -51,6 +51,7 @@ export const FONT_SIZE_CLASSES: Record = { xl: "text-[64px]", }; + export const FONT_WEIGHT_CLASSES: Record = { normal: "font-normal", medium: "font-medium", @@ -290,6 +291,13 @@ export interface DashboardPage { gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정) } +// ----- 대시보드 아이템 스타일 설정 ----- + +export interface ItemStyleConfig { + /** 라벨 텍스트 정렬 (기본: center) */ + labelAlign?: TextAlign; +} + // ----- 대시보드 아이템 ----- export interface DashboardItem { @@ -310,6 +318,9 @@ export interface DashboardItem { chartConfig?: ChartItemConfig; gaugeConfig?: GaugeConfig; statConfig?: StatCardConfig; + + /** 스타일 설정 (정렬, 글자 크기 3그룹) */ + itemStyle?: ItemStyleConfig; } // ----- 대시보드 전체 설정 -----