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가지 아이템 모드 완성
|
### pop-dashboard 스타일 정리
|
||||||
**상태**: 코딩 완료, 브라우저 테스트 대기
|
**상태**: 코딩 완료, 브라우저 확인 대기
|
||||||
**계획서**: [PLAN.MD](./PLAN.MD)
|
**계획서**: [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-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 |
|
||||||
| 2026-02-10 | A-2: 차트 xAxisColumn/yAxisColumn 입력 UI | 차트 설정 섹션에 X/Y축 컬럼 입력 필드 |
|
| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 |
|
||||||
| 2026-02-10 | A-3: 통계 카드 카테고리 설정 UI | 카테고리 추가/삭제/편집 인라인 에디터 |
|
| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 |
|
||||||
| 2026-02-10 | B-1: 차트 xAxisColumn 자동 보정 | groupBy 있으면 xAxisColumn 자동 설정 |
|
| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 |
|
||||||
| 2026-02-10 | B-2: 통계 카드 카테고리별 필터 적용 | rows 필터링으로 카테고리별 독립 건수 표시 버그 수정 |
|
|
||||||
| 2026-02-10 | fetchTableColumns 폴백 추가 | tableManagementApi 우선 사용으로 컬럼 로딩 안정화 |
|
|
||||||
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
|
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
|
||||||
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
|
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
|
||||||
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
|
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
|
||||||
|
|
@ -40,9 +41,6 @@
|
||||||
|
|
||||||
| # | 이슈 | 심각도 | 상태 |
|
| # | 이슈 | 심각도 | 상태 |
|
||||||
|---|------|--------|------|
|
|---|------|--------|------|
|
||||||
| 1 | ~~차트 groupBy 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-1) |
|
| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
|
||||||
| 2 | ~~차트 xAxisColumn 미설정 시 빈 차트~~ | ~~높음~~ | 수정 완료 (A-2, B-1) |
|
| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |
|
||||||
| 3 | ~~통계 카드 카테고리 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-3) |
|
| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
||||||
| 4 | ~~통계 카드 카테고리별 필터 미적용 버그~~ | ~~높음~~ | 수정 완료 (B-2) |
|
|
||||||
| 5 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
|
|
||||||
| 6 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ interface PopCanvasProps {
|
||||||
onLockLayout?: () => void;
|
onLockLayout?: () => void;
|
||||||
onResetOverride?: (mode: GridMode) => void;
|
onResetOverride?: (mode: GridMode) => void;
|
||||||
onChangeGapPreset?: (preset: GapPreset) => void;
|
onChangeGapPreset?: (preset: GapPreset) => void;
|
||||||
|
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
|
||||||
|
previewPageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -135,6 +137,7 @@ export default function PopCanvas({
|
||||||
onLockLayout,
|
onLockLayout,
|
||||||
onResetOverride,
|
onResetOverride,
|
||||||
onChangeGapPreset,
|
onChangeGapPreset,
|
||||||
|
previewPageIndex,
|
||||||
}: PopCanvasProps) {
|
}: PopCanvasProps) {
|
||||||
// 줌 상태
|
// 줌 상태
|
||||||
const [canvasScale, setCanvasScale] = useState(0.8);
|
const [canvasScale, setCanvasScale] = useState(0.8);
|
||||||
|
|
@ -690,6 +693,7 @@ export default function PopCanvas({
|
||||||
onComponentResizeEnd={onResizeEnd}
|
onComponentResizeEnd={onResizeEnd}
|
||||||
overrideGap={adjustedGap}
|
overrideGap={adjustedGap}
|
||||||
overridePadding={adjustedPadding}
|
overridePadding={adjustedPadding}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ export default function PopDesigner({
|
||||||
// 선택 상태
|
// 선택 상태
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드)
|
||||||
|
const [previewPageIndex, setPreviewPageIndex] = useState<number>(-1);
|
||||||
|
|
||||||
// 그리드 모드 (4개 프리셋)
|
// 그리드 모드 (4개 프리셋)
|
||||||
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||||
|
|
||||||
|
|
@ -217,24 +220,28 @@ export default function PopDesigner({
|
||||||
|
|
||||||
const handleUpdateComponent = useCallback(
|
const handleUpdateComponent = useCallback(
|
||||||
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
||||||
const existingComponent = layout.components[componentId];
|
// 함수적 업데이트로 stale closure 방지
|
||||||
if (!existingComponent) return;
|
setLayout((prev) => {
|
||||||
|
const existingComponent = prev.components[componentId];
|
||||||
|
if (!existingComponent) return prev;
|
||||||
|
|
||||||
const newLayout = {
|
const newComponent = {
|
||||||
...layout,
|
|
||||||
components: {
|
|
||||||
...layout.components,
|
|
||||||
[componentId]: {
|
|
||||||
...existingComponent,
|
...existingComponent,
|
||||||
...updates,
|
...updates,
|
||||||
},
|
};
|
||||||
|
const newLayout = {
|
||||||
|
...prev,
|
||||||
|
components: {
|
||||||
|
...prev.components,
|
||||||
|
[componentId]: newComponent,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setLayout(newLayout);
|
|
||||||
saveToHistory(newLayout);
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
});
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layout, saveToHistory]
|
[saveToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteComponent = useCallback(
|
const handleDeleteComponent = useCallback(
|
||||||
|
|
@ -637,6 +644,7 @@ export default function PopDesigner({
|
||||||
onLockLayout={handleLockLayout}
|
onLockLayout={handleLockLayout}
|
||||||
onResetOverride={handleResetOverride}
|
onResetOverride={handleResetOverride}
|
||||||
onChangeGapPreset={handleChangeGapPreset}
|
onChangeGapPreset={handleChangeGapPreset}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|
@ -655,6 +663,8 @@ export default function PopDesigner({
|
||||||
allComponents={Object.values(layout.components)}
|
allComponents={Object.values(layout.components)}
|
||||||
onSelectComponent={setSelectedComponentId}
|
onSelectComponent={setSelectedComponentId}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
onPreviewPage={setPreviewPageIndex}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ interface ComponentEditorPanelProps {
|
||||||
onSelectComponent?: (componentId: string) => void;
|
onSelectComponent?: (componentId: string) => void;
|
||||||
/** 현재 선택된 컴포넌트 ID */
|
/** 현재 선택된 컴포넌트 ID */
|
||||||
selectedComponentId?: string | null;
|
selectedComponentId?: string | null;
|
||||||
|
/** 대시보드 페이지 미리보기 인덱스 */
|
||||||
|
previewPageIndex?: number;
|
||||||
|
/** 페이지 미리보기 요청 콜백 */
|
||||||
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -73,6 +77,8 @@ export default function ComponentEditorPanel({
|
||||||
allComponents,
|
allComponents,
|
||||||
onSelectComponent,
|
onSelectComponent,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
|
previewPageIndex,
|
||||||
|
onPreviewPage,
|
||||||
}: ComponentEditorPanelProps) {
|
}: ComponentEditorPanelProps) {
|
||||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||||
|
|
||||||
|
|
@ -182,6 +188,8 @@ export default function ComponentEditorPanel({
|
||||||
<ComponentSettingsForm
|
<ComponentSettingsForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
onPreviewPage={onPreviewPage}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -362,9 +370,11 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
||||||
interface ComponentSettingsFormProps {
|
interface ComponentSettingsFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinitionV5;
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
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 가져오기
|
// PopComponentRegistry에서 configPanel 가져오기
|
||||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const ConfigPanel = registeredComp?.configPanel;
|
const ConfigPanel = registeredComp?.configPanel;
|
||||||
|
|
@ -393,6 +403,8 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
config={component.config || {}}
|
config={component.config || {}}
|
||||||
onUpdate={handleConfigUpdate}
|
onUpdate={handleConfigUpdate}
|
||||||
|
onPreviewPage={onPreviewPage}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg bg-gray-50 p-3">
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ interface PopRendererProps {
|
||||||
overridePadding?: number;
|
overridePadding?: number;
|
||||||
/** 추가 className */
|
/** 추가 className */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 대시보드 페이지 미리보기 인덱스 */
|
||||||
|
previewPageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -85,6 +87,7 @@ export default function PopRenderer({
|
||||||
overrideGap,
|
overrideGap,
|
||||||
overridePadding,
|
overridePadding,
|
||||||
className,
|
className,
|
||||||
|
previewPageIndex,
|
||||||
}: PopRendererProps) {
|
}: PopRendererProps) {
|
||||||
const { gridConfig, components, overrides } = layout;
|
const { gridConfig, components, overrides } = layout;
|
||||||
|
|
||||||
|
|
@ -250,6 +253,7 @@ export default function PopRenderer({
|
||||||
onComponentMove={onComponentMove}
|
onComponentMove={onComponentMove}
|
||||||
onComponentResize={onComponentResize}
|
onComponentResize={onComponentResize}
|
||||||
onComponentResizeEnd={onComponentResizeEnd}
|
onComponentResizeEnd={onComponentResizeEnd}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -293,6 +297,7 @@ interface DraggableComponentProps {
|
||||||
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResizeEnd?: (componentId: string) => void;
|
onComponentResizeEnd?: (componentId: string) => void;
|
||||||
|
previewPageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableComponent({
|
function DraggableComponent({
|
||||||
|
|
@ -310,6 +315,7 @@ function DraggableComponent({
|
||||||
onComponentMove,
|
onComponentMove,
|
||||||
onComponentResize,
|
onComponentResize,
|
||||||
onComponentResizeEnd,
|
onComponentResizeEnd,
|
||||||
|
previewPageIndex,
|
||||||
}: DraggableComponentProps) {
|
}: DraggableComponentProps) {
|
||||||
const [{ isDragging }, drag] = useDrag(
|
const [{ isDragging }, drag] = useDrag(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -348,6 +354,7 @@ function DraggableComponent({
|
||||||
effectivePosition={position}
|
effectivePosition={position}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
||||||
|
|
@ -498,9 +505,10 @@ interface ComponentContentProps {
|
||||||
effectivePosition: PopGridPosition;
|
effectivePosition: PopGridPosition;
|
||||||
isDesignMode: boolean;
|
isDesignMode: boolean;
|
||||||
isSelected: 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;
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
||||||
|
|
@ -515,7 +523,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-hidden pointer-events-none">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,8 +159,11 @@ async function loadItemData(item: DashboardItem): Promise<ItemData> {
|
||||||
|
|
||||||
export function PopDashboardComponent({
|
export function PopDashboardComponent({
|
||||||
config,
|
config,
|
||||||
|
previewPageIndex,
|
||||||
}: {
|
}: {
|
||||||
config?: PopDashboardConfig;
|
config?: PopDashboardConfig;
|
||||||
|
/** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */
|
||||||
|
previewPageIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const [dataMap, setDataMap] = useState<Record<string, ItemData>>({});
|
const [dataMap, setDataMap] = useState<Record<string, ItemData>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -362,7 +365,8 @@ export function PopDashboardComponent({
|
||||||
const displayMode = migrated.displayMode;
|
const displayMode = migrated.displayMode;
|
||||||
|
|
||||||
// 페이지 하나를 GridModeComponent로 렌더링
|
// 페이지 하나를 GridModeComponent로 렌더링
|
||||||
const renderPageContent = (page: DashboardPage) => (
|
const renderPageContent = (page: DashboardPage) => {
|
||||||
|
return (
|
||||||
<GridModeComponent
|
<GridModeComponent
|
||||||
cells={page.gridCells}
|
cells={page.gridCells}
|
||||||
columns={page.gridColumns}
|
columns={page.gridColumns}
|
||||||
|
|
@ -376,6 +380,7 @@ export function PopDashboardComponent({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
|
// 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
|
||||||
const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
|
const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
|
||||||
|
|
@ -392,6 +397,27 @@ export function PopDashboardComponent({
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Check,
|
Check,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -58,6 +59,10 @@ import type {
|
||||||
DashboardPage,
|
DashboardPage,
|
||||||
JoinConfig,
|
JoinConfig,
|
||||||
JoinType,
|
JoinType,
|
||||||
|
ItemStyleConfig,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
TEXT_ALIGN_LABELS,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { migrateConfig } from "./PopDashboardComponent";
|
import { migrateConfig } from "./PopDashboardComponent";
|
||||||
import {
|
import {
|
||||||
|
|
@ -73,6 +78,10 @@ import { validateExpression } from "./utils/formula";
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
config: PopDashboardConfig | undefined;
|
config: PopDashboardConfig | undefined;
|
||||||
onUpdate: (config: PopDashboardConfig) => void;
|
onUpdate: (config: PopDashboardConfig) => void;
|
||||||
|
/** 페이지 미리보기 요청 (-1이면 해제) */
|
||||||
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
|
/** 현재 미리보기 중인 페이지 인덱스 */
|
||||||
|
previewPageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 기본값 =====
|
// ===== 기본값 =====
|
||||||
|
|
@ -1528,18 +1537,92 @@ function generateDefaultCells(
|
||||||
return cells;
|
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({
|
function GridLayoutEditor({
|
||||||
cells,
|
cells,
|
||||||
gridColumns,
|
gridColumns,
|
||||||
gridRows,
|
gridRows,
|
||||||
items,
|
items,
|
||||||
onChange,
|
onChange,
|
||||||
|
onUpdateItem,
|
||||||
}: {
|
}: {
|
||||||
cells: DashboardCell[];
|
cells: DashboardCell[];
|
||||||
gridColumns: number;
|
gridColumns: number;
|
||||||
gridRows: number;
|
gridRows: number;
|
||||||
items: DashboardItem[];
|
items: DashboardItem[];
|
||||||
onChange: (cells: DashboardCell[], cols: number, rows: number) => void;
|
onChange: (cells: DashboardCell[], cols: number, rows: number) => void;
|
||||||
|
/** 아이템 스타일 업데이트 콜백 */
|
||||||
|
onUpdateItem?: (updatedItem: DashboardItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const ensuredCells =
|
const ensuredCells =
|
||||||
cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows);
|
cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows);
|
||||||
|
|
@ -1710,6 +1793,34 @@ function GridLayoutEditor({
|
||||||
각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을
|
각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을
|
||||||
추가/삭제할 수 있습니다.
|
추가/삭제할 수 있습니다.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1722,12 +1833,18 @@ function PageEditor({
|
||||||
items,
|
items,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onPreview,
|
||||||
|
isPreviewing,
|
||||||
|
onUpdateItem,
|
||||||
}: {
|
}: {
|
||||||
page: DashboardPage;
|
page: DashboardPage;
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
items: DashboardItem[];
|
items: DashboardItem[];
|
||||||
onChange: (updatedPage: DashboardPage) => void;
|
onChange: (updatedPage: DashboardPage) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onPreview?: () => void;
|
||||||
|
isPreviewing?: boolean;
|
||||||
|
onUpdateItem?: (updatedItem: DashboardItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
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">
|
<span className="rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
|
||||||
{page.gridColumns}x{page.gridRows}
|
{page.gridColumns}x{page.gridRows}
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
variant={isPreviewing ? "default" : "ghost"}
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => onPreview?.()}
|
||||||
|
title="이 페이지 미리보기"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -1793,6 +1919,7 @@ function PageEditor({
|
||||||
gridRows: rows,
|
gridRows: rows,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onUpdateItem={onUpdateItem}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1802,10 +1929,8 @@ function PageEditor({
|
||||||
|
|
||||||
// ===== 메인 설정 패널 =====
|
// ===== 메인 설정 패널 =====
|
||||||
|
|
||||||
export function PopDashboardConfigPanel({
|
export function PopDashboardConfigPanel(props: ConfigPanelProps) {
|
||||||
config,
|
const { config, onUpdate: onChange } = props;
|
||||||
onUpdate: onChange,
|
|
||||||
}: ConfigPanelProps) {
|
|
||||||
// config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장
|
// config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장
|
||||||
const merged = { ...DEFAULT_CONFIG, ...(config || {}) };
|
const merged = { ...DEFAULT_CONFIG, ...(config || {}) };
|
||||||
|
|
||||||
|
|
@ -2068,6 +2193,23 @@ export function PopDashboardConfigPanel({
|
||||||
);
|
);
|
||||||
updateConfig({ pages: newPages });
|
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,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
|
CartesianGrid,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { DashboardItem } from "../../types";
|
import type { DashboardItem } from "../../types";
|
||||||
|
import { TEXT_ALIGN_CLASSES } from "../../types";
|
||||||
|
import { abbreviateNumber } from "../utils/formula";
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
|
|
||||||
|
|
@ -58,7 +61,7 @@ export function ChartItemComponent({
|
||||||
rows,
|
rows,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
}: ChartItemProps) {
|
}: ChartItemProps) {
|
||||||
const { chartConfig, visibility } = item;
|
const { chartConfig, visibility, itemStyle } = item;
|
||||||
const chartType = chartConfig?.chartType ?? "bar";
|
const chartType = chartConfig?.chartType ?? "bar";
|
||||||
const colors = chartConfig?.colors?.length
|
const colors = chartConfig?.colors?.length
|
||||||
? chartConfig.colors
|
? chartConfig.colors
|
||||||
|
|
@ -66,6 +69,9 @@ export function ChartItemComponent({
|
||||||
const xKey = chartConfig?.xAxisColumn ?? "name";
|
const xKey = chartConfig?.xAxisColumn ?? "name";
|
||||||
const yKey = chartConfig?.yAxisColumn ?? "value";
|
const yKey = chartConfig?.yAxisColumn ?? "value";
|
||||||
|
|
||||||
|
// 라벨 정렬만 사용자 설정
|
||||||
|
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
||||||
|
|
||||||
// 컨테이너가 너무 작으면 메시지 표시
|
// 컨테이너가 너무 작으면 메시지 표시
|
||||||
if (containerWidth < MIN_CHART_WIDTH) {
|
if (containerWidth < MIN_CHART_WIDTH) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="@container flex h-full w-full flex-col p-2">
|
<div className="@container flex h-full w-full flex-col p-2">
|
||||||
{/* 라벨 */}
|
{/* 라벨 - 사용자 정렬 적용 */}
|
||||||
{visibility.showLabel && (
|
{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}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -97,24 +115,34 @@ export function ChartItemComponent({
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
{chartType === "bar" ? (
|
{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
|
<XAxis
|
||||||
dataKey={xKey}
|
dataKey={xKey}
|
||||||
tick={{ fontSize: 10 }}
|
tick={xAxisTickProps}
|
||||||
hide={containerWidth < 200}
|
hide={containerWidth < 200}
|
||||||
/>
|
/>
|
||||||
<YAxis tick={{ fontSize: 10 }} hide={containerWidth < 200} />
|
<YAxis
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
hide={containerWidth < 200}
|
||||||
|
tickFormatter={(v: number) => abbreviateNumber(v)}
|
||||||
|
/>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey={yKey} fill={colors[0]} radius={[2, 2, 0, 0]} />
|
<Bar dataKey={yKey} fill={colors[0]} radius={[2, 2, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
) : chartType === "line" ? (
|
) : 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
|
<XAxis
|
||||||
dataKey={xKey}
|
dataKey={xKey}
|
||||||
tick={{ fontSize: 10 }}
|
tick={xAxisTickProps}
|
||||||
hide={containerWidth < 200}
|
hide={containerWidth < 200}
|
||||||
/>
|
/>
|
||||||
<YAxis tick={{ fontSize: 10 }} hide={containerWidth < 200} />
|
<YAxis
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
hide={containerWidth < 200}
|
||||||
|
tickFormatter={(v: number) => abbreviateNumber(v)}
|
||||||
|
/>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
|
@ -137,7 +165,7 @@ export function ChartItemComponent({
|
||||||
label={
|
label={
|
||||||
containerWidth > 250
|
containerWidth > 250
|
||||||
? ({ name, value, percent }: { name: string; value: number; percent: number }) =>
|
? ({ name, value, percent }: { name: string; value: number; percent: number }) =>
|
||||||
`${name} ${value} (${(percent * 100).toFixed(0)}%)`
|
`${name} ${abbreviateNumber(value)} (${(percent * 100).toFixed(0)}%)`
|
||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
labelLine={containerWidth > 250}
|
labelLine={containerWidth > 250}
|
||||||
|
|
@ -150,7 +178,7 @@ export function ChartItemComponent({
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number, name: string) => [value, name]}
|
formatter={(value: number, name: string) => [abbreviateNumber(value), name]}
|
||||||
/>
|
/>
|
||||||
{containerWidth > 300 && (
|
{containerWidth > 300 && (
|
||||||
<Legend
|
<Legend
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,19 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
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";
|
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 =====
|
// ===== Props =====
|
||||||
|
|
||||||
export interface GaugeItemProps {
|
export interface GaugeItemProps {
|
||||||
|
|
@ -43,12 +53,19 @@ export function GaugeItemComponent({
|
||||||
data,
|
data,
|
||||||
targetValue,
|
targetValue,
|
||||||
}: GaugeItemProps) {
|
}: GaugeItemProps) {
|
||||||
const { visibility, gaugeConfig } = item;
|
const { visibility, gaugeConfig, itemStyle } = item;
|
||||||
const current = data ?? 0;
|
const current = data ?? 0;
|
||||||
const min = gaugeConfig?.min ?? 0;
|
const min = gaugeConfig?.min ?? 0;
|
||||||
const max = gaugeConfig?.max ?? 100;
|
const max = gaugeConfig?.max ?? 100;
|
||||||
const target = targetValue ?? gaugeConfig?.target ?? max;
|
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)
|
// 달성률 계산 (0~100)
|
||||||
const range = max - min;
|
const range = max - min;
|
||||||
const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0;
|
const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0;
|
||||||
|
|
@ -70,9 +87,9 @@ export function GaugeItemComponent({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-2">
|
<div className="@container flex h-full w-full flex-col items-center justify-center p-2">
|
||||||
{/* 라벨 */}
|
{/* 라벨 - 사용자 정렬 적용 */}
|
||||||
{visibility.showLabel && (
|
{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}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -110,8 +127,8 @@ export function GaugeItemComponent({
|
||||||
x={cx}
|
x={cx}
|
||||||
y={cy - 10}
|
y={cy - 10}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-foreground text-2xl font-bold"
|
className="fill-foreground font-bold"
|
||||||
fontSize="24"
|
fontSize={svgValueFontSize}
|
||||||
>
|
>
|
||||||
{abbreviateNumber(current)}
|
{abbreviateNumber(current)}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -122,8 +139,8 @@ export function GaugeItemComponent({
|
||||||
x={cx}
|
x={cx}
|
||||||
y={cy + 10}
|
y={cy + 10}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-muted-foreground text-xs"
|
className="fill-muted-foreground"
|
||||||
fontSize="12"
|
fontSize={svgSubFontSize}
|
||||||
>
|
>
|
||||||
{percentage.toFixed(1)}%
|
{percentage.toFixed(1)}%
|
||||||
</text>
|
</text>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { DashboardItem } from "../../types";
|
import type { DashboardItem } from "../../types";
|
||||||
|
import { TEXT_ALIGN_CLASSES } from "../../types";
|
||||||
import { abbreviateNumber } from "../utils/formula";
|
import { abbreviateNumber } from "../utils/formula";
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
|
|
@ -61,20 +62,23 @@ export function KpiCardComponent({
|
||||||
trendValue,
|
trendValue,
|
||||||
formulaDisplay,
|
formulaDisplay,
|
||||||
}: KpiCardProps) {
|
}: KpiCardProps) {
|
||||||
const { visibility, kpiConfig } = item;
|
const { visibility, kpiConfig, itemStyle } = item;
|
||||||
const displayValue = data ?? 0;
|
const displayValue = data ?? 0;
|
||||||
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
|
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
|
||||||
|
|
||||||
|
// 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동
|
||||||
|
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
||||||
{/* 라벨 */}
|
{/* 라벨 - 사용자 정렬 적용 */}
|
||||||
{visibility.showLabel && (
|
{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}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 메인 값 */}
|
{/* 메인 값 - @container 반응형 */}
|
||||||
{visibility.showValue && (
|
{visibility.showValue && (
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span
|
<span
|
||||||
|
|
@ -100,7 +104,7 @@ export function KpiCardComponent({
|
||||||
|
|
||||||
{/* 보조 라벨 (수식 표시 등) */}
|
{/* 보조 라벨 (수식 표시 등) */}
|
||||||
{visibility.showSubLabel && formulaDisplay && (
|
{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(" / ")}
|
{item.formula?.values.map((v) => v.label).join(" / ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { DashboardItem } from "../../types";
|
import type { DashboardItem } from "../../types";
|
||||||
|
import { TEXT_ALIGN_CLASSES } from "../../types";
|
||||||
import { abbreviateNumber } from "../utils/formula";
|
import { abbreviateNumber } from "../utils/formula";
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
|
|
@ -32,20 +33,23 @@ const DEFAULT_STAT_COLORS = [
|
||||||
// ===== 메인 컴포넌트 =====
|
// ===== 메인 컴포넌트 =====
|
||||||
|
|
||||||
export function StatCardComponent({ item, categoryData }: StatCardProps) {
|
export function StatCardComponent({ item, categoryData }: StatCardProps) {
|
||||||
const { visibility, statConfig } = item;
|
const { visibility, statConfig, itemStyle } = item;
|
||||||
const categories = statConfig?.categories ?? [];
|
const categories = statConfig?.categories ?? [];
|
||||||
const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
|
const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
|
||||||
|
|
||||||
|
// 라벨 정렬만 사용자 설정
|
||||||
|
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
||||||
{/* 라벨 */}
|
{/* 라벨 - 사용자 정렬 적용 */}
|
||||||
{visibility.showLabel && (
|
{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}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 총합 */}
|
{/* 총합 - @container 반응형 */}
|
||||||
{visibility.showValue && (
|
{visibility.showValue && (
|
||||||
<p className="mb-2 text-lg font-bold @[200px]:text-2xl @[350px]:text-3xl">
|
<p className="mb-2 text-lg font-bold @[200px]:text-2xl @[350px]:text-3xl">
|
||||||
{abbreviateNumber(total)}
|
{abbreviateNumber(total)}
|
||||||
|
|
@ -80,7 +84,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) {
|
||||||
|
|
||||||
{/* 보조 라벨 (단위 등) */}
|
{/* 보조 라벨 (단위 등) */}
|
||||||
{visibility.showSubLabel && (
|
{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
|
{visibility.showUnit && item.kpiConfig?.unit
|
||||||
? `단위: ${item.kpiConfig.unit}`
|
? `단위: ${item.kpiConfig.unit}`
|
||||||
: ""}
|
: ""}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export const FONT_SIZE_CLASSES: Record<FontSize, string> = {
|
||||||
xl: "text-[64px]",
|
xl: "text-[64px]",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
|
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
|
||||||
normal: "font-normal",
|
normal: "font-normal",
|
||||||
medium: "font-medium",
|
medium: "font-medium",
|
||||||
|
|
@ -290,6 +291,13 @@ export interface DashboardPage {
|
||||||
gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정)
|
gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- 대시보드 아이템 스타일 설정 -----
|
||||||
|
|
||||||
|
export interface ItemStyleConfig {
|
||||||
|
/** 라벨 텍스트 정렬 (기본: center) */
|
||||||
|
labelAlign?: TextAlign;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- 대시보드 아이템 -----
|
// ----- 대시보드 아이템 -----
|
||||||
|
|
||||||
export interface DashboardItem {
|
export interface DashboardItem {
|
||||||
|
|
@ -310,6 +318,9 @@ export interface DashboardItem {
|
||||||
chartConfig?: ChartItemConfig;
|
chartConfig?: ChartItemConfig;
|
||||||
gaugeConfig?: GaugeConfig;
|
gaugeConfig?: GaugeConfig;
|
||||||
statConfig?: StatCardConfig;
|
statConfig?: StatCardConfig;
|
||||||
|
|
||||||
|
/** 스타일 설정 (정렬, 글자 크기 3그룹) */
|
||||||
|
itemStyle?: ItemStyleConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 대시보드 전체 설정 -----
|
// ----- 대시보드 전체 설정 -----
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue