feat(pop-dashboard): 라벨 정렬 + 페이지 미리보기 + 차트 디자인 개선

- 라벨 정렬(좌/중/우) 기능 추가 (KPI, 차트, 게이지, 통계카드)
- 글자 크기 커스텀 제거 (컨테이너 반응형 자동 적용)
- 페이지별 미리보기 버튼 추가 (디자이너 캔버스에 즉시 반영)
- 아이템 스타일 에디터 접기/펼치기 지원
- 차트 디자인: CartesianGrid, 대각선 X축 라벨, 숫자 약어(K/M), 축 여백
- handleUpdateComponent stale closure 버그 수정 (함수적 setState)
- 디버그 console.log 전량 제거

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-11 14:23:20 +09:00
parent 6f45efef03
commit 960b1c9946
12 changed files with 347 additions and 83 deletions

View File

@ -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`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |

View File

@ -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}
/>
)}
</div>

View File

@ -69,6 +69,9 @@ export default function PopDesigner({
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드)
const [previewPageIndex, setPreviewPageIndex] = useState<number>(-1);
// 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
@ -217,24 +220,28 @@ export default function PopDesigner({
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
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}
/>
</ResizablePanel>
@ -655,6 +663,8 @@ export default function PopDesigner({
allComponents={Object.values(layout.components)}
onSelectComponent={setSelectedComponentId}
selectedComponentId={selectedComponentId}
previewPageIndex={previewPageIndex}
onPreviewPage={setPreviewPageIndex}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@ -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({
<ComponentSettingsForm
component={component}
onUpdate={onUpdateComponent}
previewPageIndex={previewPageIndex}
onPreviewPage={onPreviewPage}
/>
</TabsContent>
@ -362,9 +370,11 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => 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
<ConfigPanel
config={component.config || {}}
onUpdate={handleConfigUpdate}
onPreviewPage={onPreviewPage}
previewPageIndex={previewPageIndex}
/>
) : (
<div className="rounded-lg bg-gray-50 p-3">

View File

@ -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 (
<div className="h-full w-full overflow-hidden pointer-events-none">
<ActualComp config={component.config} label={component.label} />
<ActualComp config={component.config} label={component.label} previewPageIndex={previewPageIndex} />
</div>
);
}

View File

@ -159,8 +159,11 @@ async function loadItemData(item: DashboardItem): Promise<ItemData> {
export function PopDashboardComponent({
config,
previewPageIndex,
}: {
config?: PopDashboardConfig;
/** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */
previewPageIndex?: number;
}) {
const [dataMap, setDataMap] = useState<Record<string, ItemData>>({});
const [loading, setLoading] = useState(true);
@ -362,20 +365,22 @@ export function PopDashboardComponent({
const displayMode = migrated.displayMode;
// 페이지 하나를 GridModeComponent로 렌더링
const renderPageContent = (page: DashboardPage) => (
<GridModeComponent
cells={page.gridCells}
columns={page.gridColumns}
rows={page.gridRows}
gap={config.gap}
containerWidth={containerWidth}
renderItem={(itemId) => {
const item = visibleItems.find((i) => i.id === itemId);
if (!item) return null;
return renderSingleItem(item);
}}
/>
);
const renderPageContent = (page: DashboardPage) => {
return (
<GridModeComponent
cells={page.gridCells}
columns={page.gridColumns}
rows={page.gridRows}
gap={config.gap}
containerWidth={containerWidth}
renderItem={(itemId) => {
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 (
<div
ref={containerRef}
className="h-full w-full"
style={
config.backgroundColor
? { backgroundColor: config.backgroundColor }
: undefined
}
>
{renderPageContent(pages[previewPageIndex])}
</div>
);
}
// 표시 모드별 렌더링
return (
<div

View File

@ -18,6 +18,7 @@ import {
GripVertical,
Check,
ChevronsUpDown,
Eye,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -58,6 +59,10 @@ import type {
DashboardPage,
JoinConfig,
JoinType,
ItemStyleConfig,
} from "../types";
import {
TEXT_ALIGN_LABELS,
} from "../types";
import { migrateConfig } from "./PopDashboardComponent";
import {
@ -73,6 +78,10 @@ import { validateExpression } from "./utils/formula";
interface ConfigPanelProps {
config: PopDashboardConfig | undefined;
onUpdate: (config: PopDashboardConfig) => 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<ItemStyleConfig>) => {
const updatedItem = {
...item,
itemStyle: { ...item.itemStyle, ...partial },
};
onUpdate(updatedItem);
};
return (
<div className="rounded border">
{/* 헤더 - 클릭으로 접기/펼치기 */}
<button
type="button"
className="flex w-full items-center justify-between px-2 py-1.5 text-left hover:bg-muted/50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<span className="text-[10px] font-medium truncate">
{item.label || item.id}
</span>
{expanded ? (
<ChevronUp className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
</button>
{/* 내용 - 접기/펼치기 */}
{expanded && (
<div className="space-y-2 border-t px-2 pb-2 pt-1.5">
{/* 라벨 정렬 */}
<div>
<span className="text-[9px] text-muted-foreground">
</span>
<div className="mt-0.5 flex gap-1">
{(["left", "center", "right"] as const).map((align) => (
<Button
key={align}
variant={
item.itemStyle?.labelAlign === align ||
(!item.itemStyle?.labelAlign && align === "center")
? "default"
: "outline"
}
size="sm"
className="h-6 flex-1 text-[10px] px-1"
onClick={() => updateStyle({ labelAlign: align })}
>
{TEXT_ALIGN_LABELS[align]}
</Button>
))}
</div>
</div>
</div>
)}
</div>
);
}
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({
. +/- /
/ .
</p>
{/* 배정된 아이템별 스타일 설정 */}
{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 (
<div className="space-y-1 border-t pt-2">
<span className="text-[10px] font-medium text-muted-foreground">
</span>
{assignedItems.map((item) => (
<ItemStyleEditor
key={item.id}
item={item}
onUpdate={onUpdateItem}
/>
))}
</div>
);
})()}
</div>
);
}
@ -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({
<span className="rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
{page.gridColumns}x{page.gridRows}
</span>
<Button
variant={isPreviewing ? "default" : "ghost"}
size="icon"
className="h-6 w-6"
onClick={() => onPreview?.()}
title="이 페이지 미리보기"
>
<Eye className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
@ -1793,6 +1919,7 @@ function PageEditor({
gridRows: rows,
})
}
onUpdateItem={onUpdateItem}
/>
</div>
)}
@ -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);
}
}}
/>
))}

View File

@ -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 (
<div className="@container flex h-full w-full flex-col p-2">
{/* 라벨 */}
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className="mb-1 text-xs text-muted-foreground @[250px]:text-sm">
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
@ -97,24 +115,34 @@ export function ChartItemComponent({
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
{chartType === "bar" ? (
<BarChart data={rows as Record<string, string | number>[]}>
<BarChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey={xKey}
tick={{ fontSize: 10 }}
tick={xAxisTickProps}
hide={containerWidth < 200}
/>
<YAxis tick={{ fontSize: 10 }} hide={containerWidth < 200} />
<YAxis
tick={{ fontSize: 10 }}
hide={containerWidth < 200}
tickFormatter={(v: number) => abbreviateNumber(v)}
/>
<Tooltip />
<Bar dataKey={yKey} fill={colors[0]} radius={[2, 2, 0, 0]} />
</BarChart>
) : chartType === "line" ? (
<LineChart data={rows as Record<string, string | number>[]}>
<LineChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey={xKey}
tick={{ fontSize: 10 }}
tick={xAxisTickProps}
hide={containerWidth < 200}
/>
<YAxis tick={{ fontSize: 10 }} hide={containerWidth < 200} />
<YAxis
tick={{ fontSize: 10 }}
hide={containerWidth < 200}
tickFormatter={(v: number) => abbreviateNumber(v)}
/>
<Tooltip />
<Line
type="monotone"
@ -137,7 +165,7 @@ export function ChartItemComponent({
label={
containerWidth > 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({
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [value, name]}
formatter={(value: number, name: string) => [abbreviateNumber(value), name]}
/>
{containerWidth > 300 && (
<Legend

View File

@ -8,9 +8,19 @@
*/
import React from "react";
import type { DashboardItem } from "../../types";
import type { DashboardItem, FontSize } from "../../types";
import { TEXT_ALIGN_CLASSES } from "../../types";
import { abbreviateNumber } from "../utils/formula";
/** FontSize -> SVG 직접 fontSize(px) 매핑 */
const SVG_FONT_SIZE_MAP: Record<FontSize, number> = {
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 (
<div className="@container flex h-full w-full flex-col items-center justify-center p-2">
{/* 라벨 */}
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className="shrink-0 truncate text-xs text-muted-foreground @[250px]:text-sm">
<p className={`w-full shrink-0 truncate text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
@ -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)}
</text>
@ -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)}%
</text>

