diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx
index 35c3bb02..f646061c 100644
--- a/frontend/components/admin/dashboard/CanvasElement.tsx
+++ b/frontend/components/admin/dashboard/CanvasElement.tsx
@@ -39,6 +39,10 @@ const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/Ris
// 시계 위젯 임포트
import { ClockWidget } from "./widgets/ClockWidget";
+// 달력 위젯 임포트
+import { CalendarWidget } from "./widgets/CalendarWidget";
+// 기사 관리 위젯 임포트
+import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
interface CanvasElementProps {
element: DashboardElement;
@@ -137,9 +141,13 @@ export function CanvasElement({
const deltaY = e.clientY - dragStart.y;
// 임시 위치 계산 (스냅 안 됨)
- const rawX = Math.max(0, dragStart.elementX + deltaX);
+ let rawX = Math.max(0, dragStart.elementX + deltaX);
const rawY = Math.max(0, dragStart.elementY + deltaY);
+ // X 좌표가 캔버스 너비를 벗어나지 않도록 제한
+ const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
+ rawX = Math.min(rawX, maxX);
+
setTempPosition({ x: rawX, y: rawY });
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
@@ -150,46 +158,58 @@ export function CanvasElement({
let newX = resizeStart.elementX;
let newY = resizeStart.elementY;
- const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
+ // 최소 크기 설정: 달력은 2x3, 나머지는 2x2
+ const minWidthCells = 2;
+ const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2;
+ const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells;
+ const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells;
switch (resizeStart.handle) {
case "se": // 오른쪽 아래
- newWidth = Math.max(minSize, resizeStart.width + deltaX);
- newHeight = Math.max(minSize, resizeStart.height + deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width + deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height + deltaY);
break;
case "sw": // 왼쪽 아래
- newWidth = Math.max(minSize, resizeStart.width - deltaX);
- newHeight = Math.max(minSize, resizeStart.height + deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width - deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height + deltaY);
newX = resizeStart.elementX + deltaX;
break;
case "ne": // 오른쪽 위
- newWidth = Math.max(minSize, resizeStart.width + deltaX);
- newHeight = Math.max(minSize, resizeStart.height - deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width + deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height - deltaY);
newY = resizeStart.elementY + deltaY;
break;
case "nw": // 왼쪽 위
- newWidth = Math.max(minSize, resizeStart.width - deltaX);
- newHeight = Math.max(minSize, resizeStart.height - deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width - deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height - deltaY);
newX = resizeStart.elementX + deltaX;
newY = resizeStart.elementY + deltaY;
break;
}
+ // 가로 너비가 캔버스를 벗어나지 않도록 제한
+ const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX;
+ newWidth = Math.min(newWidth, maxWidth);
+
// 임시 크기/위치 저장 (스냅 안 됨)
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
setTempSize({ width: newWidth, height: newHeight });
}
},
- [isDragging, isResizing, dragStart, resizeStart],
+ [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype],
);
// 마우스 업 처리 (그리드 스냅 적용)
const handleMouseUp = useCallback(() => {
if (isDragging && tempPosition) {
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
- const snappedX = snapToGrid(tempPosition.x, cellSize);
+ let snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
+ // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
+ const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
+ snappedX = Math.min(snappedX, maxX);
+
onUpdate(element.id, {
position: { x: snappedX, y: snappedY },
});
@@ -201,9 +221,13 @@ export function CanvasElement({
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
const snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
- const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
+ let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
+ // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
+ const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX;
+ snappedWidth = Math.min(snappedWidth, maxWidth);
+
onUpdate(element.id, {
position: { x: snappedX, y: snappedY },
size: { width: snappedWidth, height: snappedHeight },
@@ -215,7 +239,7 @@ export function CanvasElement({
setIsDragging(false);
setIsResizing(false);
- }, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]);
+ }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]);
// 전역 마우스 이벤트 등록
React.useEffect(() => {
@@ -253,12 +277,11 @@ export function CanvasElement({
executionTime: 0,
});
} catch (error) {
- // console.error('❌ 데이터 로딩 오류:', error);
setChartData(null);
} finally {
setIsLoadingData(false);
}
- }, [element.dataSource?.query, element.type, element.subtype]);
+ }, [element.dataSource?.query, element.type]);
// 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩
useEffect(() => {
@@ -301,6 +324,10 @@ export function CanvasElement({
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
case "clock":
return "bg-gradient-to-br from-teal-400 to-cyan-600";
+ case "calendar":
+ return "bg-gradient-to-br from-indigo-400 to-purple-600";
+ case "driver-management":
+ return "bg-gradient-to-br from-blue-400 to-indigo-600";
default:
return "bg-gray-200";
}
@@ -330,16 +357,20 @@ export function CanvasElement({
{element.title}
- {/* 설정 버튼 (시계 위젯은 자체 설정 UI 사용) */}
- {onConfigure && !(element.type === "widget" && element.subtype === "clock") && (
-
- )}
+ {/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
+ {onConfigure &&
+ !(
+ element.type === "widget" &&
+ (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
+ ) && (
+
+ )}
{/* 삭제 버튼 */}
@@ -375,16 +406,12 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "weather" ? (
// 날씨 위젯 렌더링
-
+
) : element.type === "widget" && element.subtype === "exchange" ? (
// 환율 위젯 렌더링
-
+
) : element.type === "widget" && element.subtype === "clock" ? (
// 시계 위젯 렌더링
@@ -416,6 +443,26 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "calendar" ? (
+ // 달력 위젯 렌더링
+
+ {
+ onUpdate(element.id, { calendarConfig: newConfig });
+ }}
+ />
+
+ ) : element.type === "widget" && element.subtype === "driver-management" ? (
+ // 기사 관리 위젯 렌더링
+
+ {
+ onUpdate(element.id, { driverManagementConfig: newConfig });
+ }}
+ />
+
) : (
// 기타 위젯 렌더링
);
}
-
-/**
- * 샘플 데이터 생성 함수 (실제 API 호출 대신 사용)
- */
-function generateSampleData(query: string, chartType: string): QueryResult {
- // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
- const isMonthly = query.toLowerCase().includes("month");
- const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출");
- const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자");
- const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품");
-
- let columns: string[];
- let rows: Record
[];
-
- if (isMonthly && isSales) {
- // 월별 매출 데이터
- columns = ["month", "sales", "order_count"];
- rows = [
- { month: "2024-01", sales: 1200000, order_count: 45 },
- { month: "2024-02", sales: 1350000, order_count: 52 },
- { month: "2024-03", sales: 1180000, order_count: 41 },
- { month: "2024-04", sales: 1420000, order_count: 58 },
- { month: "2024-05", sales: 1680000, order_count: 67 },
- { month: "2024-06", sales: 1540000, order_count: 61 },
- ];
- } else if (isUsers) {
- // 사용자 가입 추이
- columns = ["week", "new_users"];
- rows = [
- { week: "2024-W10", new_users: 23 },
- { week: "2024-W11", new_users: 31 },
- { week: "2024-W12", new_users: 28 },
- { week: "2024-W13", new_users: 35 },
- { week: "2024-W14", new_users: 42 },
- { week: "2024-W15", new_users: 38 },
- ];
- } else if (isProducts) {
- // 상품별 판매량
- columns = ["product_name", "total_sold", "revenue"];
- rows = [
- { product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
- { product_name: "노트북", total_sold: 89, revenue: 178000000 },
- { product_name: "태블릿", total_sold: 134, revenue: 67000000 },
- { product_name: "이어폰", total_sold: 267, revenue: 26700000 },
- { product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
- ];
- } else {
- // 기본 샘플 데이터
- columns = ["category", "value", "count"];
- rows = [
- { category: "A", value: 100, count: 10 },
- { category: "B", value: 150, count: 15 },
- { category: "C", value: 120, count: 12 },
- { category: "D", value: 180, count: 18 },
- { category: "E", value: 90, count: 9 },
- ];
- }
-
- return {
- columns,
- rows,
- totalRows: rows.length,
- executionTime: Math.floor(Math.random() * 100) + 50, // 50-150ms
- };
-}
diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx
index 8d331307..e0e2b81d 100644
--- a/frontend/components/admin/dashboard/DashboardSidebar.tsx
+++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx
@@ -133,15 +133,31 @@ export function DashboardSidebar() {
type="widget"
subtype="delivery-status"
onDragStart={handleDragStart}
- className="border-l-4 border-blue-600"
+ className="border-l-4 border-amber-500"
/>
+
+
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts
index 50909504..eb936652 100644
--- a/frontend/components/admin/dashboard/types.ts
+++ b/frontend/components/admin/dashboard/types.ts
@@ -15,10 +15,12 @@ export type ElementSubtype =
| "exchange"
| "weather"
| "clock"
+ | "calendar"
| "calculator"
| "vehicle-map"
| "delivery-status"
- | "risk-alert"; // 위젯 타입
+ | "risk-alert"
+ | "driver-management"; // 위젯 타입
export interface Position {
x: number;
@@ -41,6 +43,8 @@ export interface DashboardElement {
dataSource?: ChartDataSource; // 데이터 소스 설정
chartConfig?: ChartConfig; // 차트 설정
clockConfig?: ClockConfig; // 시계 설정
+ calendarConfig?: CalendarConfig; // 달력 설정
+ driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
}
export interface DragData {
@@ -90,3 +94,42 @@ export interface ClockConfig {
theme: "light" | "dark" | "custom"; // 테마
customColor?: string; // 사용자 지정 색상 (custom 테마일 때)
}
+
+// 달력 위젯 설정
+export interface CalendarConfig {
+ view: "month" | "week" | "day"; // 뷰 타입
+ startWeekOn: "monday" | "sunday"; // 주 시작 요일
+ highlightWeekends: boolean; // 주말 강조
+ highlightToday: boolean; // 오늘 강조
+ showHolidays: boolean; // 공휴일 표시
+ theme: "light" | "dark" | "custom"; // 테마
+ customColor?: string; // 사용자 지정 색상
+ showWeekNumbers?: boolean; // 주차 표시 (선택)
+}
+
+// 기사 관리 위젯 설정
+export interface DriverManagementConfig {
+ viewType: "list"; // 뷰 타입 (현재는 리스트만)
+ autoRefreshInterval: number; // 자동 새로고침 간격 (초)
+ visibleColumns: string[]; // 표시할 컬럼 목록
+ theme: "light" | "dark" | "custom"; // 테마
+ customColor?: string; // 사용자 지정 색상
+ statusFilter: "all" | "driving" | "standby" | "resting" | "maintenance"; // 상태 필터
+ sortBy: "name" | "vehicleNumber" | "status" | "departureTime"; // 정렬 기준
+ sortOrder: "asc" | "desc"; // 정렬 순서
+}
+
+// 기사 정보
+export interface DriverInfo {
+ id: string; // 기사 고유 ID
+ name: string; // 기사 이름
+ vehicleNumber: string; // 차량 번호
+ vehicleType: string; // 차량 유형
+ phone: string; // 연락처
+ status: "standby" | "driving" | "resting" | "maintenance"; // 운행 상태
+ departure?: string; // 출발지
+ destination?: string; // 목적지
+ departureTime?: string; // 출발 시간
+ estimatedArrival?: string; // 예상 도착 시간
+ progress?: number; // 운행 진행률 (0-100)
+}