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:
parent
6f45efef03
commit
960b1c9946
36
STATUS.md
36
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`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
: ""}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// ----- 대시보드 전체 설정 -----
|
||||
|
|
|
|||
Loading…
Reference in New Issue