View File

@ -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 (
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
{/* 라벨 */}
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className="text-xs text-muted-foreground @[250px]:text-sm">
<p className={`w-full text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
{/* 메인 값 */}
{/* 메인 값 - @container 반응형 */}
{visibility.showValue && (
<div className="flex items-baseline gap-1">
<span
@ -100,7 +104,7 @@ export function KpiCardComponent({
{/* 보조 라벨 (수식 표시 등) */}
{visibility.showSubLabel && formulaDisplay && (
<p className="text-xs text-muted-foreground @[350px]:text-sm">
<p className="text-xs text-muted-foreground @[200px]:text-sm">
{item.formula?.values.map((v) => v.label).join(" / ")}
</p>
)}

View File

@ -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 (
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
{/* 라벨 */}
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className="mb-1 text-xs text-muted-foreground @[250px]:text-sm">
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
{/* 총합 */}
{/* 총합 - @container 반응형 */}
{visibility.showValue && (
<p className="mb-2 text-lg font-bold @[200px]:text-2xl @[350px]:text-3xl">
{abbreviateNumber(total)}
@ -80,7 +84,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) {
{/* 보조 라벨 (단위 등) */}
{visibility.showSubLabel && (
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[10px] text-muted-foreground @[150px]:text-xs">
{visibility.showUnit && item.kpiConfig?.unit
? `단위: ${item.kpiConfig.unit}`
: ""}

View File

@ -51,6 +51,7 @@ export const FONT_SIZE_CLASSES: Record<FontSize, string> = {
xl: "text-[64px]",
};
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
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;
}
// ----- 대시보드 전체 설정 -----