창고 3d 위젯 기능 삭제
This commit is contained in:
parent
d948aa3d3d
commit
3b57d4acda
|
|
@ -55,7 +55,6 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
|||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import warehouseRoutes from "./routes/warehouseRoutes"; // 창고 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -205,7 +204,6 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
|||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/warehouse", warehouseRoutes); // 창고 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
@ -235,7 +233,7 @@ app.listen(PORT, HOST, async () => {
|
|||
|
||||
// 대시보드 마이그레이션 실행
|
||||
try {
|
||||
const { runDashboardMigration } = await import('./database/runMigration');
|
||||
const { runDashboardMigration } = await import("./database/runMigration");
|
||||
await runDashboardMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 대시보드 마이그레이션 실패:`, error);
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import { WarehouseService } from "../services/WarehouseService";
|
||||
|
||||
export class WarehouseController {
|
||||
private warehouseService: WarehouseService;
|
||||
|
||||
constructor() {
|
||||
this.warehouseService = new WarehouseService();
|
||||
}
|
||||
|
||||
// 창고 및 자재 데이터 조회
|
||||
getWarehouseData = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await this.warehouseService.getWarehouseData();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
warehouses: data.warehouses,
|
||||
materials: data.materials,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("창고 데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 데이터를 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 창고 정보 조회
|
||||
getWarehouseById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const warehouse = await this.warehouseService.getWarehouseById(id);
|
||||
|
||||
if (!warehouse) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "창고를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: warehouse,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("창고 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 정보를 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 창고별 자재 목록 조회
|
||||
getMaterialsByWarehouse = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { warehouseId } = req.params;
|
||||
const materials =
|
||||
await this.warehouseService.getMaterialsByWarehouse(warehouseId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: materials,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("자재 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 목록을 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 창고 통계 조회
|
||||
getWarehouseStats = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await this.warehouseService.getWarehouseStats();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("창고 통계 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 통계를 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { WarehouseController } from "../controllers/WarehouseController";
|
||||
|
||||
const router = Router();
|
||||
const warehouseController = new WarehouseController();
|
||||
|
||||
// 창고 및 자재 데이터 조회
|
||||
router.get("/data", warehouseController.getWarehouseData);
|
||||
|
||||
// 특정 창고 정보 조회
|
||||
router.get("/:id", warehouseController.getWarehouseById);
|
||||
|
||||
// 창고별 자재 목록 조회
|
||||
router.get(
|
||||
"/:warehouseId/materials",
|
||||
warehouseController.getMaterialsByWarehouse
|
||||
);
|
||||
|
||||
// 창고 통계 조회
|
||||
router.get("/stats/summary", warehouseController.getWarehouseStats);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import pool from "../database/db";
|
||||
|
||||
export class WarehouseService {
|
||||
// 창고 및 자재 데이터 조회
|
||||
async getWarehouseData() {
|
||||
try {
|
||||
// 창고 목록 조회
|
||||
const warehousesResult = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
capacity,
|
||||
current_usage,
|
||||
status,
|
||||
description,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM warehouse
|
||||
WHERE status = 'active'
|
||||
ORDER BY id
|
||||
`);
|
||||
|
||||
// 자재 목록 조회
|
||||
const materialsResult = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
warehouse_id,
|
||||
name,
|
||||
material_code,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
status,
|
||||
last_updated,
|
||||
created_at
|
||||
FROM warehouse_material
|
||||
ORDER BY warehouse_id, id
|
||||
`);
|
||||
|
||||
return {
|
||||
warehouses: warehousesResult,
|
||||
materials: materialsResult,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 창고 정보 조회
|
||||
async getWarehouseById(id: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
capacity,
|
||||
current_usage,
|
||||
status,
|
||||
description,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM warehouse
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 창고별 자재 목록 조회
|
||||
async getMaterialsByWarehouse(warehouseId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
warehouse_id,
|
||||
name,
|
||||
material_code,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
status,
|
||||
last_updated,
|
||||
created_at
|
||||
FROM warehouse_material
|
||||
WHERE warehouse_id = $1
|
||||
ORDER BY id
|
||||
`,
|
||||
[warehouseId]
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 통계 조회
|
||||
async getWarehouseStats() {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT w.id) as total_warehouses,
|
||||
COUNT(m.id) as total_materials,
|
||||
SUM(w.capacity) as total_capacity,
|
||||
SUM(w.current_usage) as total_usage,
|
||||
ROUND(AVG((w.current_usage::numeric / NULLIF(w.capacity, 0)) * 100), 2) as avg_usage_percent
|
||||
FROM warehouse w
|
||||
LEFT JOIN warehouse_material m ON w.id = m.warehouse_id
|
||||
WHERE w.status = 'active'
|
||||
`);
|
||||
|
||||
// 상태별 자재 수
|
||||
const statusResult = await pool.query(`
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as count
|
||||
FROM warehouse_material
|
||||
GROUP BY status
|
||||
`);
|
||||
|
||||
const statusCounts = statusResult.reduce(
|
||||
(acc: Record<string, number>, row: any) => {
|
||||
acc[row.status] = parseInt(row.count);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
return {
|
||||
...result[0],
|
||||
materialsByStatus: statusCounts,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -98,17 +98,6 @@ const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/Docu
|
|||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const Warehouse3DWidget = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({
|
||||
default: mod.Warehouse3DWidget,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
},
|
||||
);
|
||||
|
||||
// 시계 위젯 임포트
|
||||
import { ClockWidget } from "./widgets/ClockWidget";
|
||||
// 달력 위젯 임포트
|
||||
|
|
@ -231,20 +220,16 @@ export function CanvasElement({
|
|||
const subGridSize = Math.floor(cellSize / 3);
|
||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px)
|
||||
|
||||
|
||||
// X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
||||
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
|
||||
const distToGridX = Math.abs(rawX - nearestGridX);
|
||||
const snappedX = distToGridX <= magneticThreshold
|
||||
? nearestGridX
|
||||
: Math.round(rawX / subGridSize) * subGridSize;
|
||||
|
||||
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
|
||||
|
||||
// Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
||||
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
|
||||
const distToGridY = Math.abs(rawY - nearestGridY);
|
||||
const snappedY = distToGridY <= magneticThreshold
|
||||
? nearestGridY
|
||||
: Math.round(rawY / subGridSize) * subGridSize;
|
||||
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
|
||||
|
||||
setTempPosition({ x: snappedX, y: snappedY });
|
||||
} else if (isResizing) {
|
||||
|
|
@ -293,45 +278,49 @@ export function CanvasElement({
|
|||
const subGridSize = Math.floor(cellSize / 3);
|
||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15;
|
||||
|
||||
|
||||
// 위치 스냅
|
||||
const nearestGridX = Math.round(newX / gridSize) * gridSize;
|
||||
const distToGridX = Math.abs(newX - nearestGridX);
|
||||
const snappedX = distToGridX <= magneticThreshold
|
||||
? nearestGridX
|
||||
: Math.round(newX / subGridSize) * subGridSize;
|
||||
|
||||
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize;
|
||||
|
||||
const nearestGridY = Math.round(newY / gridSize) * gridSize;
|
||||
const distToGridY = Math.abs(newY - nearestGridY);
|
||||
const snappedY = distToGridY <= magneticThreshold
|
||||
? nearestGridY
|
||||
: Math.round(newY / subGridSize) * subGridSize;
|
||||
|
||||
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize;
|
||||
|
||||
// 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외)
|
||||
// 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2
|
||||
const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5;
|
||||
|
||||
|
||||
// 가장 가까운 그리드 칸 수 계산
|
||||
const nearestWidthCells = Math.round(newWidth / gridSize);
|
||||
const nearestGridWidth = calculateGridWidth(nearestWidthCells);
|
||||
const distToGridWidth = Math.abs(newWidth - nearestGridWidth);
|
||||
const snappedWidth = distToGridWidth <= magneticThreshold
|
||||
? nearestGridWidth
|
||||
: Math.round(newWidth / subGridSize) * subGridSize;
|
||||
|
||||
const snappedWidth =
|
||||
distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize;
|
||||
|
||||
const nearestHeightCells = Math.round(newHeight / gridSize);
|
||||
const nearestGridHeight = calculateGridWidth(nearestHeightCells);
|
||||
const distToGridHeight = Math.abs(newHeight - nearestGridHeight);
|
||||
const snappedHeight = distToGridHeight <= magneticThreshold
|
||||
? nearestGridHeight
|
||||
: Math.round(newHeight / subGridSize) * subGridSize;
|
||||
const snappedHeight =
|
||||
distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize;
|
||||
|
||||
// 임시 크기/위치 저장 (스냅됨)
|
||||
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
|
||||
setTempSize({ width: snappedWidth, height: snappedHeight });
|
||||
}
|
||||
},
|
||||
[isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth, cellSize],
|
||||
[
|
||||
isDragging,
|
||||
isResizing,
|
||||
dragStart,
|
||||
resizeStart,
|
||||
element.size.width,
|
||||
element.type,
|
||||
element.subtype,
|
||||
canvasWidth,
|
||||
cellSize,
|
||||
],
|
||||
);
|
||||
|
||||
// 마우스 업 처리 (이미 스냅된 위치 사용)
|
||||
|
|
@ -738,11 +727,6 @@ export function CanvasElement({
|
|||
<div className="widget-interactive-area h-full w-full">
|
||||
<DocumentWidget />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "warehouse-3d" ? (
|
||||
// 창고 현황 3D 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<Warehouse3DWidget element={element} />
|
||||
</div>
|
||||
) : (
|
||||
// 기타 위젯 렌더링
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -459,7 +459,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
<DashboardTopMenu
|
||||
onSaveLayout={saveLayout}
|
||||
onClearCanvas={clearCanvas}
|
||||
onViewDashboard={dashboardId ? () => router.push(`/dashboard/${dashboardId}`) : undefined}
|
||||
dashboardTitle={dashboardTitle}
|
||||
onAddElement={addElementFromMenu}
|
||||
resolution={resolution}
|
||||
|
|
@ -469,117 +468,117 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
onBackgroundColorChange={setCanvasBackgroundColor}
|
||||
/>
|
||||
|
||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||
<div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8">
|
||||
<div
|
||||
className="relative shadow-2xl"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||
<div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8">
|
||||
<div
|
||||
className="relative shadow-2xl"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
}}
|
||||
>
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
elements={elements}
|
||||
selectedElement={selectedElement}
|
||||
onCreateElement={createElement}
|
||||
onUpdateElement={updateElement}
|
||||
onRemoveElement={removeElement}
|
||||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
backgroundColor={canvasBackgroundColor}
|
||||
canvasWidth={canvasConfig.width}
|
||||
canvasHeight={dynamicCanvasHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요소 설정 모달 */}
|
||||
{configModalElement && (
|
||||
<>
|
||||
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
||||
<ListWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? (
|
||||
<TodoWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveTodoWidgetConfig}
|
||||
/>
|
||||
) : (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 저장 모달 */}
|
||||
<DashboardSaveModal
|
||||
isOpen={saveModalOpen}
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
initialTitle={dashboardTitle}
|
||||
initialDescription={dashboardDescription}
|
||||
isEditing={!!dashboardId}
|
||||
/>
|
||||
|
||||
{/* 저장 성공 모달 */}
|
||||
<Dialog
|
||||
open={successModalOpen}
|
||||
onOpenChange={() => {
|
||||
setSuccessModalOpen(false);
|
||||
router.push("/admin/dashboard");
|
||||
}}
|
||||
>
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
elements={elements}
|
||||
selectedElement={selectedElement}
|
||||
onCreateElement={createElement}
|
||||
onUpdateElement={updateElement}
|
||||
onRemoveElement={removeElement}
|
||||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
backgroundColor={canvasBackgroundColor}
|
||||
canvasWidth={canvasConfig.width}
|
||||
canvasHeight={dynamicCanvasHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요소 설정 모달 */}
|
||||
{configModalElement && (
|
||||
<>
|
||||
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
||||
<ListWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? (
|
||||
<TodoWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveTodoWidgetConfig}
|
||||
/>
|
||||
) : (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 저장 모달 */}
|
||||
<DashboardSaveModal
|
||||
isOpen={saveModalOpen}
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
initialTitle={dashboardTitle}
|
||||
initialDescription={dashboardDescription}
|
||||
isEditing={!!dashboardId}
|
||||
/>
|
||||
|
||||
{/* 저장 성공 모달 */}
|
||||
<Dialog
|
||||
open={successModalOpen}
|
||||
onOpenChange={() => {
|
||||
setSuccessModalOpen(false);
|
||||
router.push("/admin/dashboard");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSuccessModalOpen(false);
|
||||
router.push("/admin/dashboard");
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSuccessModalOpen(false);
|
||||
router.push("/admin/dashboard");
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 초기화 확인 모달 */}
|
||||
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>캔버스 초기화</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
|
||||
<br />
|
||||
계속하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
|
||||
초기화
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{/* 초기화 확인 모달 */}
|
||||
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>캔버스 초기화</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
|
||||
<br />
|
||||
계속하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
|
||||
초기화
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
);
|
||||
|
|
@ -618,8 +617,6 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "🚚 기사 관리 위젯";
|
||||
case "list":
|
||||
return "📋 리스트 위젯";
|
||||
case "warehouse-3d":
|
||||
return "🏭 창고 현황 (3D)";
|
||||
default:
|
||||
return "🔧 위젯";
|
||||
}
|
||||
|
|
@ -660,8 +657,6 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "driver-management";
|
||||
case "list":
|
||||
return "list-widget";
|
||||
case "warehouse-3d":
|
||||
return "warehouse-3d";
|
||||
default:
|
||||
return "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
|
||||
<SelectItem value="status-summary">커스텀 상태 카드</SelectItem>
|
||||
<SelectItem value="warehouse-3d">창고 현황 (3D)</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>일반 위젯</SelectLabel>
|
||||
|
|
|
|||
|
|
@ -35,8 +35,7 @@ export type ElementSubtype =
|
|||
| "booking-alert"
|
||||
| "maintenance"
|
||||
| "document"
|
||||
| "list"
|
||||
| "warehouse-3d"; // 위젯 타입
|
||||
| "list"; // 위젯 타입
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
|
|
|
|||
|
|
@ -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<THREE.Mesh>(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 (
|
||||
<group position={[warehouse.position_x, warehouse.position_y + warehouse.size_y / 2, warehouse.position_z]}>
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<boxGeometry args={[warehouse.size_x, warehouse.size_y, warehouse.size_z]} />
|
||||
<meshStandardMaterial color={warehouse.color} transparent opacity={0.3} wireframe={false} />
|
||||
</mesh>
|
||||
|
||||
{/* 창고 테두리 */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(warehouse.size_x, warehouse.size_y, warehouse.size_z)]} />
|
||||
<lineBasicMaterial color={isSelected ? "#FFD700" : hovered ? "#FFFFFF" : warehouse.color} linewidth={2} />
|
||||
</lineSegments>
|
||||
|
||||
{/* 창고 이름 라벨 */}
|
||||
<Text position={[0, warehouse.size_y / 2 + 1, 0]} fontSize={1} color="white" anchorX="center" anchorY="middle">
|
||||
{warehouse.name}
|
||||
</Text>
|
||||
|
||||
{/* 사용률 표시 */}
|
||||
<Html position={[0, warehouse.size_y / 2 + 2, 0]} center>
|
||||
<div className="pointer-events-none rounded bg-black/80 px-2 py-1 text-xs text-white">
|
||||
{usagePercentage.toFixed(0)}% 사용중
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// 자재 3D 박스 컴포넌트
|
||||
function MaterialBox({
|
||||
material,
|
||||
onClick,
|
||||
isSelected,
|
||||
}: {
|
||||
material: MaterialData;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(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 (
|
||||
<group position={[material.position_x, material.position_y + material.size_y / 2, material.position_z]}>
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<boxGeometry args={[material.size_x, material.size_y, material.size_z]} />
|
||||
<meshStandardMaterial color={statusColor} metalness={0.5} roughness={0.2} />
|
||||
</mesh>
|
||||
|
||||
{(hovered || isSelected) && (
|
||||
<Html position={[0, material.size_y / 2 + 0.5, 0]} center>
|
||||
<div className="pointer-events-none rounded bg-black/90 px-2 py-1 text-xs text-white shadow-lg">
|
||||
<div className="font-bold">{material.name}</div>
|
||||
<div className="text-gray-300">
|
||||
{material.quantity} {material.unit}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{/* 조명 */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} castShadow />
|
||||
<directionalLight position={[-10, 10, -5]} intensity={0.5} />
|
||||
|
||||
{/* 바닥 그리드 */}
|
||||
<gridHelper args={[100, 50, "#444444", "#222222"]} position={[0, 0, 0]} />
|
||||
|
||||
{/* 창고들 */}
|
||||
{warehouses.map((warehouse) => (
|
||||
<WarehouseBox
|
||||
key={warehouse.id}
|
||||
warehouse={warehouse}
|
||||
onClick={() => {
|
||||
if (selectedWarehouse?.id === warehouse.id) {
|
||||
onSelectWarehouse(null);
|
||||
} else {
|
||||
onSelectWarehouse(warehouse);
|
||||
onSelectMaterial(null);
|
||||
}
|
||||
}}
|
||||
isSelected={selectedWarehouse?.id === warehouse.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 자재들 */}
|
||||
{materials.map((material) => (
|
||||
<MaterialBox
|
||||
key={material.id}
|
||||
material={material}
|
||||
onClick={() => {
|
||||
if (selectedMaterial?.id === material.id) {
|
||||
onSelectMaterial(null);
|
||||
} else {
|
||||
onSelectMaterial(material);
|
||||
}
|
||||
}}
|
||||
isSelected={selectedMaterial?.id === material.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 카메라 컨트롤 */}
|
||||
<OrbitControls enableDamping dampingFactor={0.05} minDistance={10} maxDistance={100} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) {
|
||||
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<WarehouseData | null>(null);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<MaterialData | null>(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 (
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`flex h-full flex-col ${isFullscreen ? "fixed inset-0 z-50" : ""}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-bold">🏭 창고 현황 (3D)</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">
|
||||
{warehouses.length}개 창고 | {materials.length}개 자재
|
||||
</Badge>
|
||||
<button onClick={() => setIsFullscreen(!isFullscreen)} className="text-gray-500 hover:text-gray-700">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 gap-4 p-4">
|
||||
{/* 3D 뷰 */}
|
||||
<div className="flex-1 rounded-lg bg-gray-900">
|
||||
<Canvas camera={{ position: [30, 20, 30], fov: 50 }}>
|
||||
<Suspense fallback={null}>
|
||||
<Scene
|
||||
warehouses={warehouses}
|
||||
materials={materials}
|
||||
onSelectWarehouse={setSelectedWarehouse}
|
||||
onSelectMaterial={setSelectedMaterial}
|
||||
selectedWarehouse={selectedWarehouse}
|
||||
selectedMaterial={selectedMaterial}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
{/* 정보 패널 */}
|
||||
<div className="w-80 space-y-4 overflow-y-auto">
|
||||
{/* 선택된 창고 정보 */}
|
||||
{selectedWarehouse && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Info className="h-4 w-4" />
|
||||
창고 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">이름:</span> {selectedWarehouse.name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">ID:</span> {selectedWarehouse.id}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">용량:</span> {selectedWarehouse.current_usage} /{" "}
|
||||
{selectedWarehouse.capacity}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">사용률:</span>{" "}
|
||||
{((selectedWarehouse.current_usage / selectedWarehouse.capacity) * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">상태:</span>{" "}
|
||||
<Badge variant={selectedWarehouse.status === "active" ? "default" : "secondary"}>
|
||||
{selectedWarehouse.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedWarehouse.description && (
|
||||
<div>
|
||||
<span className="font-semibold">설명:</span> {selectedWarehouse.description}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 선택된 자재 정보 */}
|
||||
{selectedMaterial && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Info className="h-4 w-4" />
|
||||
자재 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">이름:</span> {selectedMaterial.name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">코드:</span> {selectedMaterial.material_code}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">수량:</span> {selectedMaterial.quantity} {selectedMaterial.unit}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">위치:</span>{" "}
|
||||
{warehouses.find((w) => w.id === selectedMaterial.warehouse_id)?.name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">상태:</span>{" "}
|
||||
<Badge
|
||||
variant={
|
||||
selectedMaterial.status === "urgent"
|
||||
? "destructive"
|
||||
: selectedMaterial.status === "reserved"
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
{selectedMaterial.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 창고 목록 */}
|
||||
{!selectedWarehouse && !selectedMaterial && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">창고 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{warehouses.map((warehouse) => {
|
||||
const warehouseMaterials = materials.filter((m) => m.warehouse_id === warehouse.id);
|
||||
return (
|
||||
<button
|
||||
key={warehouse.id}
|
||||
onClick={() => setSelectedWarehouse(warehouse)}
|
||||
className="w-full rounded-lg border p-2 text-left transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">{warehouse.name}</span>
|
||||
<Badge variant="outline">{warehouseMaterials.length}개</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{((warehouse.current_usage / warehouse.capacity) * 100).toFixed(0)}% 사용중
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue