@@ -247,12 +249,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
{/* 단계별 내용 */}
-
- {currentStep === 1 && (
-
- )}
+ {!isHeaderOnlyWidget && (
+
+ {currentStep === 1 && (
+
+ )}
- {currentStep === 2 && (
+ {currentStep === 2 && (
{/* 왼쪽: 데이터 설정 */}
@@ -308,15 +311,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
- )}
-
+ )}
+
{queryResult && {queryResult.rows.length}개 데이터 로드됨 }
- {!isSimpleWidget && currentStep > 1 && (
+ {!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && (
이전
@@ -325,14 +329,20 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
취소
- {currentStep === 1 ? (
- // 1단계: 다음 버튼 (모든 타입 공통)
+ {isHeaderOnlyWidget ? (
+ // 헤더 전용 위젯: 바로 저장
+
+
+ 저장
+
+ ) : currentStep === 1 ? (
+ // 1단계: 다음 버튼
다음
) : (
- // 2단계: 저장 버튼 (모든 타입 공통)
+ // 2단계: 저장 버튼
저장
diff --git a/frontend/components/admin/dashboard/MenuAssignmentModal.tsx b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx
index 9220a0c8..cd4ac614 100644
--- a/frontend/components/admin/dashboard/MenuAssignmentModal.tsx
+++ b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx
@@ -61,7 +61,7 @@ export const MenuAssignmentModal: React.FC = ({
setUserMenus(userResponse.data || []);
}
} catch (error) {
- console.error("메뉴 목록 로드 실패:", error);
+ // console.error("메뉴 목록 로드 실패:", error);
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx
index 181d80fa..81bee6ea 100644
--- a/frontend/components/admin/dashboard/QueryEditor.tsx
+++ b/frontend/components/admin/dashboard/QueryEditor.tsx
@@ -35,9 +35,9 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 쿼리 실행
const executeQuery = useCallback(async () => {
- console.log("🚀 executeQuery 호출됨!");
- console.log("📝 현재 쿼리:", query);
- console.log("✅ query.trim():", query.trim());
+ // console.log("🚀 executeQuery 호출됨!");
+ // console.log("📝 현재 쿼리:", query);
+ // console.log("✅ query.trim():", query.trim());
if (!query.trim()) {
setError("쿼리를 입력해주세요.");
@@ -47,13 +47,13 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 외부 DB인 경우 커넥션 ID 확인
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
setError("외부 DB 커넥션을 선택해주세요.");
- console.log("❌ 쿼리가 비어있음!");
+ // console.log("❌ 쿼리가 비어있음!");
return;
}
setIsExecuting(true);
setError(null);
- console.log("🔄 쿼리 실행 시작...");
+ // console.log("🔄 쿼리 실행 시작...");
try {
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
@@ -247,7 +247,7 @@ ORDER BY Q4 DESC;`,
-
+
수동
10초
30초
diff --git a/frontend/components/admin/dashboard/collisionUtils.ts b/frontend/components/admin/dashboard/collisionUtils.ts
new file mode 100644
index 00000000..ff597eda
--- /dev/null
+++ b/frontend/components/admin/dashboard/collisionUtils.ts
@@ -0,0 +1,162 @@
+/**
+ * 대시보드 위젯 충돌 감지 및 자동 재배치 유틸리티
+ */
+
+import { DashboardElement } from "./types";
+
+export interface Rectangle {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * 두 사각형이 겹치는지 확인 (여유있는 충돌 감지)
+ * @param rect1 첫 번째 사각형
+ * @param rect2 두 번째 사각형
+ * @param cellSize 한 그리드 칸의 크기 (기본: 130px)
+ */
+export function isColliding(rect1: Rectangle, rect2: Rectangle, cellSize: number = 130): boolean {
+ // 겹친 영역 계산
+ const overlapX = Math.max(
+ 0,
+ Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - Math.max(rect1.x, rect2.x)
+ );
+ const overlapY = Math.max(
+ 0,
+ Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - Math.max(rect1.y, rect2.y)
+ );
+
+ // 큰 그리드의 절반(cellSize/2 ≈ 65px) 이상 겹쳐야 충돌로 간주
+ const collisionThreshold = Math.floor(cellSize / 2);
+ return overlapX >= collisionThreshold && overlapY >= collisionThreshold;
+}
+
+/**
+ * 특정 위젯과 충돌하는 다른 위젯들을 찾기
+ */
+export function findCollisions(
+ element: DashboardElement,
+ allElements: DashboardElement[],
+ cellSize: number = 130,
+ excludeId?: string
+): DashboardElement[] {
+ const elementRect: Rectangle = {
+ x: element.position.x,
+ y: element.position.y,
+ width: element.size.width,
+ height: element.size.height,
+ };
+
+ return allElements.filter((other) => {
+ if (other.id === element.id || other.id === excludeId) {
+ return false;
+ }
+
+ const otherRect: Rectangle = {
+ x: other.position.x,
+ y: other.position.y,
+ width: other.size.width,
+ height: other.size.height,
+ };
+
+ return isColliding(elementRect, otherRect, cellSize);
+ });
+}
+
+/**
+ * 충돌을 해결하기 위해 위젯을 아래로 이동
+ */
+export function resolveCollisionVertically(
+ movingElement: DashboardElement,
+ collidingElement: DashboardElement,
+ gridSize: number = 10
+): { x: number; y: number } {
+ // 충돌하는 위젯 아래로 이동
+ const newY = collidingElement.position.y + collidingElement.size.height + gridSize;
+
+ return {
+ x: collidingElement.position.x,
+ y: Math.round(newY / gridSize) * gridSize, // 그리드에 스냅
+ };
+}
+
+/**
+ * 여러 위젯의 충돌을 재귀적으로 해결
+ */
+export function resolveAllCollisions(
+ elements: DashboardElement[],
+ movedElementId: string,
+ subGridSize: number = 10,
+ canvasWidth: number = 1560,
+ cellSize: number = 130,
+ maxIterations: number = 50
+): DashboardElement[] {
+ let result = [...elements];
+ let iterations = 0;
+
+ // 이동한 위젯부터 시작
+ const movedIndex = result.findIndex((el) => el.id === movedElementId);
+ if (movedIndex === -1) return result;
+
+ // Y 좌표로 정렬 (위에서 아래로 처리)
+ const sortedIndices = result
+ .map((el, idx) => ({ el, idx }))
+ .sort((a, b) => a.el.position.y - b.el.position.y)
+ .map((item) => item.idx);
+
+ while (iterations < maxIterations) {
+ let hasCollision = false;
+
+ for (const idx of sortedIndices) {
+ const element = result[idx];
+ const collisions = findCollisions(element, result, cellSize);
+
+ if (collisions.length > 0) {
+ hasCollision = true;
+
+ // 첫 번째 충돌만 처리 (가장 위에 있는 것)
+ const collision = collisions.sort((a, b) => a.position.y - b.position.y)[0];
+
+ // 충돌하는 위젯을 아래로 이동
+ const collisionIdx = result.findIndex((el) => el.id === collision.id);
+ if (collisionIdx !== -1) {
+ const newY = element.position.y + element.size.height + subGridSize;
+
+ result[collisionIdx] = {
+ ...result[collisionIdx],
+ position: {
+ ...result[collisionIdx].position,
+ y: Math.round(newY / subGridSize) * subGridSize,
+ },
+ };
+ }
+ }
+ }
+
+ if (!hasCollision) break;
+ iterations++;
+ }
+
+ return result;
+}
+
+/**
+ * 위젯이 캔버스 경계를 벗어나지 않도록 제한
+ */
+export function constrainToCanvas(
+ element: DashboardElement,
+ canvasWidth: number,
+ canvasHeight: number,
+ gridSize: number = 10
+): { x: number; y: number } {
+ const maxX = canvasWidth - element.size.width;
+ const maxY = canvasHeight - element.size.height;
+
+ return {
+ x: Math.max(0, Math.min(Math.round(element.position.x / gridSize) * gridSize, maxX)),
+ y: Math.max(0, Math.min(Math.round(element.position.y / gridSize) * gridSize, maxY)),
+ };
+}
+
diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts
index 54149222..3864e861 100644
--- a/frontend/components/admin/dashboard/gridUtils.ts
+++ b/frontend/components/admin/dashboard/gridUtils.ts
@@ -8,9 +8,10 @@
// 기본 그리드 설정 (FHD 기준)
export const GRID_CONFIG = {
COLUMNS: 12, // 모든 해상도에서 12칸 고정
- GAP: 8, // 셀 간격 고정
- SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
+ GAP: 5, // 셀 간격 고정
+ SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
+ SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
} as const;
@@ -23,14 +24,23 @@ export function calculateCellSize(canvasWidth: number): number {
return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
}
+/**
+ * 서브 그리드 크기 계산 (세밀한 조정용)
+ */
+export function calculateSubGridSize(cellSize: number): number {
+ return Math.floor(cellSize / GRID_CONFIG.SUB_GRID_DIVISIONS);
+}
+
/**
* 해상도별 그리드 설정 계산
*/
export function calculateGridConfig(canvasWidth: number) {
const cellSize = calculateCellSize(canvasWidth);
+ const subGridSize = calculateSubGridSize(cellSize);
return {
...GRID_CONFIG,
CELL_SIZE: cellSize,
+ SUB_GRID_SIZE: subGridSize,
CANVAS_WIDTH: canvasWidth,
};
}
@@ -51,15 +61,18 @@ export const getCanvasWidth = () => {
};
/**
- * 좌표를 가장 가까운 그리드 포인트로 스냅 (여백 포함)
+ * 좌표를 서브 그리드에 스냅 (세밀한 조정 가능)
* @param value - 스냅할 좌표값
- * @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE)
- * @returns 스냅된 좌표값 (여백 포함)
+ * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px)
+ * @returns 스냅된 좌표값
*/
-export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
- const cellWithGap = cellSize + GRID_CONFIG.GAP;
- const gridIndex = Math.round(value / cellWithGap);
- return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING;
+export const snapToGrid = (value: number, subGridSize?: number): number => {
+ // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드)
+ const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3);
+
+ // 서브 그리드 단위로 스냅
+ const gridIndex = Math.round(value / snapSize);
+ return gridIndex * snapSize;
};
/**
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts
index 73a4bf89..08308cc4 100644
--- a/frontend/components/admin/dashboard/types.ts
+++ b/frontend/components/admin/dashboard/types.ts
@@ -36,7 +36,7 @@ export type ElementSubtype =
| "maintenance"
| "document"
| "list"
- | "warehouse-3d"; // 위젯 타입
+ | "yard-management-3d"; // 야드 관리 3D 위젯
export interface Position {
x: number;
@@ -64,6 +64,7 @@ export interface DashboardElement {
calendarConfig?: CalendarConfig; // 달력 설정
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
+ yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
}
export interface DragData {
@@ -272,3 +273,9 @@ export interface ListColumn {
align?: "left" | "center" | "right"; // 정렬
visible?: boolean; // 표시 여부 (기본: true)
}
+
+// 야드 관리 3D 설정
+export interface YardManagementConfig {
+ layoutId: number; // 선택된 야드 레이아웃 ID
+ layoutName?: string; // 레이아웃 이름 (표시용)
+}
diff --git a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx
index 4f54ac65..b5638f94 100644
--- a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx
+++ b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx
@@ -8,6 +8,7 @@ import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUti
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react";
+import { useDashboard } from "@/contexts/DashboardContext";
interface CalendarWidgetProps {
element: DashboardElement;
@@ -21,11 +22,19 @@ interface CalendarWidgetProps {
* - 내장 설정 UI
*/
export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) {
+ // Context에서 선택된 날짜 관리
+ const { selectedDate, setSelectedDate } = useDashboard();
+
// 현재 표시 중인 년/월
const today = new Date();
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [settingsOpen, setSettingsOpen] = useState(false);
+
+ // 날짜 클릭 핸들러
+ const handleDateClick = (date: Date) => {
+ setSelectedDate(date);
+ };
// 기본 설정값
const config = element.calendarConfig || {
@@ -98,7 +107,15 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps)
{/* 달력 콘텐츠 */}
- {config.view === "month" && }
+ {config.view === "month" && (
+
+ )}
{/* 추후 WeekView, DayView 추가 가능 */}
diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
index ec432299..ab721c4b 100644
--- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx
+++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
@@ -219,8 +219,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
return (
+ {/* 제목 - 항상 표시 */}
-
{element.title}
+ {element.customTitle || element.title}
{/* 테이블 뷰 */}
diff --git a/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx
index eb2a0e8a..e182f433 100644
--- a/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx
+++ b/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx
@@ -131,7 +131,7 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
// 저장
const handleSave = () => {
onSave({
- title,
+ customTitle: title,
dataSource,
listConfig,
});
@@ -166,10 +166,19 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
id="list-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
+ onKeyDown={(e) => {
+ // 모든 키보드 이벤트를 input 필드 내부에서만 처리
+ e.stopPropagation();
+ }}
placeholder="예: 사용자 목록"
className="mt-1"
/>
+
+ {/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
+
+ 💡 리스트 위젯은 제목이 항상 표시됩니다
+
{/* 진행 상태 표시 */}
diff --git a/frontend/components/admin/dashboard/widgets/MonthView.tsx b/frontend/components/admin/dashboard/widgets/MonthView.tsx
index c0fd3871..67c1596c 100644
--- a/frontend/components/admin/dashboard/widgets/MonthView.tsx
+++ b/frontend/components/admin/dashboard/widgets/MonthView.tsx
@@ -7,12 +7,14 @@ interface MonthViewProps {
days: CalendarDay[];
config: CalendarConfig;
isCompact?: boolean; // 작은 크기 (2x2, 3x3)
+ selectedDate?: Date | null; // 선택된 날짜
+ onDateClick?: (date: Date) => void; // 날짜 클릭 핸들러
}
/**
* 월간 달력 뷰 컴포넌트
*/
-export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
+export function MonthView({ days, config, isCompact = false, selectedDate, onDateClick }: MonthViewProps) {
const weekDayNames = getWeekDayNames(config.startWeekOn);
// 테마별 스타일
@@ -43,10 +45,27 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
const themeStyles = getThemeStyles();
+ // 날짜가 선택된 날짜인지 확인
+ const isSelected = (day: CalendarDay) => {
+ if (!selectedDate || !day.isCurrentMonth) return false;
+ return (
+ selectedDate.getFullYear() === day.date.getFullYear() &&
+ selectedDate.getMonth() === day.date.getMonth() &&
+ selectedDate.getDate() === day.date.getDate()
+ );
+ };
+
+ // 날짜 클릭 핸들러
+ const handleDayClick = (day: CalendarDay) => {
+ if (!day.isCurrentMonth || !onDateClick) return;
+ onDateClick(day.date);
+ };
+
// 날짜 셀 스타일 클래스
const getDayCellClass = (day: CalendarDay) => {
const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors";
const sizeClass = isCompact ? "text-xs" : "text-sm";
+ const cursorClass = day.isCurrentMonth ? "cursor-pointer" : "cursor-default";
let colorClass = "text-gray-700";
@@ -54,6 +73,10 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
if (!day.isCurrentMonth) {
colorClass = "text-gray-300";
}
+ // 선택된 날짜
+ else if (isSelected(day)) {
+ colorClass = "text-white font-bold";
+ }
// 오늘
else if (config.highlightToday && day.isToday) {
colorClass = "text-white font-bold";
@@ -67,9 +90,16 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
colorClass = "text-red-600";
}
- const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100";
+ let bgClass = "";
+ if (isSelected(day)) {
+ bgClass = ""; // 선택된 날짜는 배경색이 style로 적용됨
+ } else if (config.highlightToday && day.isToday) {
+ bgClass = "";
+ } else {
+ bgClass = "hover:bg-gray-100";
+ }
- return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`;
+ return `${baseClass} ${sizeClass} ${colorClass} ${bgClass} ${cursorClass}`;
};
return (
@@ -97,9 +127,13 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
handleDayClick(day)}
style={{
- backgroundColor:
- config.highlightToday && day.isToday ? themeStyles.todayBg : undefined,
+ backgroundColor: isSelected(day)
+ ? "#10b981" // 선택된 날짜는 초록색
+ : config.highlightToday && day.isToday
+ ? themeStyles.todayBg
+ : undefined,
color:
config.showHolidays && day.isHoliday && day.isCurrentMonth
? themeStyles.holidayText
diff --git a/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx
new file mode 100644
index 00000000..398c4d17
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx
@@ -0,0 +1,336 @@
+"use client";
+
+import React, { useState, useCallback, useEffect } from "react";
+import { DashboardElement, ChartDataSource, QueryResult } from "../types";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
+import { DataSourceSelector } from "../data-sources/DataSourceSelector";
+import { DatabaseConfig } from "../data-sources/DatabaseConfig";
+import { ApiConfig } from "../data-sources/ApiConfig";
+import { QueryEditor } from "../QueryEditor";
+
+interface TodoWidgetConfigModalProps {
+ isOpen: boolean;
+ element: DashboardElement;
+ onClose: () => void;
+ onSave: (updates: Partial
) => void;
+}
+
+/**
+ * To-Do 위젯 설정 모달
+ * - 2단계 설정: 데이터 소스 → 쿼리 입력/테스트
+ */
+export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) {
+ const [currentStep, setCurrentStep] = useState<1 | 2>(1);
+ const [title, setTitle] = useState(element.title || "✅ To-Do / 긴급 지시");
+ const [dataSource, setDataSource] = useState(
+ element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
+ );
+ const [queryResult, setQueryResult] = useState(null);
+
+ // 모달 열릴 때 element에서 설정 로드
+ useEffect(() => {
+ if (isOpen) {
+ setTitle(element.title || "✅ To-Do / 긴급 지시");
+ if (element.dataSource) {
+ setDataSource(element.dataSource);
+ }
+ setCurrentStep(1);
+ }
+ }, [isOpen, element.id]);
+
+ // 데이터 소스 타입 변경
+ const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
+ if (type === "database") {
+ setDataSource((prev) => ({
+ ...prev,
+ type: "database",
+ connectionType: "current",
+ }));
+ } else {
+ setDataSource((prev) => ({
+ ...prev,
+ type: "api",
+ method: "GET",
+ }));
+ }
+ setQueryResult(null);
+ }, []);
+
+ // 데이터 소스 업데이트
+ const handleDataSourceUpdate = useCallback((updates: Partial) => {
+ setDataSource((prev) => ({ ...prev, ...updates }));
+ }, []);
+
+ // 쿼리 실행 결과 처리
+ const handleQueryTest = useCallback(
+ (result: QueryResult) => {
+ // console.log("🎯 TodoWidget - handleQueryTest 호출됨!");
+ // console.log("📊 쿼리 결과:", result);
+ // console.log("📝 rows 개수:", result.rows?.length);
+ // console.log("❌ error:", result.error);
+ setQueryResult(result);
+ // console.log("✅ setQueryResult 호출 완료!");
+
+ // 강제 리렌더링 확인
+ // setTimeout(() => {
+ // console.log("🔄 1초 후 queryResult 상태:", result);
+ // }, 1000);
+ },
+ [],
+ );
+
+ // 저장
+ const handleSave = useCallback(() => {
+ if (!dataSource.query || !queryResult || queryResult.error) {
+ alert("쿼리를 입력하고 테스트를 먼저 실행해주세요.");
+ return;
+ }
+
+ if (!queryResult.rows || queryResult.rows.length === 0) {
+ alert("쿼리 결과가 없습니다. 데이터가 반환되는 쿼리를 입력해주세요.");
+ return;
+ }
+
+ onSave({
+ title,
+ dataSource,
+ });
+
+ onClose();
+ }, [title, dataSource, queryResult, onSave, onClose]);
+
+ // 다음 단계로
+ const handleNext = useCallback(() => {
+ if (currentStep === 1) {
+ if (dataSource.type === "database") {
+ if (!dataSource.connectionId && dataSource.connectionType === "external") {
+ alert("외부 데이터베이스를 선택해주세요.");
+ return;
+ }
+ } else if (dataSource.type === "api") {
+ if (!dataSource.url) {
+ alert("API URL을 입력해주세요.");
+ return;
+ }
+ }
+ setCurrentStep(2);
+ }
+ }, [currentStep, dataSource]);
+
+ // 이전 단계로
+ const handlePrev = useCallback(() => {
+ if (currentStep === 2) {
+ setCurrentStep(1);
+ }
+ }, [currentStep]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+
To-Do 위젯 설정
+
+ 데이터 소스와 쿼리를 설정하면 자동으로 To-Do 목록이 표시됩니다
+
+
+
+
+
+
+
+ {/* 진행 상태 */}
+
+
+ {/* 본문 */}
+
+ {/* Step 1: 데이터 소스 선택 */}
+ {currentStep === 1 && (
+
+
+ 제목
+ setTitle(e.target.value)}
+ placeholder="예: ✅ 오늘의 할 일"
+ className="mt-2"
+ />
+
+
+
+ 데이터 소스 타입
+
+
+
+ {dataSource.type === "database" && (
+
+ )}
+
+ {dataSource.type === "api" &&
}
+
+ )}
+
+ {/* Step 2: 쿼리 입력 및 테스트 */}
+ {currentStep === 2 && (
+
+
+
+
💡 컬럼명 가이드
+
+ 쿼리 결과에 다음 컬럼명이 있으면 자동으로 To-Do 항목으로 변환됩니다:
+
+
+
+ id - 고유 ID (없으면 자동 생성)
+
+
+ title,{" "}
+ task,{" "}
+ name - 제목 (필수)
+
+
+ description,{" "}
+ desc,{" "}
+ content - 상세 설명
+
+
+ priority - 우선순위 (urgent, high,
+ normal, low)
+
+
+ status - 상태 (pending, in_progress,
+ completed)
+
+
+ assigned_to,{" "}
+ assignedTo,{" "}
+ user - 담당자
+
+
+ due_date,{" "}
+ dueDate,{" "}
+ deadline - 마감일
+
+
+ is_urgent,{" "}
+ isUrgent,{" "}
+ urgent - 긴급 여부
+
+
+
+
+
+
+
+ {/* 디버그: 항상 표시되는 테스트 메시지 */}
+
+
+ 🔍 디버그: queryResult 상태 = {queryResult ? "있음" : "없음"}
+
+ {queryResult && (
+
+ rows: {queryResult.rows?.length}개, error: {queryResult.error || "없음"}
+
+ )}
+
+
+ {queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && (
+
+
✅ 쿼리 테스트 성공!
+
+ 총 {queryResult.rows.length}개 의 To-Do 항목을 찾았습니다.
+
+
+
첫 번째 데이터 미리보기:
+
+ {JSON.stringify(queryResult.rows[0], null, 2)}
+
+
+
+ )}
+
+ )}
+
+
+ {/* 하단 버튼 */}
+
+
+ {currentStep > 1 && (
+
+
+ 이전
+
+ )}
+
+
+
+
+ 취소
+
+
+ {currentStep < 2 ? (
+
+ 다음
+
+
+ ) : (
+ {
+ const isDisabled = !queryResult || queryResult.error || !queryResult.rows || queryResult.rows.length === 0;
+ // console.log("💾 저장 버튼 disabled:", isDisabled);
+ // console.log("💾 queryResult:", queryResult);
+ return isDisabled;
+ })()}
+ >
+
+ 저장
+
+ )}
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx
deleted file mode 100644
index 71d2df0e..00000000
--- a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx
+++ /dev/null
@@ -1,418 +0,0 @@
-"use client";
-
-import React, { useRef, useState, useEffect, Suspense } from "react";
-import { Canvas, useFrame } from "@react-three/fiber";
-import { OrbitControls, Text, Box, Html } from "@react-three/drei";
-import * as THREE from "three";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Loader2, Maximize2, Info } from "lucide-react";
-
-interface WarehouseData {
- id: string;
- name: string;
- position_x: number;
- position_y: number;
- position_z: number;
- size_x: number;
- size_y: number;
- size_z: number;
- color: string;
- capacity: number;
- current_usage: number;
- status: string;
- description?: string;
-}
-
-interface MaterialData {
- id: string;
- warehouse_id: string;
- name: string;
- material_code: string;
- quantity: number;
- unit: string;
- position_x: number;
- position_y: number;
- position_z: number;
- size_x: number;
- size_y: number;
- size_z: number;
- color: string;
- status: string;
-}
-
-interface Warehouse3DWidgetProps {
- element?: any;
-}
-
-// 창고 3D 박스 컴포넌트
-function WarehouseBox({
- warehouse,
- onClick,
- isSelected,
-}: {
- warehouse: WarehouseData;
- onClick: () => void;
- isSelected: boolean;
-}) {
- const meshRef = useRef(null);
- const [hovered, setHovered] = useState(false);
-
- useFrame(() => {
- if (meshRef.current) {
- if (isSelected) {
- meshRef.current.scale.lerp(new THREE.Vector3(1.05, 1.05, 1.05), 0.1);
- } else if (hovered) {
- meshRef.current.scale.lerp(new THREE.Vector3(1.02, 1.02, 1.02), 0.1);
- } else {
- meshRef.current.scale.lerp(new THREE.Vector3(1, 1, 1), 0.1);
- }
- }
- });
-
- const usagePercentage = (warehouse.current_usage / warehouse.capacity) * 100;
-
- return (
-
- {
- e.stopPropagation();
- onClick();
- }}
- onPointerOver={() => setHovered(true)}
- onPointerOut={() => setHovered(false)}
- >
-
-
-
-
- {/* 창고 테두리 */}
-
-
-
-
-
- {/* 창고 이름 라벨 */}
-
- {warehouse.name}
-
-
- {/* 사용률 표시 */}
-
-
- {usagePercentage.toFixed(0)}% 사용중
-
-
-
- );
-}
-
-// 자재 3D 박스 컴포넌트
-function MaterialBox({
- material,
- onClick,
- isSelected,
-}: {
- material: MaterialData;
- onClick: () => void;
- isSelected: boolean;
-}) {
- const meshRef = useRef(null);
- const [hovered, setHovered] = useState(false);
-
- useFrame(() => {
- if (meshRef.current && (isSelected || hovered)) {
- meshRef.current.rotation.y += 0.01;
- }
- });
-
- const statusColor =
- {
- stocked: material.color,
- reserved: "#FFA500",
- urgent: "#FF0000",
- out_of_stock: "#808080",
- }[material.status] || material.color;
-
- return (
-
- {
- e.stopPropagation();
- onClick();
- }}
- onPointerOver={() => setHovered(true)}
- onPointerOut={() => setHovered(false)}
- >
-
-
-
-
- {(hovered || isSelected) && (
-
-
-
{material.name}
-
- {material.quantity} {material.unit}
-
-
-
- )}
-
- );
-}
-
-// 3D 씬 컴포넌트
-function Scene({
- warehouses,
- materials,
- onSelectWarehouse,
- onSelectMaterial,
- selectedWarehouse,
- selectedMaterial,
-}: {
- warehouses: WarehouseData[];
- materials: MaterialData[];
- onSelectWarehouse: (warehouse: WarehouseData | null) => void;
- onSelectMaterial: (material: MaterialData | null) => void;
- selectedWarehouse: WarehouseData | null;
- selectedMaterial: MaterialData | null;
-}) {
- return (
- <>
- {/* 조명 */}
-
-
-
-
- {/* 바닥 그리드 */}
-
-
- {/* 창고들 */}
- {warehouses.map((warehouse) => (
- {
- if (selectedWarehouse?.id === warehouse.id) {
- onSelectWarehouse(null);
- } else {
- onSelectWarehouse(warehouse);
- onSelectMaterial(null);
- }
- }}
- isSelected={selectedWarehouse?.id === warehouse.id}
- />
- ))}
-
- {/* 자재들 */}
- {materials.map((material) => (
- {
- if (selectedMaterial?.id === material.id) {
- onSelectMaterial(null);
- } else {
- onSelectMaterial(material);
- }
- }}
- isSelected={selectedMaterial?.id === material.id}
- />
- ))}
-
- {/* 카메라 컨트롤 */}
-
- >
- );
-}
-
-export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) {
- const [warehouses, setWarehouses] = useState([]);
- const [materials, setMaterials] = useState([]);
- const [loading, setLoading] = useState(true);
- const [selectedWarehouse, setSelectedWarehouse] = useState(null);
- const [selectedMaterial, setSelectedMaterial] = useState(null);
- const [isFullscreen, setIsFullscreen] = useState(false);
-
- useEffect(() => {
- loadData();
- }, []);
-
- const loadData = async () => {
- try {
- setLoading(true);
- // API 호출 (백엔드 API 구현 필요)
- const response = await fetch("/api/warehouse/data");
- if (response.ok) {
- const data = await response.json();
- setWarehouses(data.warehouses || []);
- setMaterials(data.materials || []);
- } else {
- // 임시 더미 데이터 (개발용)
- console.log("API 실패, 더미 데이터 사용");
- }
- } catch (error) {
- console.error("창고 데이터 로드 실패:", error);
- } finally {
- setLoading(false);
- }
- };
-
- if (loading) {
- return (
-
-
-
-
-
- );
- }
-
- return (
-
-
- 🏭 창고 현황 (3D)
-
-
- {warehouses.length}개 창고 | {materials.length}개 자재
-
- setIsFullscreen(!isFullscreen)} className="text-gray-500 hover:text-gray-700">
-
-
-
-
-
- {/* 3D 뷰 */}
-
-
-
-
-
-
-
-
- {/* 정보 패널 */}
-
- {/* 선택된 창고 정보 */}
- {selectedWarehouse && (
-
-
-
-
- 창고 정보
-
-
-
-
- 이름: {selectedWarehouse.name}
-
-
- ID: {selectedWarehouse.id}
-
-
- 용량: {selectedWarehouse.current_usage} /{" "}
- {selectedWarehouse.capacity}
-
-
- 사용률: {" "}
- {((selectedWarehouse.current_usage / selectedWarehouse.capacity) * 100).toFixed(1)}%
-
-
- 상태: {" "}
-
- {selectedWarehouse.status}
-
-
- {selectedWarehouse.description && (
-
- 설명: {selectedWarehouse.description}
-
- )}
-
-
- )}
-
- {/* 선택된 자재 정보 */}
- {selectedMaterial && (
-
-
-
-
- 자재 정보
-
-
-
-
- 이름: {selectedMaterial.name}
-
-
- 코드: {selectedMaterial.material_code}
-
-
- 수량: {selectedMaterial.quantity} {selectedMaterial.unit}
-
-
- 위치: {" "}
- {warehouses.find((w) => w.id === selectedMaterial.warehouse_id)?.name}
-
-
- 상태: {" "}
-
- {selectedMaterial.status}
-
-
-
-
- )}
-
- {/* 창고 목록 */}
- {!selectedWarehouse && !selectedMaterial && (
-
-
- 창고 목록
-
-
- {warehouses.map((warehouse) => {
- const warehouseMaterials = materials.filter((m) => m.warehouse_id === warehouse.id);
- return (
- setSelectedWarehouse(warehouse)}
- className="w-full rounded-lg border p-2 text-left transition-colors hover:bg-gray-50"
- >
-
- {warehouse.name}
- {warehouseMaterials.length}개
-
-
- {((warehouse.current_usage / warehouse.capacity) * 100).toFixed(0)}% 사용중
-
-
- );
- })}
-
-
- )}
-
-
-
- );
-}
diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx
new file mode 100644
index 00000000..2ba2e697
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Plus, Check } from "lucide-react";
+import YardLayoutList from "./yard-3d/YardLayoutList";
+import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
+import YardEditor from "./yard-3d/YardEditor";
+import Yard3DViewer from "./yard-3d/Yard3DViewer";
+import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
+import type { YardManagementConfig } from "../types";
+
+interface YardLayout {
+ id: number;
+ name: string;
+ description: string;
+ placement_count: number;
+ updated_at: string;
+}
+
+interface YardManagement3DWidgetProps {
+ isEditMode?: boolean;
+ config?: YardManagementConfig;
+ onConfigChange?: (config: YardManagementConfig) => void;
+}
+
+export default function YardManagement3DWidget({
+ isEditMode = false,
+ config,
+ onConfigChange,
+}: YardManagement3DWidgetProps) {
+ const [layouts, setLayouts] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [editingLayout, setEditingLayout] = useState(null);
+
+ // 레이아웃 목록 로드
+ const loadLayouts = async () => {
+ try {
+ setIsLoading(true);
+ const response = await yardLayoutApi.getAllLayouts();
+ if (response.success) {
+ setLayouts(response.data);
+ }
+ } catch (error) {
+ console.error("야드 레이아웃 목록 조회 실패:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (isEditMode) {
+ loadLayouts();
+ }
+ }, [isEditMode]);
+
+ // 레이아웃 선택 (편집 모드에서만)
+ const handleSelectLayout = (layout: YardLayout) => {
+ if (onConfigChange) {
+ onConfigChange({
+ layoutId: layout.id,
+ layoutName: layout.name,
+ });
+ }
+ };
+
+ // 새 레이아웃 생성
+ const handleCreateLayout = async (name: string, description: string) => {
+ try {
+ const response = await yardLayoutApi.createLayout({ name, description });
+ if (response.success) {
+ await loadLayouts();
+ setIsCreateModalOpen(false);
+ setEditingLayout(response.data);
+ }
+ } catch (error) {
+ console.error("야드 레이아웃 생성 실패:", error);
+ throw error;
+ }
+ };
+
+ // 편집 완료
+ const handleEditComplete = () => {
+ if (editingLayout && onConfigChange) {
+ onConfigChange({
+ layoutId: editingLayout.id,
+ layoutName: editingLayout.name,
+ });
+ }
+ setEditingLayout(null);
+ loadLayouts();
+ };
+
+ // 편집 모드: 편집 중인 경우 YardEditor 표시
+ if (isEditMode && editingLayout) {
+ return (
+
+
+
+ );
+ }
+
+ // 편집 모드: 레이아웃 선택 UI
+ if (isEditMode) {
+ return (
+
+
+
+
야드 레이아웃 선택
+
+ {config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
+
+
+
setIsCreateModalOpen(true)} size="sm">
+ 새 야드 생성
+
+
+
+
+ {isLoading ? (
+
+ ) : layouts.length === 0 ? (
+
+
+
🏗️
+
생성된 야드 레이아웃이 없습니다
+
먼저 야드 레이아웃을 생성하세요
+
+
+ ) : (
+
+ {layouts.map((layout) => (
+
+
+
handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
+
+ {layout.name}
+ {config?.layoutId === layout.id && }
+
+ {layout.description && {layout.description}
}
+ 배치된 자재: {layout.placement_count}개
+
+
{
+ e.stopPropagation();
+ setEditingLayout(layout);
+ }}
+ >
+ 편집
+
+
+
+ ))}
+
+ )}
+
+
+ {/* 생성 모달 */}
+
setIsCreateModalOpen(false)}
+ onCreate={handleCreateLayout}
+ />
+
+ );
+ }
+
+ // 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시
+ if (!config?.layoutId) {
+ return (
+
+
+
🏗️
+
야드 레이아웃이 설정되지 않았습니다
+
대시보드 편집에서 레이아웃을 선택하세요
+
+
+ );
+ }
+
+ // 선택된 레이아웃의 3D 뷰어 표시
+ return (
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx
new file mode 100644
index 00000000..2d813744
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Loader2 } from "lucide-react";
+
+interface TempMaterial {
+ id: number;
+ material_code: string;
+ material_name: string;
+ category: string;
+ unit: string;
+ default_color: string;
+ description: string;
+}
+
+interface MaterialAddModalProps {
+ isOpen: boolean;
+ material: TempMaterial | null;
+ onClose: () => void;
+ onAdd: (placementData: any) => Promise;
+}
+
+export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: MaterialAddModalProps) {
+ const [quantity, setQuantity] = useState("1");
+ const [positionX, setPositionX] = useState("0");
+ const [positionY, setPositionY] = useState("0");
+ const [positionZ, setPositionZ] = useState("0");
+ const [sizeX, setSizeX] = useState("5");
+ const [sizeY, setSizeY] = useState("5");
+ const [sizeZ, setSizeZ] = useState("5");
+ const [color, setColor] = useState("");
+ const [isAdding, setIsAdding] = useState(false);
+
+ // 모달이 열릴 때 기본값 설정
+ const handleOpen = (open: boolean) => {
+ if (open && material) {
+ setColor(material.default_color);
+ setQuantity("1");
+ setPositionX("0");
+ setPositionY("0");
+ setPositionZ("0");
+ setSizeX("5");
+ setSizeY("5");
+ setSizeZ("5");
+ }
+ };
+
+ // 자재 추가
+ const handleAdd = async () => {
+ if (!material) return;
+
+ setIsAdding(true);
+ try {
+ await onAdd({
+ external_material_id: `TEMP-${Date.now()}`,
+ material_code: material.material_code,
+ material_name: material.material_name,
+ quantity: parseInt(quantity) || 1,
+ unit: material.unit,
+ position_x: parseFloat(positionX) || 0,
+ position_y: parseFloat(positionY) || 0,
+ position_z: parseFloat(positionZ) || 0,
+ size_x: parseFloat(sizeX) || 5,
+ size_y: parseFloat(sizeY) || 5,
+ size_z: parseFloat(sizeZ) || 5,
+ color: color || material.default_color,
+ });
+ onClose();
+ } catch (error) {
+ console.error("자재 추가 실패:", error);
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ if (!material) return null;
+
+ return (
+ {
+ handleOpen(open);
+ if (!open) onClose();
+ }}
+ >
+
+
+ 자재 배치 설정
+
+
+
+ {/* 자재 정보 */}
+
+
선택한 자재
+
+
+
+
{material.material_name}
+
{material.material_code}
+
+
+
+
+ {/* 수량 */}
+
+
수량
+
+ setQuantity(e.target.value)}
+ min="1"
+ className="flex-1"
+ />
+ {material.unit}
+
+
+
+ {/* 3D 위치 */}
+
+
+ {/* 3D 크기 */}
+
+
+ {/* 색상 */}
+
+
+
+
+
+ 취소
+
+
+ {isAdding ? (
+ <>
+
+ 추가 중...
+ >
+ ) : (
+ "배치"
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx
new file mode 100644
index 00000000..d2388711
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Trash2 } from "lucide-react";
+
+interface YardPlacement {
+ id: number;
+ external_material_id: string;
+ material_code: string;
+ material_name: string;
+ quantity: number;
+ unit: string;
+ position_x: number;
+ position_y: number;
+ position_z: number;
+ size_x: number;
+ size_y: number;
+ size_z: number;
+ color: string;
+ memo?: string;
+}
+
+interface MaterialEditPanelProps {
+ placement: YardPlacement | null;
+ onClose: () => void;
+ onUpdate: (id: number, updates: Partial) => void;
+ onRemove: (id: number) => void;
+}
+
+export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) {
+ const [editData, setEditData] = useState>({});
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ // placement 변경 시 editData 초기화
+ useEffect(() => {
+ if (placement) {
+ setEditData({
+ position_x: placement.position_x,
+ position_y: placement.position_y,
+ position_z: placement.position_z,
+ size_x: placement.size_x,
+ size_y: placement.size_y,
+ size_z: placement.size_z,
+ color: placement.color,
+ memo: placement.memo,
+ });
+ }
+ }, [placement]);
+
+ if (!placement) return null;
+
+ // 변경사항 적용
+ const handleApply = () => {
+ onUpdate(placement.id, editData);
+ };
+
+ // 배치 해제
+ const handleRemove = () => {
+ onRemove(placement.id);
+ setIsDeleteDialogOpen(false);
+ };
+
+ return (
+
+
+
자재 정보
+
+ 닫기
+
+
+
+
+ {/* 읽기 전용 정보 */}
+
+
자재 정보 (읽기 전용)
+
+
자재 코드
+
{placement.material_code}
+
+
+
자재 이름
+
{placement.material_name}
+
+
+
수량
+
+ {placement.quantity} {placement.unit}
+
+
+
+
+ {/* 배치 정보 (편집 가능) */}
+
+
배치 정보 (편집 가능)
+
+ {/* 3D 위치 */}
+
+
+ {/* 3D 크기 */}
+
+
+ {/* 색상 */}
+
+
+ {/* 메모 */}
+
+
+ 메모
+
+
+
+ {/* 적용 버튼 */}
+
+ 변경사항 적용
+
+
+
+ {/* 배치 해제 */}
+
+ setIsDeleteDialogOpen(true)} className="w-full" size="sm">
+
+ 배치 해제
+
+
+
+
+ {/* 삭제 확인 모달 */}
+
+
+
+ 배치 해제
+
+ 정말로 이 자재를 배치 해제하시겠습니까?
+
+ "{placement.material_name}" ({placement.quantity} {placement.unit})
+
+
+
+ 취소
+
+ 해제
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx
new file mode 100644
index 00000000..317ef37a
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Search, Loader2 } from "lucide-react";
+import { materialApi } from "@/lib/api/yardLayoutApi";
+
+interface TempMaterial {
+ id: number;
+ material_code: string;
+ material_name: string;
+ category: string;
+ unit: string;
+ default_color: string;
+ description: string;
+}
+
+interface MaterialLibraryProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSelect: (material: TempMaterial) => void;
+}
+
+export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialLibraryProps) {
+ const [materials, setMaterials] = useState([]);
+ const [categories, setCategories] = useState([]);
+ const [searchText, setSearchText] = useState("");
+ const [selectedCategory, setSelectedCategory] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedMaterial, setSelectedMaterial] = useState(null);
+
+ // 자재 목록 로드
+ const loadMaterials = async () => {
+ try {
+ setIsLoading(true);
+ const [materialsResponse, categoriesResponse] = await Promise.all([
+ materialApi.getTempMaterials({
+ search: searchText || undefined,
+ category: selectedCategory || undefined,
+ page: 1,
+ limit: 50,
+ }),
+ materialApi.getCategories(),
+ ]);
+
+ if (materialsResponse.success) {
+ setMaterials(materialsResponse.data);
+ }
+
+ if (categoriesResponse.success) {
+ setCategories(categoriesResponse.data);
+ }
+ } catch (error) {
+ console.error("자재 목록 조회 실패:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ loadMaterials();
+ }
+ }, [isOpen, searchText, selectedCategory]);
+
+ // 자재 선택 및 추가
+ const handleSelectMaterial = () => {
+ if (selectedMaterial) {
+ onSelect(selectedMaterial);
+ setSelectedMaterial(null);
+ onClose();
+ }
+ };
+
+ // 모달 닫기
+ const handleClose = () => {
+ setSelectedMaterial(null);
+ setSearchText("");
+ setSelectedCategory("");
+ onClose();
+ };
+
+ return (
+
+
+
+ 자재 선택
+
+
+
+ {/* 검색 및 필터 */}
+
+
+
+ setSearchText(e.target.value)}
+ className="pl-9"
+ />
+
+
setSelectedCategory(e.target.value)}
+ className="rounded-md border border-gray-300 px-3 py-2 text-sm"
+ >
+ 전체 카테고리
+ {categories.map((category) => (
+
+ {category}
+
+ ))}
+
+
+
+ {/* 자재 목록 */}
+ {isLoading ? (
+
+
+
+ ) : materials.length === 0 ? (
+
+ {searchText || selectedCategory ? "검색 결과가 없습니다" : "등록된 자재가 없습니다"}
+
+ ) : (
+
+
+
+
+ 색상
+ 자재 코드
+ 자재 이름
+ 카테고리
+ 단위
+
+
+
+ {materials.map((material) => (
+ setSelectedMaterial(material)}
+ >
+
+
+
+ {material.material_code}
+ {material.material_name}
+ {material.category}
+ {material.unit}
+
+ ))}
+
+
+
+ )}
+
+ {/* 선택된 자재 정보 */}
+ {selectedMaterial && (
+
+
선택된 자재
+
+
+
+
{selectedMaterial.material_name}
+
{selectedMaterial.material_code}
+
+
+ {selectedMaterial.description && (
+
{selectedMaterial.description}
+ )}
+
+ )}
+
+
+
+
+ 취소
+
+
+ 선택
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
new file mode 100644
index 00000000..b9995e26
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
@@ -0,0 +1,299 @@
+"use client";
+
+import { Canvas, useThree } from "@react-three/fiber";
+import { OrbitControls, Grid, Box } from "@react-three/drei";
+import { Suspense, useRef, useState, useEffect } from "react";
+import * as THREE from "three";
+
+interface YardPlacement {
+ id: number;
+ external_material_id: string;
+ material_code: string;
+ material_name: string;
+ quantity: number;
+ unit: string;
+ position_x: number;
+ position_y: number;
+ position_z: number;
+ size_x: number;
+ size_y: number;
+ size_z: number;
+ color: string;
+}
+
+interface Yard3DCanvasProps {
+ placements: YardPlacement[];
+ selectedPlacementId: number | null;
+ onPlacementClick: (placement: YardPlacement) => void;
+ onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
+}
+
+// 자재 박스 컴포넌트 (드래그 가능)
+function MaterialBox({
+ placement,
+ isSelected,
+ onClick,
+ onDrag,
+ onDragStart,
+ onDragEnd,
+}: {
+ placement: YardPlacement;
+ isSelected: boolean;
+ onClick: () => void;
+ onDrag?: (position: { x: number; y: number; z: number }) => void;
+ onDragStart?: () => void;
+ onDragEnd?: () => void;
+}) {
+ const meshRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 });
+ const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const { camera, gl } = useThree();
+
+ // 드래그 중이 아닐 때 위치 업데이트
+ useEffect(() => {
+ if (!isDragging && meshRef.current) {
+ meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z);
+ }
+ }, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
+
+ // 전역 이벤트 리스너 등록
+ useEffect(() => {
+ const handleGlobalMouseMove = (e: MouseEvent) => {
+ if (isDragging && onDrag && meshRef.current) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // 마우스 이동 거리 계산 (픽셀)
+ const deltaX = e.clientX - mouseStartPos.current.x;
+ const deltaY = e.clientY - mouseStartPos.current.y;
+
+ // 카메라 거리를 고려한 스케일 팩터
+ const distance = camera.position.distanceTo(meshRef.current.position);
+ const scaleFactor = distance / 500; // 조정 가능한 값
+
+ // 카메라 방향 벡터
+ const cameraDirection = new THREE.Vector3();
+ camera.getWorldDirection(cameraDirection);
+
+ // 카메라의 우측 벡터 (X축 이동용)
+ const right = new THREE.Vector3();
+ right.crossVectors(camera.up, cameraDirection).normalize();
+
+ // 실제 3D 공간에서의 이동량 계산
+ const moveRight = right.multiplyScalar(-deltaX * scaleFactor);
+ const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z)
+ .normalize()
+ .multiplyScalar(deltaY * scaleFactor);
+
+ // 최종 위치 계산
+ const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
+ const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
+
+ // NaN 검증
+ if (isNaN(finalX) || isNaN(finalZ)) {
+ return;
+ }
+
+ // 즉시 mesh 위치 업데이트 (부드러운 드래그)
+ meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
+
+ // 상태 업데이트 (저장용)
+ onDrag({
+ x: finalX,
+ y: dragStartPos.current.y,
+ z: finalZ,
+ });
+ }
+ };
+
+ const handleGlobalMouseUp = () => {
+ if (isDragging) {
+ setIsDragging(false);
+ gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
+ if (onDragEnd) {
+ onDragEnd();
+ }
+ }
+ };
+
+ if (isDragging) {
+ window.addEventListener("mousemove", handleGlobalMouseMove);
+ window.addEventListener("mouseup", handleGlobalMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", handleGlobalMouseMove);
+ window.removeEventListener("mouseup", handleGlobalMouseUp);
+ };
+ }
+ }, [isDragging, onDrag, onDragEnd, camera, isSelected, gl.domElement]);
+
+ const handlePointerDown = (e: any) => {
+ e.stopPropagation();
+
+ // 뷰어 모드(onDrag 없음)에서는 클릭만 처리
+ if (!onDrag) {
+ return;
+ }
+
+ // 편집 모드에서 선택되었고 드래그 가능한 경우
+ if (isSelected && meshRef.current) {
+ // 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
+ dragStartPos.current = {
+ x: Number(placement.position_x),
+ y: Number(placement.position_y),
+ z: Number(placement.position_z),
+ };
+
+ // 마우스 시작 위치 저장
+ mouseStartPos.current = {
+ x: e.clientX,
+ y: e.clientY,
+ };
+
+ setIsDragging(true);
+ gl.domElement.style.cursor = "grabbing";
+ if (onDragStart) {
+ onDragStart();
+ }
+ }
+ };
+
+ return (
+ {
+ e.stopPropagation();
+ e.nativeEvent?.stopPropagation();
+ e.nativeEvent?.stopImmediatePropagation();
+ console.log("3D Box clicked:", placement.material_name);
+ onClick();
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerOver={() => {
+ // 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
+ if (onDrag) {
+ gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
+ } else {
+ gl.domElement.style.cursor = "pointer";
+ }
+ }}
+ onPointerOut={() => {
+ if (!isDragging) {
+ gl.domElement.style.cursor = "default";
+ }
+ }}
+ >
+
+
+ );
+}
+
+// 3D 씬 컴포넌트
+function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
+ const [isDraggingAny, setIsDraggingAny] = useState(false);
+ const orbitControlsRef = useRef(null);
+
+ return (
+ <>
+ {/* 조명 */}
+
+
+
+
+ {/* 바닥 그리드 */}
+
+
+ {/* 자재 박스들 */}
+ {placements.map((placement) => (
+ onPlacementClick(placement)}
+ onDrag={onPlacementDrag ? (position) => onPlacementDrag(placement.id, position) : undefined}
+ onDragStart={() => {
+ setIsDraggingAny(true);
+ if (orbitControlsRef.current) {
+ orbitControlsRef.current.enabled = false;
+ }
+ }}
+ onDragEnd={() => {
+ setIsDraggingAny(false);
+ if (orbitControlsRef.current) {
+ orbitControlsRef.current.enabled = true;
+ }
+ }}
+ />
+ ))}
+
+ {/* 카메라 컨트롤 */}
+
+ >
+ );
+}
+
+export default function Yard3DCanvas({
+ placements,
+ selectedPlacementId,
+ onPlacementClick,
+ onPlacementDrag,
+}: Yard3DCanvasProps) {
+ const handleCanvasClick = (e: any) => {
+ // Canvas의 빈 공간을 클릭했을 때만 선택 해제
+ // e.target이 canvas 엘리먼트인 경우
+ if (e.target.tagName === "CANVAS") {
+ onPlacementClick(null as any);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx
new file mode 100644
index 00000000..2c6f1bf4
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import Yard3DCanvas from "./Yard3DCanvas";
+import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
+import { Loader2 } from "lucide-react";
+
+interface YardPlacement {
+ id: number;
+ yard_layout_id: number;
+ external_material_id: string;
+ material_code: string;
+ material_name: string;
+ quantity: number;
+ unit: string;
+ position_x: number;
+ position_y: number;
+ position_z: number;
+ size_x: number;
+ size_y: number;
+ size_z: number;
+ color: string;
+ status?: string;
+ memo?: string;
+}
+
+interface Yard3DViewerProps {
+ layoutId: number;
+}
+
+export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
+ const [placements, setPlacements] = useState([]);
+ const [selectedPlacement, setSelectedPlacement] = useState(null);
+ const [layoutName, setLayoutName] = useState("");
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 선택 변경 로그
+ const handlePlacementClick = (placement: YardPlacement | null) => {
+ console.log("Yard3DViewer - Placement clicked:", placement?.material_name);
+ setSelectedPlacement(placement);
+ };
+
+ // 선택 상태 변경 감지
+ useEffect(() => {
+ console.log("selectedPlacement changed:", selectedPlacement?.material_name);
+ }, [selectedPlacement]);
+
+ // 야드 레이아웃 및 배치 데이터 로드
+ useEffect(() => {
+ const loadData = async () => {
+ try {
+ setIsLoading(true);
+ setError(null);
+
+ // 야드 레이아웃 정보 조회
+ const layoutResponse = await yardLayoutApi.getLayoutById(layoutId);
+ if (layoutResponse.success) {
+ setLayoutName(layoutResponse.data.name);
+ }
+
+ // 배치 데이터 조회
+ const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId);
+ if (placementsResponse.success) {
+ setPlacements(placementsResponse.data);
+ } else {
+ setError("배치 데이터를 불러올 수 없습니다.");
+ }
+ } catch (err) {
+ console.error("데이터 로드 실패:", err);
+ setError("데이터를 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadData();
+ }, [layoutId]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (placements.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* 3D 캔버스 */}
+
+
+ {/* 야드 이름 (좌측 상단) */}
+ {layoutName && (
+
+
{layoutName}
+
+ )}
+
+ {/* 선택된 자재 정보 패널 (우측 상단) */}
+ {selectedPlacement && (
+
+
+
자재 정보
+ {
+ setSelectedPlacement(null);
+ }}
+ className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+ >
+ ✕
+
+
+
+
+
+
자재명
+
{selectedPlacement.material_name}
+
+
+
+
수량
+
+ {selectedPlacement.quantity} {selectedPlacement.unit}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx
new file mode 100644
index 00000000..1d93f6a9
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx
@@ -0,0 +1,461 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft, Save, Loader2, X } from "lucide-react";
+import { yardLayoutApi, materialApi } from "@/lib/api/yardLayoutApi";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import dynamic from "next/dynamic";
+
+const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
+ ssr: false,
+ loading: () => (
+
+
+
+ ),
+});
+
+interface TempMaterial {
+ id: number;
+ material_code: string;
+ material_name: string;
+ category: string;
+ unit: string;
+ default_color: string;
+ description: string;
+}
+
+interface YardLayout {
+ id: number;
+ name: string;
+ description: string;
+ placement_count?: number;
+ updated_at: string;
+}
+
+interface YardPlacement {
+ id: number;
+ yard_layout_id: number;
+ external_material_id: string;
+ material_code: string;
+ material_name: string;
+ quantity: number;
+ unit: string;
+ position_x: number;
+ position_y: number;
+ position_z: number;
+ size_x: number;
+ size_y: number;
+ size_z: number;
+ color: string;
+ memo?: string;
+}
+
+interface YardEditorProps {
+ layout: YardLayout;
+ onBack: () => void;
+}
+
+export default function YardEditor({ layout, onBack }: YardEditorProps) {
+ const [placements, setPlacements] = useState([]);
+ const [materials, setMaterials] = useState([]);
+ const [selectedPlacement, setSelectedPlacement] = useState(null);
+ const [selectedMaterial, setSelectedMaterial] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // 배치 목록 & 자재 목록 로드
+ useEffect(() => {
+ const loadData = async () => {
+ try {
+ setIsLoading(true);
+ const [placementsRes, materialsRes] = await Promise.all([
+ yardLayoutApi.getPlacementsByLayoutId(layout.id),
+ materialApi.getTempMaterials({ limit: 100 }),
+ ]);
+
+ if (placementsRes.success) {
+ setPlacements(placementsRes.data);
+ }
+ if (materialsRes.success) {
+ setMaterials(materialsRes.data);
+ }
+ } catch (error) {
+ console.error("데이터 로드 실패:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadData();
+ }, [layout.id]);
+
+ // 자재 클릭 → 배치 추가
+ const handleMaterialClick = async (material: TempMaterial) => {
+ // 이미 배치되었는지 확인
+ const alreadyPlaced = placements.find((p) => p.material_code === material.material_code);
+ if (alreadyPlaced) {
+ alert("이미 배치된 자재입니다.");
+ return;
+ }
+
+ setSelectedMaterial(material);
+
+ // 기본 위치에 배치
+ const placementData = {
+ external_material_id: `TEMP-${material.id}`,
+ material_code: material.material_code,
+ material_name: material.material_name,
+ quantity: 1,
+ unit: material.unit,
+ position_x: 0,
+ position_y: 0,
+ position_z: 0,
+ size_x: 5,
+ size_y: 5,
+ size_z: 5,
+ color: material.default_color,
+ };
+
+ try {
+ const response = await yardLayoutApi.addMaterialPlacement(layout.id, placementData);
+ if (response.success) {
+ setPlacements((prev) => [...prev, response.data]);
+ setSelectedPlacement(response.data);
+ setSelectedMaterial(null);
+ }
+ } catch (error: any) {
+ console.error("자재 배치 실패:", error);
+ alert("자재 배치에 실패했습니다.");
+ }
+ };
+
+ // 자재 드래그 (3D 캔버스에서)
+ const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => {
+ const updatedPosition = {
+ position_x: Math.round(position.x * 2) / 2,
+ position_y: position.y,
+ position_z: Math.round(position.z * 2) / 2,
+ };
+
+ setPlacements((prev) =>
+ prev.map((p) =>
+ p.id === id
+ ? {
+ ...p,
+ ...updatedPosition,
+ }
+ : p,
+ ),
+ );
+
+ // 선택된 자재도 업데이트
+ if (selectedPlacement?.id === id) {
+ setSelectedPlacement((prev) =>
+ prev
+ ? {
+ ...prev,
+ ...updatedPosition,
+ }
+ : null,
+ );
+ }
+ };
+
+ // 자재 배치 해제
+ const handlePlacementRemove = async (id: number) => {
+ try {
+ const response = await yardLayoutApi.removePlacement(id);
+ if (response.success) {
+ setPlacements((prev) => prev.filter((p) => p.id !== id));
+ setSelectedPlacement(null);
+ }
+ } catch (error) {
+ console.error("배치 해제 실패:", error);
+ alert("배치 해제에 실패했습니다.");
+ }
+ };
+
+ // 위치/크기/색상 업데이트
+ const handlePlacementUpdate = (id: number, updates: Partial) => {
+ setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
+ };
+
+ // 저장
+ const handleSave = async () => {
+ setIsSaving(true);
+ try {
+ const response = await yardLayoutApi.batchUpdatePlacements(
+ layout.id,
+ placements.map((p) => ({
+ id: p.id,
+ position_x: p.position_x,
+ position_y: p.position_y,
+ position_z: p.position_z,
+ size_x: p.size_x,
+ size_y: p.size_y,
+ size_z: p.size_z,
+ color: p.color,
+ })),
+ );
+
+ if (response.success) {
+ alert("저장되었습니다");
+ }
+ } catch (error) {
+ console.error("저장 실패:", error);
+ alert("저장에 실패했습니다");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ // 필터링된 자재 목록
+ const filteredMaterials = materials.filter(
+ (m) =>
+ m.material_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ m.material_code.toLowerCase().includes(searchTerm.toLowerCase()),
+ );
+
+ return (
+
+ {/* 상단 툴바 */}
+
+
+
+
+ 목록으로
+
+
+
{layout.name}
+ {layout.description &&
{layout.description}
}
+
+
+
+
+ {isSaving ? (
+ <>
+
+ 저장 중...
+ >
+ ) : (
+ <>
+
+ 저장
+ >
+ )}
+
+
+
+ {/* 메인 컨텐츠 영역 */}
+
+ {/* 좌측: 3D 캔버스 */}
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* 우측: 자재 목록 또는 편집 패널 */}
+
+ {selectedPlacement ? (
+ // 선택된 자재 편집 패널
+
+
+
자재 정보
+ setSelectedPlacement(null)}>
+
+
+
+
+
+
+ {/* 읽기 전용 정보 */}
+
+
자재 코드
+
{selectedPlacement.material_code}
+
+
+
+
자재명
+
{selectedPlacement.material_name}
+
+
+
+
수량 (변경 불가)
+
+ {selectedPlacement.quantity} {selectedPlacement.unit}
+
+
+
+ {/* 편집 가능 정보 */}
+
+
배치 정보
+
+
+
+
+
+
+ 색상
+ handlePlacementUpdate(selectedPlacement.id, { color: e.target.value })}
+ />
+
+
+
+
handlePlacementRemove(selectedPlacement.id)}
+ >
+ 배치 해제
+
+
+
+
+ ) : (
+ // 자재 목록
+
+
+
자재 목록
+ setSearchTerm(e.target.value)}
+ className="text-sm"
+ />
+
+
+
+ {filteredMaterials.length === 0 ? (
+
+ 검색 결과가 없습니다
+
+ ) : (
+
+ {filteredMaterials.map((material) => {
+ const isPlaced = placements.some((p) => p.material_code === material.material_code);
+ return (
+
!isPlaced && handleMaterialClick(material)}
+ disabled={isPlaced}
+ className={`mb-2 w-full rounded-lg border p-3 text-left transition-all ${
+ isPlaced
+ ? "cursor-not-allowed border-gray-200 bg-gray-50 opacity-50"
+ : "cursor-pointer border-gray-200 bg-white hover:border-blue-500 hover:shadow-sm"
+ }`}
+ >
+ {material.material_name}
+ {material.material_code}
+ {material.category}
+ {isPlaced && 배치됨
}
+
+ );
+ })}
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx
new file mode 100644
index 00000000..14514f9f
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Loader2 } from "lucide-react";
+
+interface YardLayoutCreateModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onCreate: (name: string, description: string) => Promise;
+}
+
+export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: YardLayoutCreateModalProps) {
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isCreating, setIsCreating] = useState(false);
+ const [error, setError] = useState("");
+
+ // 생성 실행
+ const handleCreate = async () => {
+ if (!name.trim()) {
+ setError("야드 이름을 입력하세요");
+ return;
+ }
+
+ setIsCreating(true);
+ setError("");
+
+ try {
+ await onCreate(name.trim(), description.trim());
+ setName("");
+ setDescription("");
+ } catch (error: any) {
+ console.error("야드 생성 실패:", error);
+ setError(error.message || "야드 생성에 실패했습니다");
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ // 모달 닫기
+ const handleClose = () => {
+ if (isCreating) return;
+ setName("");
+ setDescription("");
+ setError("");
+ onClose();
+ };
+
+ // Enter 키 처리
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleCreate();
+ }
+ };
+
+ return (
+
+
+
+ 새 야드 생성
+ 야드의 이름과 설명을 입력하세요
+
+
+
+ {/* 야드 이름 */}
+
+
+ 야드 이름 *
+
+ {
+ setName(e.target.value);
+ setError("");
+ }}
+ onKeyDown={handleKeyDown}
+ placeholder="예: A구역, 1번 야드"
+ disabled={isCreating}
+ autoFocus
+ />
+
+
+ {/* 설명 */}
+
+ 설명
+
+
+ {/* 에러 메시지 */}
+ {error &&
{error}
}
+
+
+
+
+ 취소
+
+
+ {isCreating ? (
+ <>
+
+ 생성 중...
+ >
+ ) : (
+ "생성"
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx
new file mode 100644
index 00000000..ab80f20f
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Search, MoreVertical, Loader2 } from "lucide-react";
+
+interface YardLayout {
+ id: number;
+ name: string;
+ description: string;
+ placement_count: number;
+ updated_at: string;
+}
+
+interface YardLayoutListProps {
+ layouts: YardLayout[];
+ isLoading: boolean;
+ onSelect: (layout: YardLayout) => void;
+ onDelete: (id: number) => Promise;
+ onDuplicate: (id: number, newName: string) => Promise;
+}
+
+export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete, onDuplicate }: YardLayoutListProps) {
+ const [searchText, setSearchText] = useState("");
+ const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [duplicateTarget, setDuplicateTarget] = useState(null);
+ const [duplicateName, setDuplicateName] = useState("");
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isDuplicating, setIsDuplicating] = useState(false);
+
+ // 검색 필터링
+ const filteredLayouts = layouts.filter((layout) => {
+ if (!searchText) return true;
+ return (
+ layout.name.toLowerCase().includes(searchText.toLowerCase()) ||
+ layout.description?.toLowerCase().includes(searchText.toLowerCase())
+ );
+ });
+
+ // 정렬
+ const sortedLayouts = [...filteredLayouts].sort((a, b) => {
+ if (sortOrder === "recent") {
+ return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
+ } else {
+ return a.name.localeCompare(b.name);
+ }
+ });
+
+ // 날짜 포맷팅
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
+
+ // 삭제 확인
+ const handleDeleteConfirm = async () => {
+ if (!deleteTarget) return;
+
+ setIsDeleting(true);
+ try {
+ await onDelete(deleteTarget.id);
+ setDeleteTarget(null);
+ } catch (error) {
+ console.error("삭제 실패:", error);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ // 복제 실행
+ const handleDuplicateConfirm = async () => {
+ if (!duplicateTarget || !duplicateName.trim()) return;
+
+ setIsDuplicating(true);
+ try {
+ await onDuplicate(duplicateTarget.id, duplicateName);
+ setDuplicateTarget(null);
+ setDuplicateName("");
+ } catch (error) {
+ console.error("복제 실패:", error);
+ } finally {
+ setIsDuplicating(false);
+ }
+ };
+
+ // 복제 모달 열기
+ const handleDuplicateClick = (layout: YardLayout) => {
+ setDuplicateTarget(layout);
+ setDuplicateName(`${layout.name} (복사본)`);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 검색 및 정렬 */}
+
+
+
+ setSearchText(e.target.value)}
+ className="pl-9"
+ />
+
+
setSortOrder(e.target.value as "recent" | "name")}
+ className="rounded-md border border-gray-300 px-3 py-2 text-sm"
+ >
+ 최근 수정순
+ 이름순
+
+
+
+ {/* 테이블 */}
+ {sortedLayouts.length === 0 ? (
+
+
+ {searchText ? "검색 결과가 없습니다" : "등록된 야드가 없습니다"}
+
+
+ ) : (
+
+
+
+
+ 야드명
+ 설명
+ 배치 자재
+ 최종 수정
+ 작업
+
+
+
+ {sortedLayouts.map((layout) => (
+ onSelect(layout)}>
+ {layout.name}
+ {layout.description || "-"}
+ {layout.placement_count}개
+ {formatDate(layout.updated_at)}
+
+
+ e.stopPropagation()}>
+
+
+
+
+
+ onSelect(layout)}>편집
+ handleDuplicateClick(layout)}>복제
+ setDeleteTarget(layout)} className="text-red-600">
+ 삭제
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 총 개수 */}
+
총 {sortedLayouts.length}개
+
+ {/* 삭제 확인 모달 */}
+
setDeleteTarget(null)}>
+
+
+ 야드 삭제
+
+ 정말로 "{deleteTarget?.name}" 야드를 삭제하시겠습니까?
+
+ 배치된 자재 정보도 함께 삭제됩니다.
+
+
+
+ 취소
+
+ {isDeleting ? (
+ <>
+
+ 삭제 중...
+ >
+ ) : (
+ "삭제"
+ )}
+
+
+
+
+
+ {/* 복제 모달 */}
+
setDuplicateTarget(null)}>
+
+
+ 야드 복제
+ 새로운 야드의 이름을 입력하세요
+
+
+
+ 야드 이름
+ setDuplicateName(e.target.value)}
+ placeholder="야드 이름을 입력하세요"
+ />
+
+
+
+ setDuplicateTarget(null)} disabled={isDuplicating}>
+ 취소
+
+
+ {isDuplicating ? (
+ <>
+
+ 복제 중...
+ >
+ ) : (
+ "복제"
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx
index 2f587db4..9b6e83f8 100644
--- a/frontend/components/dashboard/DashboardViewer.tsx
+++ b/frontend/components/dashboard/DashboardViewer.tsx
@@ -3,6 +3,7 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
+import { DashboardProvider } from "@/contexts/DashboardContext";
import { RESOLUTIONS, Resolution } from "@/components/admin/dashboard/ResolutionSelector";
import dynamic from "next/dynamic";
@@ -38,13 +39,9 @@ const ListWidget = dynamic(
{ ssr: false },
);
-const Warehouse3DWidget = dynamic(
- () =>
- import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({
- default: mod.Warehouse3DWidget,
- })),
- { ssr: false },
-);
+const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
+ ssr: false,
+});
/**
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
@@ -84,8 +81,8 @@ function renderWidget(element: DashboardElement) {
case "list":
return ;
- case "warehouse-3d":
- return ;
+ case "yard-management-3d":
+ return ;
// === 차량 관련 (추가 위젯) ===
case "vehicle-status":
@@ -122,7 +119,9 @@ function renderWidget(element: DashboardElement) {
interface DashboardViewerProps {
elements: DashboardElement[];
+ dashboardId?: string;
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
+ backgroundColor?: string; // 배경색
resolution?: string; // 대시보드 해상도
}
@@ -132,7 +131,13 @@ interface DashboardViewerProps {
* - 실시간 데이터 업데이트
* - 편집 화면과 동일한 레이아웃 (중앙 정렬, 고정 크기)
*/
-export function DashboardViewer({ elements, refreshInterval, resolution = "fhd" }: DashboardViewerProps) {
+export function DashboardViewer({
+ elements,
+ dashboardId,
+ refreshInterval,
+ backgroundColor = "#f9fafb",
+ resolution = "fhd",
+}: DashboardViewerProps) {
const [elementData, setElementData] = useState>({});
const [loadingElements, setLoadingElements] = useState>(new Set());
@@ -250,28 +255,32 @@ export function DashboardViewer({ elements, refreshInterval, resolution = "fhd"
}
return (
-
- {/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
-
- {/* 대시보드 요소들 */}
- {elements.map((element) => (
-
loadElementData(element)}
- />
- ))}
+
+ {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
+
+ {/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
+
+ {/* 대시보드 요소들 */}
+ {elements.map((element) => (
+ loadElementData(element)}
+ />
+ ))}
+
-
+
);
}
@@ -305,21 +314,21 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
{element.customTitle || element.title}
- {/* 새로고침 버튼 (호버 시에만 표시) */}
- {isHovered && (
-
- {isLoading ? (
-
- ) : (
- "🔄"
- )}
-
- )}
+ {/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */}
+
+ {isLoading ? (
+
+ ) : (
+ "🔄"
+ )}
+
)}
diff --git a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx
index b47f0fb4..cffab99b 100644
--- a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx
+++ b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx
@@ -161,7 +161,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
-
🔔 {element?.customTitle || "예약 요청 알림"}
+
{element?.customTitle || "예약 요청 알림"}
{newCount > 0 && (
{newCount}
diff --git a/frontend/components/dashboard/widgets/CalculatorWidget.tsx b/frontend/components/dashboard/widgets/CalculatorWidget.tsx
index b8816bbc..d86c44e3 100644
--- a/frontend/components/dashboard/widgets/CalculatorWidget.tsx
+++ b/frontend/components/dashboard/widgets/CalculatorWidget.tsx
@@ -7,7 +7,7 @@
* - 대시보드 위젯으로 사용 가능
*/
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { DashboardElement } from '@/components/admin/dashboard/types';
@@ -117,11 +117,62 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
setDisplay(String(value / 100));
};
+ // 키보드 입력 처리
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const key = event.key;
+
+ // 숫자 키 (0-9)
+ if (/^[0-9]$/.test(key)) {
+ event.preventDefault();
+ handleNumber(key);
+ }
+ // 연산자 키
+ else if (key === '+' || key === '-' || key === '*' || key === '/') {
+ event.preventDefault();
+ handleOperation(key);
+ }
+ // 소수점
+ else if (key === '.') {
+ event.preventDefault();
+ handleDecimal();
+ }
+ // Enter 또는 = (계산)
+ else if (key === 'Enter' || key === '=') {
+ event.preventDefault();
+ handleEquals();
+ }
+ // Escape 또는 c (초기화)
+ else if (key === 'Escape' || key.toLowerCase() === 'c') {
+ event.preventDefault();
+ handleClear();
+ }
+ // Backspace (지우기)
+ else if (key === 'Backspace') {
+ event.preventDefault();
+ handleBackspace();
+ }
+ // % (퍼센트)
+ else if (key === '%') {
+ event.preventDefault();
+ handlePercent();
+ }
+ };
+
+ // 이벤트 리스너 등록
+ window.addEventListener('keydown', handleKeyDown);
+
+ // 클린업
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [display, previousValue, operation, waitingForOperand]);
+
return (
{/* 제목 */}
-
🧮 {element?.customTitle || "계산기"}
+
{element?.customTitle || "계산기"}
{/* 디스플레이 */}
diff --git a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx
index f7f50a43..26d6d27d 100644
--- a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx
+++ b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx
@@ -150,7 +150,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
{/* 헤더 */}
-
⚠️ 고객 클레임/이슈
+
고객 클레임/이슈
{/* 헤더 */}
-
📅 오늘 처리 현황
+
오늘 처리 현황
-
📂 {element?.customTitle || "문서 관리"}
+
{element?.customTitle || "문서 관리"}
+ 업로드
diff --git a/frontend/components/dashboard/widgets/ExchangeWidget.tsx b/frontend/components/dashboard/widgets/ExchangeWidget.tsx
index 86743326..20e0cea7 100644
--- a/frontend/components/dashboard/widgets/ExchangeWidget.tsx
+++ b/frontend/components/dashboard/widgets/ExchangeWidget.tsx
@@ -135,11 +135,11 @@ export default function ExchangeWidget({
const hasError = error || !exchangeRate;
return (
-
+
{/* 헤더 */}
-
💱 {element?.customTitle || "환율"}
+
{element?.customTitle || "환율"}
{lastUpdated
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
@@ -160,10 +160,10 @@ export default function ExchangeWidget({
- {/* 통화 선택 */}
-
+ {/* 통화 선택 - 반응형 (좁을 때 세로 배치) */}
+
-
+
@@ -179,13 +179,13 @@ export default function ExchangeWidget({
variant="ghost"
size="sm"
onClick={handleSwap}
- className="h-8 w-8 p-0 rounded-full hover:bg-white"
+ className="h-8 w-8 p-0 rounded-full hover:bg-white @[300px]:rotate-0 rotate-90"
>
-
+
diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx
index e91746a9..3ad09305 100644
--- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx
+++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx
@@ -158,7 +158,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
{/* 헤더 */}
-
📍 {displayTitle}
+
{displayTitle}
{element?.dataSource?.query ? (
총 {markers.length.toLocaleString()}개 마커
) : (
diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx
index f43ba325..dd4652d5 100644
--- a/frontend/components/dashboard/widgets/TodoWidget.tsx
+++ b/frontend/components/dashboard/widgets/TodoWidget.tsx
@@ -1,8 +1,9 @@
"use client";
import React, { useState, useEffect } from "react";
-import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react";
+import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown, Calendar as CalendarIcon } from "lucide-react";
import { DashboardElement } from "@/components/admin/dashboard/types";
+import { useDashboard } from "@/contexts/DashboardContext";
interface TodoItem {
id: string;
@@ -33,6 +34,9 @@ interface TodoWidgetProps {
}
export default function TodoWidget({ element }: TodoWidgetProps) {
+ // Context에서 선택된 날짜 가져오기
+ const { selectedDate } = useDashboard();
+
const [todos, setTodos] = useState
([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
@@ -51,22 +55,85 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
fetchTodos();
const interval = setInterval(fetchTodos, 30000); // 30초마다 갱신
return () => clearInterval(interval);
- }, [filter]);
+ }, [filter, selectedDate]); // selectedDate도 의존성에 추가
const fetchTodos = async () => {
try {
const token = localStorage.getItem("authToken");
- const filterParam = filter !== "all" ? `?status=${filter}` : "";
- const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
+ const userLang = localStorage.getItem("userLang") || "KR";
+
+ // 외부 DB 조회 (dataSource가 설정된 경우)
+ if (element?.dataSource?.query) {
+ // console.log("🔍 TodoWidget - 외부 DB 조회 시작");
+ // console.log("📝 Query:", element.dataSource.query);
+ // console.log("🔗 ConnectionId:", element.dataSource.externalConnectionId);
+ // console.log("🔗 ConnectionType:", element.dataSource.connectionType);
+
+ // 현재 DB vs 외부 DB 분기
+ const apiUrl = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId
+ ? `http://localhost:9771/api/external-db/query?userLang=${userLang}`
+ : `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`;
+
+ const requestBody = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId
+ ? {
+ connectionId: parseInt(element.dataSource.externalConnectionId),
+ query: element.dataSource.query,
+ }
+ : {
+ query: element.dataSource.query,
+ };
- if (response.ok) {
- const result = await response.json();
- setTodos(result.data || []);
- setStats(result.stats);
+ // console.log("🌐 API URL:", apiUrl);
+ // console.log("📦 Request Body:", requestBody);
+
+ const response = await fetch(apiUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ // console.log("📡 Response status:", response.status);
+
+ if (response.ok) {
+ const result = await response.json();
+ // console.log("✅ API 응답:", result);
+ // console.log("📦 result.data:", result.data);
+ // console.log("📦 result.data.rows:", result.data?.rows);
+
+ // API 응답 형식에 따라 데이터 추출
+ const rows = result.data?.rows || result.data || [];
+ // console.log("📊 추출된 rows:", rows);
+
+ const externalTodos = mapExternalDataToTodos(rows);
+ // console.log("📋 변환된 Todos:", externalTodos);
+ // console.log("📋 변환된 Todos 개수:", externalTodos.length);
+
+ setTodos(externalTodos);
+ setStats(calculateStatsFromTodos(externalTodos));
+
+ // console.log("✅ setTodos, setStats 호출 완료!");
+ } else {
+ const errorText = await response.text();
+ // console.error("❌ API 오류:", errorText);
+ }
+ }
+ // 내장 API 조회 (기본)
+ else {
+ const filterParam = filter !== "all" ? `?status=${filter}` : "";
+ const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ setTodos(result.data || []);
+ setStats(result.stats);
+ }
}
} catch (error) {
// console.error("To-Do 로딩 오류:", error);
@@ -75,8 +142,48 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
}
};
+ // 외부 DB 데이터를 TodoItem 형식으로 변환
+ const mapExternalDataToTodos = (data: any[]): TodoItem[] => {
+ return data.map((row, index) => ({
+ id: row.id || `todo-${index}`,
+ title: row.title || row.task || row.name || "제목 없음",
+ description: row.description || row.desc || row.content,
+ priority: row.priority || "normal",
+ status: row.status || "pending",
+ assignedTo: row.assigned_to || row.assignedTo || row.user,
+ dueDate: row.due_date || row.dueDate || row.deadline,
+ createdAt: row.created_at || row.createdAt || new Date().toISOString(),
+ updatedAt: row.updated_at || row.updatedAt || new Date().toISOString(),
+ completedAt: row.completed_at || row.completedAt,
+ isUrgent: row.is_urgent || row.isUrgent || row.urgent || false,
+ order: row.display_order || row.order || index,
+ }));
+ };
+
+ // Todo 배열로부터 통계 계산
+ const calculateStatsFromTodos = (todoList: TodoItem[]): TodoStats => {
+ return {
+ total: todoList.length,
+ pending: todoList.filter((t) => t.status === "pending").length,
+ inProgress: todoList.filter((t) => t.status === "in_progress").length,
+ completed: todoList.filter((t) => t.status === "completed").length,
+ urgent: todoList.filter((t) => t.isUrgent).length,
+ overdue: todoList.filter((t) => {
+ if (!t.dueDate) return false;
+ return new Date(t.dueDate) < new Date() && t.status !== "completed";
+ }).length,
+ };
+ };
+
+ // 외부 DB 조회 여부 확인
+ const isExternalData = !!element?.dataSource?.query;
+
const handleAddTodo = async () => {
if (!newTodo.title.trim()) return;
+ if (isExternalData) {
+ alert("외부 데이터베이스 조회 모드에서는 추가할 수 없습니다.");
+ return;
+ }
try {
const token = localStorage.getItem("authToken");
@@ -185,6 +292,27 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
return "⚠️ 오늘 마감";
};
+ // 선택된 날짜로 필터링
+ const filteredTodos = selectedDate
+ ? todos.filter((todo) => {
+ if (!todo.dueDate) return false;
+ const todoDate = new Date(todo.dueDate);
+ return (
+ todoDate.getFullYear() === selectedDate.getFullYear() &&
+ todoDate.getMonth() === selectedDate.getMonth() &&
+ todoDate.getDate() === selectedDate.getDate()
+ );
+ })
+ : todos;
+
+ const formatSelectedDate = () => {
+ if (!selectedDate) return null;
+ const year = selectedDate.getFullYear();
+ const month = selectedDate.getMonth() + 1;
+ const day = selectedDate.getDate();
+ return `${year}년 ${month}월 ${day}일`;
+ };
+
if (loading) {
return (
@@ -195,59 +323,71 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
return (
- {/* 헤더 */}
-
-
-
✅ {element?.customTitle || "To-Do / 긴급 지시"}
-
setShowAddForm(!showAddForm)}
- className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
- >
-
- 추가
-
-
-
- {/* 통계 */}
- {stats && (
-
-
-
-
{stats.inProgress}
-
진행중
-
-
-
+ {/* 제목 - 항상 표시 */}
+
+
{element?.customTitle || "To-Do / 긴급 지시"}
+ {selectedDate && (
+
+
+ {formatSelectedDate()} 할일
)}
-
- {/* 필터 */}
-
- {(["all", "pending", "in_progress", "completed"] as const).map((f) => (
- setFilter(f)}
- className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
- filter === f
- ? "bg-primary text-white"
- : "bg-gray-100 text-gray-600 hover:bg-gray-200"
- }`}
- >
- {f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
-
- ))}
-
+ {/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */}
+ {element?.showHeader !== false && (
+
+
+
setShowAddForm(!showAddForm)}
+ className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
+ >
+
+ 추가
+
+
+
+ {/* 통계 */}
+ {stats && (
+
+
+
+
{stats.inProgress}
+
진행중
+
+
+
+
+ )}
+
+ {/* 필터 */}
+
+ {(["all", "pending", "in_progress", "completed"] as const).map((f) => (
+ setFilter(f)}
+ className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
+ filter === f
+ ? "bg-primary text-white"
+ : "bg-gray-100 text-gray-600 hover:bg-gray-200"
+ }`}
+ >
+ {f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
+
+ ))}
+
+
+ )}
+
{/* 추가 폼 */}
{showAddForm && (
@@ -315,16 +455,16 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
{/* To-Do 리스트 */}
- {todos.length === 0 ? (
+ {filteredTodos.length === 0 ? (
📝
-
할 일이 없습니다
+
{selectedDate ? `${formatSelectedDate()} 할 일이 없습니다` : "할 일이 없습니다"}
) : (
- {todos.map((todo) => (
+ {filteredTodos.map((todo) => (
-
📋 차량 목록
+
차량 목록
마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}
diff --git a/frontend/components/dashboard/widgets/WeatherWidget.tsx b/frontend/components/dashboard/widgets/WeatherWidget.tsx
index 833f0f2a..57683a57 100644
--- a/frontend/components/dashboard/widgets/WeatherWidget.tsx
+++ b/frontend/components/dashboard/widgets/WeatherWidget.tsx
@@ -280,9 +280,12 @@ export default function WeatherWidget({
if (loading && !weather) {
return (
-
+
-
날씨 정보 불러오는 중...
+
+
실제 기상청 API 연결 중...
+
실시간 관측 데이터를 가져오고 있습니다
+
);
@@ -290,10 +293,27 @@ export default function WeatherWidget({
// 에러 상태
if (error || !weather) {
+ const isTestMode = error?.includes('API 키가 설정되지 않았습니다');
return (
-
+
-
{error || '날씨 정보를 불러올 수 없습니다.'}
+
+
+ {isTestMode ? '⚠️ 테스트 모드' : '❌ 연결 실패'}
+
+
+ {error || '날씨 정보를 불러올 수 없습니다.'}
+
+ {isTestMode && (
+
+ 임시 데이터가 표시됩니다
+
+ )}
+
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
- {children}
+ {children}
{/* 프로필 수정 모달 */}
diff --git a/frontend/contexts/DashboardContext.tsx b/frontend/contexts/DashboardContext.tsx
new file mode 100644
index 00000000..2830e3d5
--- /dev/null
+++ b/frontend/contexts/DashboardContext.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import React, { createContext, useContext, useState, ReactNode } from "react";
+
+/**
+ * 대시보드 위젯 간 데이터 공유를 위한 Context
+ * - 달력에서 날짜 선택 시 할일/긴급지시 위젯에 전달
+ */
+
+interface DashboardContextType {
+ selectedDate: Date | null;
+ setSelectedDate: (date: Date | null) => void;
+}
+
+const DashboardContext = createContext
(undefined);
+
+export function DashboardProvider({ children }: { children: ReactNode }) {
+ const [selectedDate, setSelectedDate] = useState(null);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useDashboard() {
+ const context = useContext(DashboardContext);
+ if (context === undefined) {
+ throw new Error("useDashboard must be used within a DashboardProvider");
+ }
+ return context;
+}
+
diff --git a/frontend/lib/api/yardLayoutApi.ts b/frontend/lib/api/yardLayoutApi.ts
new file mode 100644
index 00000000..2dbd9f4c
--- /dev/null
+++ b/frontend/lib/api/yardLayoutApi.ts
@@ -0,0 +1,84 @@
+import { apiCall } from "./client";
+
+// 야드 레이아웃 관리 API
+export const yardLayoutApi = {
+ // 모든 야드 레이아웃 목록 조회
+ async getAllLayouts() {
+ return apiCall("GET", "/yard-layouts");
+ },
+
+ // 특정 야드 레이아웃 상세 조회
+ async getLayoutById(id: number) {
+ return apiCall("GET", `/yard-layouts/${id}`);
+ },
+
+ // 새 야드 레이아웃 생성
+ async createLayout(data: { name: string; description?: string }) {
+ return apiCall("POST", "/yard-layouts", data);
+ },
+
+ // 야드 레이아웃 수정
+ async updateLayout(id: number, data: { name?: string; description?: string }) {
+ return apiCall("PUT", `/yard-layouts/${id}`, data);
+ },
+
+ // 야드 레이아웃 삭제
+ async deleteLayout(id: number) {
+ return apiCall("DELETE", `/yard-layouts/${id}`);
+ },
+
+ // 야드 레이아웃 복제
+ async duplicateLayout(id: number, name: string) {
+ return apiCall("POST", `/yard-layouts/${id}/duplicate`, { name });
+ },
+
+ // 특정 야드의 배치 자재 목록 조회
+ async getPlacementsByLayoutId(layoutId: number) {
+ return apiCall("GET", `/yard-layouts/${layoutId}/placements`);
+ },
+
+ // 야드에 자재 배치 추가
+ async addMaterialPlacement(layoutId: number, data: any) {
+ return apiCall("POST", `/yard-layouts/${layoutId}/placements`, data);
+ },
+
+ // 배치 정보 수정
+ async updatePlacement(placementId: number, data: any) {
+ return apiCall("PUT", `/yard-layouts/placements/${placementId}`, data);
+ },
+
+ // 배치 해제
+ async removePlacement(placementId: number) {
+ return apiCall("DELETE", `/yard-layouts/placements/${placementId}`);
+ },
+
+ // 여러 배치 일괄 업데이트
+ async batchUpdatePlacements(layoutId: number, placements: any[]) {
+ return apiCall("PUT", `/yard-layouts/${layoutId}/placements/batch`, { placements });
+ },
+};
+
+// 자재 관리 API
+export const materialApi = {
+ // 임시 자재 마스터 목록 조회
+ async getTempMaterials(params?: { search?: string; category?: string; page?: number; limit?: number }) {
+ const queryParams = new URLSearchParams();
+ if (params?.search) queryParams.append("search", params.search);
+ if (params?.category) queryParams.append("category", params.category);
+ if (params?.page) queryParams.append("page", params.page.toString());
+ if (params?.limit) queryParams.append("limit", params.limit.toString());
+
+ const queryString = queryParams.toString();
+ return apiCall("GET", `/materials/temp${queryString ? `?${queryString}` : ""}`);
+ },
+
+ // 특정 자재 상세 조회
+ async getTempMaterialByCode(code: string) {
+ return apiCall("GET", `/materials/temp/${code}`);
+ },
+
+ // 카테고리 목록 조회
+ async getCategories() {
+ return apiCall("GET", "/materials/temp/categories");
+ },
+};