feat(pop-dashboard): 페이지 기반 구조 전환 및 설정 패널 고도화
구조 변경: - grid 모드를 독립 displayMode에서 페이지 내부 그리드 레이아웃으로 전환 - DashboardPage 타입 추가 (각 페이지가 독립 그리드 보유) - migrateConfig()로 기존 grid/useGridLayout 설정 자동 마이그레이션 설정 패널 (PopDashboardConfig): - 드롭다운 기반 집계 설정 UI 전면 재작성 (+917줄) - 테이블/컬럼 선택 Combobox, 페이지 관리, 셀 배치 편집기 - fetchTableList() 추가 (테이블 목록 조회) 컴포넌트/모드 개선: - GridMode: 반응형 자동 열 축소 (MIN_CELL_WIDTH 기준) - PopDashboardComponent: 페이지 기반 렌더링 로직 - PopDashboardPreview: 페이지 뱃지 표시 기타: - ComponentEditorPanel: 탭 콘텐츠 스크롤 수정 (min-h-0 추가) - types.ts: grid를 displayMode에서 제거, DashboardPage 타입 추가 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
73e3d56381
commit
dc523d86c3
|
|
@ -97,8 +97,8 @@ export default function ComponentEditorPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
<Tabs defaultValue="position" className="flex min-h-0 flex-1 flex-col">
|
||||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
<TabsList className="w-full shrink-0 justify-start rounded-none border-b bg-transparent px-2">
|
||||||
<TabsTrigger value="position" className="gap-1 text-xs">
|
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||||
<Grid3x3 className="h-3 w-3" />
|
<Grid3x3 className="h-3 w-3" />
|
||||||
위치
|
위치
|
||||||
|
|
@ -118,7 +118,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 위치 탭 */}
|
{/* 위치 탭 */}
|
||||||
<TabsContent value="position" className="flex-1 overflow-auto p-4">
|
<TabsContent value="position" className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
<PositionForm
|
<PositionForm
|
||||||
component={component}
|
component={component}
|
||||||
currentMode={currentMode}
|
currentMode={currentMode}
|
||||||
|
|
@ -129,7 +129,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 설정 탭 */}
|
{/* 설정 탭 */}
|
||||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
<TabsContent value="settings" className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
<ComponentSettingsForm
|
<ComponentSettingsForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
|
|
@ -137,7 +137,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 표시 탭 */}
|
{/* 표시 탭 */}
|
||||||
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
<TabsContent value="visibility" className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
<VisibilityForm
|
<VisibilityForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
|
|
@ -145,7 +145,7 @@ export default function ComponentEditorPanel({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 데이터 탭 */}
|
{/* 데이터 탭 */}
|
||||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
<TabsContent value="data" className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
<DataBindingPlaceholder />
|
<DataBindingPlaceholder />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type {
|
import type {
|
||||||
PopDashboardConfig,
|
PopDashboardConfig,
|
||||||
DashboardItem,
|
DashboardItem,
|
||||||
|
DashboardPage,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { fetchAggregatedData } from "./utils/dataFetcher";
|
import { fetchAggregatedData } from "./utils/dataFetcher";
|
||||||
import {
|
import {
|
||||||
|
|
@ -33,6 +34,62 @@ import { AutoSlideModeComponent } from "./modes/AutoSlideMode";
|
||||||
import { GridModeComponent } from "./modes/GridMode";
|
import { GridModeComponent } from "./modes/GridMode";
|
||||||
import { ScrollModeComponent } from "./modes/ScrollMode";
|
import { ScrollModeComponent } from "./modes/ScrollMode";
|
||||||
|
|
||||||
|
// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 config를 페이지 기반 구조로 마이그레이션.
|
||||||
|
* 런타임에서만 사용 (저장된 config 원본은 변경하지 않음).
|
||||||
|
*
|
||||||
|
* 시나리오1: displayMode="grid" (가장 오래된 형태)
|
||||||
|
* 시나리오2: useGridLayout=true (직전 마이그레이션 결과)
|
||||||
|
* 시나리오3: pages 이미 있음 (새 형태) -> 변환 불필요
|
||||||
|
* 시나리오4: pages 없음 + grid 아님 -> 빈 pages (아이템 하나씩 슬라이드)
|
||||||
|
*/
|
||||||
|
export function migrateConfig(
|
||||||
|
raw: Record<string, unknown>
|
||||||
|
): PopDashboardConfig {
|
||||||
|
const config = { ...raw } as PopDashboardConfig & Record<string, unknown>;
|
||||||
|
|
||||||
|
// pages가 이미 있으면 마이그레이션 불필요
|
||||||
|
if (
|
||||||
|
Array.isArray(config.pages) &&
|
||||||
|
config.pages.length > 0
|
||||||
|
) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true
|
||||||
|
const wasGrid =
|
||||||
|
config.displayMode === ("grid" as string) ||
|
||||||
|
(config as Record<string, unknown>).useGridLayout === true;
|
||||||
|
|
||||||
|
if (wasGrid) {
|
||||||
|
const cols =
|
||||||
|
((config as Record<string, unknown>).gridColumns as number) ?? 2;
|
||||||
|
const rows =
|
||||||
|
((config as Record<string, unknown>).gridRows as number) ?? 2;
|
||||||
|
const cells =
|
||||||
|
((config as Record<string, unknown>).gridCells as DashboardPage["gridCells"]) ?? [];
|
||||||
|
|
||||||
|
const page: DashboardPage = {
|
||||||
|
id: "migrated-page-1",
|
||||||
|
label: "페이지 1",
|
||||||
|
gridColumns: cols,
|
||||||
|
gridRows: rows,
|
||||||
|
gridCells: cells,
|
||||||
|
};
|
||||||
|
|
||||||
|
config.pages = [page];
|
||||||
|
|
||||||
|
// displayMode="grid" 보정
|
||||||
|
if (config.displayMode === ("grid" as string)) {
|
||||||
|
(config as Record<string, unknown>).displayMode = "arrows";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config as PopDashboardConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 내부 타입 =====
|
// ===== 내부 타입 =====
|
||||||
|
|
||||||
interface ItemData {
|
interface ItemData {
|
||||||
|
|
@ -259,9 +316,43 @@ export function PopDashboardComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 표시 모드별 렌더링
|
// 마이그레이션: 기존 config를 페이지 기반으로 변환
|
||||||
const displayMode = config.displayMode;
|
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
|
||||||
|
const pages = migrated.pages ?? [];
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
|
||||||
|
const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
|
||||||
|
|
||||||
|
// 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템
|
||||||
|
const renderSlide = (index: number) => {
|
||||||
|
if (pages.length > 0 && pages[index]) {
|
||||||
|
return renderPageContent(pages[index]);
|
||||||
|
}
|
||||||
|
// fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시)
|
||||||
|
if (visibleItems[index]) {
|
||||||
|
return renderSingleItem(visibleItems[index]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 표시 모드별 렌더링
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -274,41 +365,27 @@ export function PopDashboardComponent({
|
||||||
>
|
>
|
||||||
{displayMode === "arrows" && (
|
{displayMode === "arrows" && (
|
||||||
<ArrowsModeComponent
|
<ArrowsModeComponent
|
||||||
itemCount={visibleItems.length}
|
itemCount={slideCount}
|
||||||
showIndicator={config.showIndicator}
|
showIndicator={config.showIndicator}
|
||||||
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
renderItem={renderSlide}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displayMode === "auto-slide" && (
|
{displayMode === "auto-slide" && (
|
||||||
<AutoSlideModeComponent
|
<AutoSlideModeComponent
|
||||||
itemCount={visibleItems.length}
|
itemCount={slideCount}
|
||||||
interval={config.autoSlideInterval}
|
interval={config.autoSlideInterval}
|
||||||
resumeDelay={config.autoSlideResumeDelay}
|
resumeDelay={config.autoSlideResumeDelay}
|
||||||
showIndicator={config.showIndicator}
|
showIndicator={config.showIndicator}
|
||||||
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
renderItem={renderSlide}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayMode === "grid" && (
|
|
||||||
<GridModeComponent
|
|
||||||
cells={config.gridCells ?? []}
|
|
||||||
columns={config.gridColumns ?? 2}
|
|
||||||
rows={config.gridRows ?? 2}
|
|
||||||
gap={config.gap}
|
|
||||||
renderItem={(itemId) => {
|
|
||||||
const item = visibleItems.find((i) => i.id === itemId);
|
|
||||||
if (!item) return null;
|
|
||||||
return renderSingleItem(item);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displayMode === "scroll" && (
|
{displayMode === "scroll" && (
|
||||||
<ScrollModeComponent
|
<ScrollModeComponent
|
||||||
itemCount={visibleItems.length}
|
itemCount={slideCount}
|
||||||
showIndicator={config.showIndicator}
|
showIndicator={config.showIndicator}
|
||||||
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
renderItem={renderSlide}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,6 +10,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react";
|
import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react";
|
||||||
import type { PopDashboardConfig, DashboardSubType } from "../types";
|
import type { PopDashboardConfig, DashboardSubType } from "../types";
|
||||||
|
import { migrateConfig } from "./PopDashboardComponent";
|
||||||
|
|
||||||
// ===== 서브타입별 아이콘 매핑 =====
|
// ===== 서브타입별 아이콘 매핑 =====
|
||||||
|
|
||||||
|
|
@ -32,7 +33,6 @@ const SUBTYPE_LABELS: Record<DashboardSubType, string> = {
|
||||||
const MODE_LABELS: Record<string, string> = {
|
const MODE_LABELS: Record<string, string> = {
|
||||||
arrows: "좌우 버튼",
|
arrows: "좌우 버튼",
|
||||||
"auto-slide": "자동 슬라이드",
|
"auto-slide": "자동 슬라이드",
|
||||||
grid: "그리드",
|
|
||||||
scroll: "스크롤",
|
scroll: "스크롤",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,34 +75,43 @@ export function PopDashboardPreviewComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleItems = config.items.filter((i) => i.visible);
|
const visibleItems = config.items.filter((i) => i.visible);
|
||||||
const mode = config.displayMode;
|
|
||||||
|
// 마이그레이션 적용
|
||||||
|
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
|
||||||
|
const pages = migrated.pages ?? [];
|
||||||
|
const hasPages = pages.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden p-1">
|
<div className="flex h-full w-full flex-col overflow-hidden p-1">
|
||||||
{/* 모드 표시 */}
|
{/* 모드 + 페이지 뱃지 */}
|
||||||
<div className="mb-1 flex items-center gap-1">
|
<div className="mb-1 flex items-center gap-1">
|
||||||
<span className="rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
|
<span className="rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
|
||||||
{MODE_LABELS[mode] ?? mode}
|
{MODE_LABELS[migrated.displayMode] ?? migrated.displayMode}
|
||||||
</span>
|
</span>
|
||||||
|
{hasPages && (
|
||||||
|
<span className="rounded bg-muted px-1 py-0.5 text-[8px] font-medium text-muted-foreground">
|
||||||
|
{pages.length}페이지
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-[8px] text-muted-foreground">
|
<span className="text-[8px] text-muted-foreground">
|
||||||
{visibleItems.length}개
|
{visibleItems.length}개
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모드별 미리보기 */}
|
{/* 미리보기 */}
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
{mode === "grid" ? (
|
{hasPages ? (
|
||||||
// 그리드: 셀 구조 시각화
|
// 첫 번째 페이지 그리드 미리보기
|
||||||
<div
|
<div
|
||||||
className="h-full w-full gap-1"
|
className="h-full w-full gap-1"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${config.gridColumns ?? 2}, 1fr)`,
|
gridTemplateColumns: `repeat(${pages[0].gridColumns}, 1fr)`,
|
||||||
gridTemplateRows: `repeat(${config.gridRows ?? 2}, 1fr)`,
|
gridTemplateRows: `repeat(${pages[0].gridRows}, 1fr)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{config.gridCells?.length
|
{pages[0].gridCells.length > 0
|
||||||
? config.gridCells.map((cell) => {
|
? pages[0].gridCells.map((cell) => {
|
||||||
const item = visibleItems.find(
|
const item = visibleItems.find(
|
||||||
(i) => i.id === cell.itemId
|
(i) => i.id === cell.itemId
|
||||||
);
|
);
|
||||||
|
|
@ -125,8 +134,7 @@ export function PopDashboardPreviewComponent({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: // 셀 미설정: 아이템만 나열
|
: visibleItems.slice(0, 4).map((item) => (
|
||||||
visibleItems.slice(0, 4).map((item) => (
|
|
||||||
<DummyItemPreview
|
<DummyItemPreview
|
||||||
key={item.id}
|
key={item.id}
|
||||||
subType={item.subType}
|
subType={item.subType}
|
||||||
|
|
@ -135,7 +143,7 @@ export function PopDashboardPreviewComponent({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 다른 모드: 첫 번째 아이템만 크게 표시
|
// 페이지 미설정: 첫 번째 아이템만 크게 표시
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
{visibleItems[0] && (
|
{visibleItems[0] && (
|
||||||
<DummyItemPreview
|
<DummyItemPreview
|
||||||
|
|
@ -143,7 +151,6 @@ export function PopDashboardPreviewComponent({
|
||||||
label={visibleItems[0].label}
|
label={visibleItems[0].label}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 추가 아이템 수 뱃지 */}
|
|
||||||
{visibleItems.length > 1 && (
|
{visibleItems.length > 1 && (
|
||||||
<div className="absolute bottom-1 right-1 rounded-full bg-primary/80 px-1.5 py-0.5 text-[8px] font-medium text-primary-foreground">
|
<div className="absolute bottom-1 right-1 rounded-full bg-primary/80 px-1.5 py-0.5 text-[8px] font-medium text-primary-foreground">
|
||||||
+{visibleItems.length - 1}
|
+{visibleItems.length - 1}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ PopComponentRegistry.registerComponent({
|
||||||
preview: PopDashboardPreviewComponent,
|
preview: PopDashboardPreviewComponent,
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
items: [],
|
items: [],
|
||||||
|
pages: [],
|
||||||
displayMode: "arrows",
|
displayMode: "arrows",
|
||||||
autoSlideInterval: 5,
|
autoSlideInterval: 5,
|
||||||
autoSlideResumeDelay: 3,
|
autoSlideResumeDelay: 3,
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,110 @@
|
||||||
*
|
*
|
||||||
* CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영)
|
* CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영)
|
||||||
* 각 셀에 @container 적용하여 내부 아이템 반응형
|
* 각 셀에 @container 적용하여 내부 아이템 반응형
|
||||||
|
*
|
||||||
|
* 반응형 자동 조정:
|
||||||
|
* - containerWidth에 따라 열 수를 자동 축소
|
||||||
|
* - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦
|
||||||
|
* - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import type { DashboardCell } from "../../types";
|
import type { DashboardCell } from "../../types";
|
||||||
|
|
||||||
|
// ===== 상수 =====
|
||||||
|
|
||||||
|
/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
|
||||||
|
const MIN_CELL_WIDTH = 160;
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
|
|
||||||
export interface GridModeProps {
|
export interface GridModeProps {
|
||||||
/** 셀 배치 정보 */
|
/** 셀 배치 정보 */
|
||||||
cells: DashboardCell[];
|
cells: DashboardCell[];
|
||||||
/** 열 수 */
|
/** 설정된 열 수 (최대값) */
|
||||||
columns: number;
|
columns: number;
|
||||||
/** 행 수 */
|
/** 설정된 행 수 */
|
||||||
rows: number;
|
rows: number;
|
||||||
/** 아이템 간 간격 (px) */
|
/** 아이템 간 간격 (px) */
|
||||||
gap?: number;
|
gap?: number;
|
||||||
|
/** 컨테이너 너비 (px, 반응형 자동 조정용) */
|
||||||
|
containerWidth?: number;
|
||||||
/** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */
|
/** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */
|
||||||
renderItem: (itemId: string) => React.ReactNode;
|
renderItem: (itemId: string) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 반응형 열 수 계산 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컨테이너 너비에 맞는 실제 열 수를 계산
|
||||||
|
*
|
||||||
|
* 설정된 columns가 최대값이고, 공간이 부족하면 축소.
|
||||||
|
* gap도 고려하여 계산.
|
||||||
|
*
|
||||||
|
* 예: columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160
|
||||||
|
* 사용 가능 너비 = 400 - (3-1)*8 = 384
|
||||||
|
* 셀당 너비 = 384/3 = 128 < 160 -> 열 축소
|
||||||
|
* columns=2: 사용 가능 = 400 - (2-1)*8 = 392, 셀당 = 196 >= 160 -> OK
|
||||||
|
*/
|
||||||
|
function computeResponsiveColumns(
|
||||||
|
configColumns: number,
|
||||||
|
containerWidth: number,
|
||||||
|
gap: number
|
||||||
|
): number {
|
||||||
|
if (containerWidth <= 0) return configColumns;
|
||||||
|
|
||||||
|
for (let cols = configColumns; cols >= 1; cols--) {
|
||||||
|
const totalGap = (cols - 1) * gap;
|
||||||
|
const cellWidth = (containerWidth - totalGap) / cols;
|
||||||
|
if (cellWidth >= MIN_CELL_WIDTH) return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 열 수가 줄어들 때 셀 배치를 자동 재배열
|
||||||
|
*
|
||||||
|
* 원본 gridColumn/gridRow를 actualColumns에 맞게 재매핑
|
||||||
|
* 셀이 원래 위치를 유지하려고 시도하되, 넘치면 아래로 이동
|
||||||
|
*/
|
||||||
|
function remapCells(
|
||||||
|
cells: DashboardCell[],
|
||||||
|
configColumns: number,
|
||||||
|
actualColumns: number,
|
||||||
|
configRows: number
|
||||||
|
): { remappedCells: DashboardCell[]; actualRows: number } {
|
||||||
|
// 열 수가 같으면 원본 그대로
|
||||||
|
if (actualColumns >= configColumns) {
|
||||||
|
return { remappedCells: cells, actualRows: configRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 셀을 원래 위치 순서대로 정렬 (행 우선)
|
||||||
|
const sorted = [...cells].sort((a, b) => {
|
||||||
|
const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0;
|
||||||
|
const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0;
|
||||||
|
if (aRow !== bRow) return aRow - bRow;
|
||||||
|
const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0;
|
||||||
|
const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0;
|
||||||
|
return aCol - bCol;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 순서대로 새 위치에 배치
|
||||||
|
let maxRow = 0;
|
||||||
|
const remapped = sorted.map((cell, index) => {
|
||||||
|
const newCol = (index % actualColumns) + 1;
|
||||||
|
const newRow = Math.floor(index / actualColumns) + 1;
|
||||||
|
maxRow = Math.max(maxRow, newRow);
|
||||||
|
return {
|
||||||
|
...cell,
|
||||||
|
gridColumn: `${newCol} / ${newCol + 1}`,
|
||||||
|
gridRow: `${newRow} / ${newRow + 1}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { remappedCells: remapped, actualRows: maxRow };
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
// ===== 메인 컴포넌트 =====
|
||||||
|
|
||||||
export function GridModeComponent({
|
export function GridModeComponent({
|
||||||
|
|
@ -32,9 +116,25 @@ export function GridModeComponent({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
gap = 8,
|
gap = 8,
|
||||||
|
containerWidth,
|
||||||
renderItem,
|
renderItem,
|
||||||
}: GridModeProps) {
|
}: GridModeProps) {
|
||||||
if (!cells.length) {
|
// 반응형 열 수 계산
|
||||||
|
const actualColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
containerWidth
|
||||||
|
? computeResponsiveColumns(columns, containerWidth, gap)
|
||||||
|
: columns,
|
||||||
|
[columns, containerWidth, gap]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 열 수가 줄었으면 셀 재배열
|
||||||
|
const { remappedCells, actualRows } = useMemo(
|
||||||
|
() => remapCells(cells, columns, actualColumns, rows),
|
||||||
|
[cells, columns, actualColumns, rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!remappedCells.length) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<span className="text-xs text-muted-foreground">셀 없음</span>
|
<span className="text-xs text-muted-foreground">셀 없음</span>
|
||||||
|
|
@ -47,12 +147,12 @@ export function GridModeComponent({
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
|
||||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
gridTemplateRows: `repeat(${actualRows}, 1fr)`,
|
||||||
gap: `${gap}px`,
|
gap: `${gap}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cells.map((cell) => (
|
{remappedCells.map((cell) => (
|
||||||
<div
|
<div
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card"
|
className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card"
|
||||||
|
|
@ -65,7 +165,9 @@ export function GridModeComponent({
|
||||||
renderItem(cell.itemId)
|
renderItem(cell.itemId)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<span className="text-[10px] text-muted-foreground/50">빈 셀</span>
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
|
빈 셀
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,14 @@
|
||||||
|
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import type { TableInfo } from "@/lib/api/tableManagement";
|
||||||
import type { DataSourceConfig, DataSourceFilter } from "../../types";
|
import type { DataSourceConfig, DataSourceFilter } from "../../types";
|
||||||
|
|
||||||
|
// ===== 타입 re-export =====
|
||||||
|
|
||||||
|
export type { TableInfo };
|
||||||
|
|
||||||
// ===== 반환 타입 =====
|
// ===== 반환 타입 =====
|
||||||
|
|
||||||
export interface AggregatedResult {
|
export interface AggregatedResult {
|
||||||
|
|
@ -233,3 +239,21 @@ export async function fetchTableColumns(
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 목록 조회 (설정 패널 Combobox용)
|
||||||
|
* tableManagementApi.getTableList() 래핑
|
||||||
|
*
|
||||||
|
* @INFRA-EXTRACT: useDataSource 완성 후 교체 예정
|
||||||
|
*/
|
||||||
|
export async function fetchTableList(): Promise<TableInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,6 @@ export interface PopActionConfig {
|
||||||
export type DashboardDisplayMode =
|
export type DashboardDisplayMode =
|
||||||
| "arrows"
|
| "arrows"
|
||||||
| "auto-slide"
|
| "auto-slide"
|
||||||
| "grid"
|
|
||||||
| "scroll";
|
| "scroll";
|
||||||
export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card";
|
export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card";
|
||||||
export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio";
|
export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio";
|
||||||
|
|
@ -280,6 +279,17 @@ export interface DashboardCell {
|
||||||
itemId: string | null; // null이면 빈 셀
|
itemId: string | null; // null이면 빈 셀
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- 대시보드 페이지(슬라이드) -----
|
||||||
|
|
||||||
|
/** 대시보드 한 페이지(슬라이드) - 독립적인 그리드 레이아웃 보유 */
|
||||||
|
export interface DashboardPage {
|
||||||
|
id: string;
|
||||||
|
label?: string; // 디자이너에서 표시할 라벨 (예: "페이지 1")
|
||||||
|
gridColumns: number; // 이 페이지의 열 수
|
||||||
|
gridRows: number; // 이 페이지의 행 수
|
||||||
|
gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정)
|
||||||
|
}
|
||||||
|
|
||||||
// ----- 대시보드 아이템 -----
|
// ----- 대시보드 아이템 -----
|
||||||
|
|
||||||
export interface DashboardItem {
|
export interface DashboardItem {
|
||||||
|
|
@ -306,17 +316,18 @@ export interface DashboardItem {
|
||||||
|
|
||||||
export interface PopDashboardConfig {
|
export interface PopDashboardConfig {
|
||||||
items: DashboardItem[];
|
items: DashboardItem[];
|
||||||
displayMode: DashboardDisplayMode;
|
pages?: DashboardPage[]; // 페이지 배열 (각 페이지가 독립 그리드 레이아웃)
|
||||||
|
displayMode: DashboardDisplayMode; // 페이지 간 전환 방식
|
||||||
|
|
||||||
// 모드별 설정
|
// 모드별 설정
|
||||||
autoSlideInterval?: number; // 초 (기본 5)
|
autoSlideInterval?: number; // 초 (기본 5)
|
||||||
autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3)
|
autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3)
|
||||||
gridCells?: DashboardCell[]; // grid 모드 셀 배치
|
|
||||||
gridColumns?: number; // grid 모드 열 수 (기본 2)
|
|
||||||
gridRows?: number; // grid 모드 행 수 (기본 2)
|
|
||||||
|
|
||||||
// 공통 스타일
|
// 공통 스타일
|
||||||
showIndicator?: boolean; // 페이지 인디케이터
|
showIndicator?: boolean; // 페이지 인디케이터
|
||||||
gap?: number; // 아이템 간 간격 px
|
gap?: number; // 아이템 간 간격 px
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
|
||||||
|
// 데이터 소스 (아이템 공통)
|
||||||
|
dataSource?: DataSourceConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue