@@ -232,12 +249,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
{/* 단계별 내용 */}
-
- {currentStep === 1 && (
-
- )}
+ {!isHeaderOnlyWidget && (
+
+ {currentStep === 1 && (
+
+ )}
- {currentStep === 2 && (
+ {currentStep === 2 && (
{/* 왼쪽: 데이터 설정 */}
@@ -293,15 +311,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
- )}
-
+ )}
+
{queryResult && {queryResult.rows.length}개 데이터 로드됨}
- {!isSimpleWidget && currentStep > 1 && (
+ {!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 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/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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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"
+ />
+
+
+
+
+ {/* 테이블 */}
+ {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 ? (
+ <>
+
+ 삭제 중...
+ >
+ ) : (
+ "삭제"
+ )}
+
+
+
+
+
+ {/* 복제 모달 */}
+
+
+ );
+}
diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx
index c6e941e3..9b6e83f8 100644
--- a/frontend/components/dashboard/DashboardViewer.tsx
+++ b/frontend/components/dashboard/DashboardViewer.tsx
@@ -1,12 +1,13 @@
"use client";
-import React, { useState, useEffect, useCallback } from "react";
+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";
// 위젯 동적 import - 모든 위젯
-const ListSummaryWidget = dynamic(() => import("./widgets/ListSummaryWidget"), { ssr: false });
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
@@ -25,7 +26,22 @@ const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr:
const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false });
const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false });
const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false });
-const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widgets/CalendarWidget").then(mod => ({ default: mod.CalendarWidget })), { ssr: false });
+const CalendarWidget = dynamic(
+ () => import("@/components/admin/dashboard/widgets/CalendarWidget").then((mod) => ({ default: mod.CalendarWidget })),
+ { ssr: false },
+);
+const ClockWidget = dynamic(
+ () => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })),
+ { ssr: false },
+);
+const ListWidget = dynamic(
+ () => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })),
+ { ssr: false },
+);
+
+const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
+ ssr: false,
+});
/**
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
@@ -34,7 +50,7 @@ const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widget
function renderWidget(element: DashboardElement) {
switch (element.subtype) {
// 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨)
-
+
// === 위젯 종류 ===
case "exchange":
return ;
@@ -43,57 +59,51 @@ function renderWidget(element: DashboardElement) {
case "calculator":
return ;
case "clock":
- return (
-
- );
+ return ;
case "map-summary":
return ;
- case "list-summary":
- return ;
case "risk-alert":
return ;
case "calendar":
return ;
case "status-summary":
return ;
-
+
// === 운영/작업 지원 ===
case "todo":
return ;
case "booking-alert":
return ;
case "maintenance":
- return ;
+ return ;
case "document":
return ;
case "list":
- return ;
-
+ return ;
+
+ case "yard-management-3d":
+ return ;
+
// === 차량 관련 (추가 위젯) ===
case "vehicle-status":
- return ;
+ return ;
case "vehicle-list":
- return ;
+ return ;
case "vehicle-map":
return ;
-
+
// === 배송 관련 (추가 위젯) ===
case "delivery-status":
- return ;
+ return ;
case "delivery-status-summary":
- return ;
+ return ;
case "delivery-today-stats":
- return ;
+ return ;
case "cargo-list":
- return ;
+ return ;
case "customer-issues":
- return ;
-
+ return ;
+
// === 기본 fallback ===
default:
return (
@@ -109,20 +119,41 @@ function renderWidget(element: DashboardElement) {
interface DashboardViewerProps {
elements: DashboardElement[];
- dashboardId: string;
+ dashboardId?: string;
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
+ backgroundColor?: string; // 배경색
+ resolution?: string; // 대시보드 해상도
}
/**
* 대시보드 뷰어 컴포넌트
* - 저장된 대시보드를 읽기 전용으로 표시
* - 실시간 데이터 업데이트
- * - 반응형 레이아웃
+ * - 편집 화면과 동일한 레이아웃 (중앙 정렬, 고정 크기)
*/
-export function DashboardViewer({ elements, dashboardId, refreshInterval }: DashboardViewerProps) {
+export function DashboardViewer({
+ elements,
+ dashboardId,
+ refreshInterval,
+ backgroundColor = "#f9fafb",
+ resolution = "fhd",
+}: DashboardViewerProps) {
const [elementData, setElementData] = useState>({});
const [loadingElements, setLoadingElements] = useState>(new Set());
- const [lastRefresh, setLastRefresh] = useState(new Date());
+
+ // 캔버스 설정 계산
+ const canvasConfig = useMemo(() => {
+ return RESOLUTIONS[resolution as Resolution] || RESOLUTIONS.fhd;
+ }, [resolution]);
+
+ // 캔버스 높이 동적 계산
+ const canvasHeight = useMemo(() => {
+ if (elements.length === 0) {
+ return canvasConfig.height;
+ }
+ const maxBottomY = Math.max(...elements.map((el) => el.position.y + el.size.height));
+ return Math.max(canvasConfig.height, maxBottomY + 100);
+ }, [elements, canvasConfig.height]);
// 개별 요소 데이터 로딩
const loadElementData = useCallback(async (element: DashboardElement) => {
@@ -176,7 +207,7 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
[element.id]: data,
}));
}
- } catch (error) {
+ } catch {
// 에러 발생 시 무시 (차트는 빈 상태로 표시됨)
} finally {
setLoadingElements((prev) => {
@@ -189,8 +220,6 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
// 모든 요소 데이터 로딩
const loadAllData = useCallback(async () => {
- setLastRefresh(new Date());
-
const chartElements = elements.filter((el) => el.type === "chart" && el.dataSource?.query);
// 병렬로 모든 차트 데이터 로딩
@@ -226,28 +255,32 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
}
return (
-
- {/* 새로고침 상태 표시 */}
-
- 마지막 업데이트: {lastRefresh.toLocaleTimeString()}
- {Array.from(loadingElements).length > 0 && (
-
({Array.from(loadingElements).length}개 로딩 중...)
- )}
+
+ {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
+
+ {/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
+
+ {/* 대시보드 요소들 */}
+ {elements.map((element) => (
+ loadElementData(element)}
+ />
+ ))}
+
-
- {/* 대시보드 요소들 */}
-
- {elements.map((element) => (
- loadElementData(element)}
- />
- ))}
-
-
+
);
}
@@ -276,16 +309,18 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
- {/* 헤더 */}
-
-
{element.title}
+ {/* 헤더 (showHeader가 false가 아닐 때만 표시) */}
+ {element.showHeader !== false && (
+
+
{element.customTitle || element.title}
- {/* 새로고침 버튼 (호버 시에만 표시) */}
- {isHovered && (
+ {/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */}
{isLoading ? (
@@ -294,14 +329,16 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
"🔄"
)}
- )}
-
+
+ )}
{/* 내용 */}
-
+
{element.type === "chart" ? (
- ) : renderWidget(element)}
+ ) : (
+ renderWidget(element)
+ )}
{/* 로딩 오버레이 */}
@@ -316,87 +353,3 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
);
}
-
-/**
- * 샘플 쿼리 결과 생성 함수 (뷰어용)
- */
-function generateSampleQueryResult(query: string, chartType: string): QueryResult {
- // 시간에 따라 약간씩 다른 데이터 생성 (실시간 업데이트 시뮬레이션)
- const timeVariation = Math.sin(Date.now() / 10000) * 0.1 + 1;
-
- 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("상품");
- const isWeekly = query.toLowerCase().includes("week");
-
- let columns: string[];
- let rows: Record
[];
-
- if (isMonthly && isSales) {
- columns = ["month", "sales", "order_count"];
- rows = [
- { month: "2024-01", sales: Math.round(1200000 * timeVariation), order_count: Math.round(45 * timeVariation) },
- { month: "2024-02", sales: Math.round(1350000 * timeVariation), order_count: Math.round(52 * timeVariation) },
- { month: "2024-03", sales: Math.round(1180000 * timeVariation), order_count: Math.round(41 * timeVariation) },
- { month: "2024-04", sales: Math.round(1420000 * timeVariation), order_count: Math.round(58 * timeVariation) },
- { month: "2024-05", sales: Math.round(1680000 * timeVariation), order_count: Math.round(67 * timeVariation) },
- { month: "2024-06", sales: Math.round(1540000 * timeVariation), order_count: Math.round(61 * timeVariation) },
- ];
- } else if (isWeekly && isUsers) {
- columns = ["week", "new_users"];
- rows = [
- { week: "2024-W10", new_users: Math.round(23 * timeVariation) },
- { week: "2024-W11", new_users: Math.round(31 * timeVariation) },
- { week: "2024-W12", new_users: Math.round(28 * timeVariation) },
- { week: "2024-W13", new_users: Math.round(35 * timeVariation) },
- { week: "2024-W14", new_users: Math.round(42 * timeVariation) },
- { week: "2024-W15", new_users: Math.round(38 * timeVariation) },
- ];
- } else if (isProducts) {
- columns = ["product_name", "total_sold", "revenue"];
- rows = [
- {
- product_name: "스마트폰",
- total_sold: Math.round(156 * timeVariation),
- revenue: Math.round(234000000 * timeVariation),
- },
- {
- product_name: "노트북",
- total_sold: Math.round(89 * timeVariation),
- revenue: Math.round(178000000 * timeVariation),
- },
- {
- product_name: "태블릿",
- total_sold: Math.round(134 * timeVariation),
- revenue: Math.round(67000000 * timeVariation),
- },
- {
- product_name: "이어폰",
- total_sold: Math.round(267 * timeVariation),
- revenue: Math.round(26700000 * timeVariation),
- },
- {
- product_name: "스마트워치",
- total_sold: Math.round(98 * timeVariation),
- revenue: Math.round(49000000 * timeVariation),
- },
- ];
- } else {
- columns = ["category", "value", "count"];
- rows = [
- { category: "A", value: Math.round(100 * timeVariation), count: Math.round(10 * timeVariation) },
- { category: "B", value: Math.round(150 * timeVariation), count: Math.round(15 * timeVariation) },
- { category: "C", value: Math.round(120 * timeVariation), count: Math.round(12 * timeVariation) },
- { category: "D", value: Math.round(180 * timeVariation), count: Math.round(18 * timeVariation) },
- { category: "E", value: Math.round(90 * timeVariation), count: Math.round(9 * timeVariation) },
- ];
- }
-
- return {
- columns,
- rows,
- totalRows: rows.length,
- executionTime: Math.floor(Math.random() * 100) + 50,
- };
-}
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({
- {/* 통화 선택 */}
-
+ {/* 통화 선택 - 반응형 (좁을 때 세로 배치) */}
+