야드 관리 (3d) 중간 구현 #105
File diff suppressed because it is too large
Load Diff
|
|
@ -55,7 +55,8 @@ 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 yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||
import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -205,7 +206,8 @@ 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/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||
app.use("/api/materials", materialRoutes); // 자재 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
@ -235,7 +237,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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { Request, Response } from "express";
|
||||
import MaterialService from "../services/MaterialService";
|
||||
|
||||
export class MaterialController {
|
||||
// 임시 자재 마스터 목록 조회
|
||||
async getTempMaterials(req: Request, res: Response) {
|
||||
try {
|
||||
const { search, category, page, limit } = req.query;
|
||||
|
||||
const result = await MaterialService.getTempMaterials({
|
||||
search: search as string,
|
||||
category: category as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 20,
|
||||
});
|
||||
|
||||
return res.json({ success: true, ...result });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching temp materials:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 자재 상세 조회
|
||||
async getTempMaterialByCode(req: Request, res: Response) {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const material = await MaterialService.getTempMaterialByCode(code);
|
||||
|
||||
if (!material) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자재를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: material });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching temp material:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 목록 조회
|
||||
async getCategories(req: Request, res: Response) {
|
||||
try {
|
||||
const categories = await MaterialService.getCategories();
|
||||
return res.json({ success: true, data: categories });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching categories:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MaterialController();
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import { Request, Response } from "express";
|
||||
import YardLayoutService from "../services/YardLayoutService";
|
||||
|
||||
export class YardLayoutController {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts(req: Request, res: Response) {
|
||||
try {
|
||||
const layouts = await YardLayoutService.getAllLayouts();
|
||||
res.json({ success: true, data: layouts });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layouts:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.getLayoutById(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const created_by = (req as any).user?.userId || "system";
|
||||
const layout = await YardLayoutService.createLayout({
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error creating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 수정
|
||||
async updateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const layout = await YardLayoutService.updateLayout(parseInt(id), {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.deleteLayout(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "야드 레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드의 모든 배치 자재 조회
|
||||
async getPlacementsByLayoutId(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placements = await YardLayoutService.getPlacementsByLayoutId(
|
||||
parseInt(id)
|
||||
);
|
||||
|
||||
res.json({ success: true, data: placements });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching placements:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 자재 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드에 자재 배치 추가
|
||||
async addMaterialPlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
if (!placementData.external_material_id || !placementData.material_code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "자재 정보가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const placement = await YardLayoutService.addMaterialPlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error adding material placement:", error);
|
||||
|
||||
if (error.code === "23505") {
|
||||
// 유니크 제약 조건 위반
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 배치된 자재입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 배치 추가 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 정보 수정
|
||||
async updatePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
const placement = await YardLayoutService.updatePlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 정보 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 해제
|
||||
async removePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placement = await YardLayoutService.removePlacement(parseInt(id));
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: "배치가 해제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 해제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { placements } = req.body;
|
||||
|
||||
if (!Array.isArray(placements) || placements.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "배치 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
|
||||
parseInt(id),
|
||||
placements
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: updatedPlacements });
|
||||
} catch (error: any) {
|
||||
console.error("Error batch updating placements:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "새 야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const layout = await YardLayoutService.duplicateLayout(
|
||||
parseInt(id),
|
||||
name
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error duplicating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new YardLayoutController();
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import express from "express";
|
||||
import MaterialController from "../controllers/MaterialController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 임시 자재 마스터 관리
|
||||
router.get("/temp", MaterialController.getTempMaterials);
|
||||
router.get("/temp/categories", MaterialController.getCategories);
|
||||
router.get("/temp/:code", MaterialController.getTempMaterialByCode);
|
||||
|
||||
export default router;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import express from "express";
|
||||
import YardLayoutController from "../controllers/YardLayoutController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 야드 레이아웃 관리
|
||||
router.get("/", YardLayoutController.getAllLayouts);
|
||||
router.get("/:id", YardLayoutController.getLayoutById);
|
||||
router.post("/", YardLayoutController.createLayout);
|
||||
router.put("/:id", YardLayoutController.updateLayout);
|
||||
router.delete("/:id", YardLayoutController.deleteLayout);
|
||||
router.post("/:id/duplicate", YardLayoutController.duplicateLayout);
|
||||
|
||||
// 자재 배치 관리
|
||||
router.get("/:id/placements", YardLayoutController.getPlacementsByLayoutId);
|
||||
router.post("/:id/placements", YardLayoutController.addMaterialPlacement);
|
||||
router.put("/:id/placements/batch", YardLayoutController.batchUpdatePlacements);
|
||||
|
||||
// 개별 배치 관리 (별도 경로)
|
||||
router.put("/placements/:id", YardLayoutController.updatePlacement);
|
||||
router.delete("/placements/:id", YardLayoutController.removePlacement);
|
||||
|
||||
export default router;
|
||||
|
|
@ -61,8 +61,9 @@ export class DashboardService {
|
|||
id, dashboard_id, element_type, element_subtype,
|
||||
position_x, position_y, width, height,
|
||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
||||
list_config, yard_config,
|
||||
display_order, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
`,
|
||||
[
|
||||
elementId,
|
||||
|
|
@ -79,6 +80,8 @@ export class DashboardService {
|
|||
element.content || null,
|
||||
JSON.stringify(element.dataSource || {}),
|
||||
JSON.stringify(element.chartConfig || {}),
|
||||
JSON.stringify(element.listConfig || null),
|
||||
JSON.stringify(element.yardConfig || null),
|
||||
i,
|
||||
now,
|
||||
now,
|
||||
|
|
@ -342,6 +345,16 @@ export class DashboardService {
|
|||
content: row.content,
|
||||
dataSource: JSON.parse(row.data_source_config || "{}"),
|
||||
chartConfig: JSON.parse(row.chart_config || "{}"),
|
||||
listConfig: row.list_config
|
||||
? typeof row.list_config === "string"
|
||||
? JSON.parse(row.list_config)
|
||||
: row.list_config
|
||||
: undefined,
|
||||
yardConfig: row.yard_config
|
||||
? typeof row.yard_config === "string"
|
||||
? JSON.parse(row.yard_config)
|
||||
: row.yard_config
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -465,8 +478,9 @@ export class DashboardService {
|
|||
id, dashboard_id, element_type, element_subtype,
|
||||
position_x, position_y, width, height,
|
||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
||||
list_config, yard_config,
|
||||
display_order, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
`,
|
||||
[
|
||||
elementId,
|
||||
|
|
@ -483,6 +497,8 @@ export class DashboardService {
|
|||
element.content || null,
|
||||
JSON.stringify(element.dataSource || {}),
|
||||
JSON.stringify(element.chartConfig || {}),
|
||||
JSON.stringify(element.listConfig || null),
|
||||
JSON.stringify(element.yardConfig || null),
|
||||
i,
|
||||
now,
|
||||
now,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { getPool } from "../database/db";
|
||||
|
||||
export class MaterialService {
|
||||
// 임시 자재 마스터 목록 조회
|
||||
async getTempMaterials(params: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { search, category, page = 1, limit = 20 } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let whereConditions: string[] = ["is_active = true"];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (search) {
|
||||
whereConditions.push(
|
||||
`(material_code ILIKE $${paramIndex} OR material_name ILIKE $${paramIndex})`
|
||||
);
|
||||
queryParams.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereConditions.push(`category = $${paramIndex}`);
|
||||
queryParams.push(category);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 전체 개수 조회
|
||||
const countQuery = `SELECT COUNT(*) as total FROM temp_material_master ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, queryParams);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
// 데이터 조회
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
id,
|
||||
material_code,
|
||||
material_name,
|
||||
category,
|
||||
unit,
|
||||
default_color,
|
||||
description,
|
||||
created_at
|
||||
FROM temp_material_master
|
||||
${whereClause}
|
||||
ORDER BY material_code ASC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
queryParams.push(limit, offset);
|
||||
const dataResult = await pool.query(dataQuery, queryParams);
|
||||
|
||||
return {
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 특정 자재 상세 조회
|
||||
async getTempMaterialByCode(materialCode: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
material_code,
|
||||
material_name,
|
||||
category,
|
||||
unit,
|
||||
default_color,
|
||||
description,
|
||||
created_at
|
||||
FROM temp_material_master
|
||||
WHERE material_code = $1 AND is_active = true
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [materialCode]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 카테고리 목록 조회
|
||||
async getCategories() {
|
||||
const query = `
|
||||
SELECT DISTINCT category
|
||||
FROM temp_material_master
|
||||
WHERE is_active = true AND category IS NOT NULL
|
||||
ORDER BY category ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query);
|
||||
return result.rows.map((row) => row.category);
|
||||
}
|
||||
}
|
||||
|
||||
export default new MaterialService();
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
import { getPool } from "../database/db";
|
||||
|
||||
export class YardLayoutService {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts() {
|
||||
const query = `
|
||||
SELECT
|
||||
yl.id,
|
||||
yl.name,
|
||||
yl.description,
|
||||
yl.created_by,
|
||||
yl.created_at,
|
||||
yl.updated_at,
|
||||
COUNT(ymp.id) as placement_count
|
||||
FROM yard_layout yl
|
||||
LEFT JOIN yard_material_placement ymp ON yl.id = ymp.yard_layout_id
|
||||
GROUP BY yl.id
|
||||
ORDER BY yl.updated_at DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(id: number) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM yard_layout
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
created_by?: string;
|
||||
}) {
|
||||
const query = `
|
||||
INSERT INTO yard_layout (name, description, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
data.name,
|
||||
data.description || null,
|
||||
data.created_by || null,
|
||||
]);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// 야드 레이아웃 수정 (이름, 설명만)
|
||||
async updateLayout(
|
||||
id: number,
|
||||
data: { name?: string; description?: string }
|
||||
) {
|
||||
const query = `
|
||||
UPDATE yard_layout
|
||||
SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
data.name || null,
|
||||
data.description || null,
|
||||
id,
|
||||
]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(id: number) {
|
||||
const query = `DELETE FROM yard_layout WHERE id = $1 RETURNING *`;
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 특정 야드의 모든 배치 자재 조회
|
||||
async getPlacementsByLayoutId(layoutId: number) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
yard_layout_id,
|
||||
external_material_id,
|
||||
material_code,
|
||||
material_name,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
memo,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM yard_material_placement
|
||||
WHERE yard_layout_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [layoutId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// 야드에 자재 배치 추가
|
||||
async addMaterialPlacement(layoutId: number, data: any) {
|
||||
const query = `
|
||||
INSERT INTO yard_material_placement (
|
||||
yard_layout_id,
|
||||
external_material_id,
|
||||
material_code,
|
||||
material_name,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
memo
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
layoutId,
|
||||
data.external_material_id,
|
||||
data.material_code,
|
||||
data.material_name,
|
||||
data.quantity,
|
||||
data.unit,
|
||||
data.position_x || 0,
|
||||
data.position_y || 0,
|
||||
data.position_z || 0,
|
||||
data.size_x || 5,
|
||||
data.size_y || 5,
|
||||
data.size_z || 5,
|
||||
data.color || "#3b82f6",
|
||||
data.memo || null,
|
||||
]);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// 배치 정보 수정 (위치, 크기, 색상, 메모만)
|
||||
async updatePlacement(placementId: number, data: any) {
|
||||
const query = `
|
||||
UPDATE yard_material_placement
|
||||
SET
|
||||
position_x = COALESCE($1, position_x),
|
||||
position_y = COALESCE($2, position_y),
|
||||
position_z = COALESCE($3, position_z),
|
||||
size_x = COALESCE($4, size_x),
|
||||
size_y = COALESCE($5, size_y),
|
||||
size_z = COALESCE($6, size_z),
|
||||
color = COALESCE($7, color),
|
||||
memo = COALESCE($8, memo),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
data.position_x,
|
||||
data.position_y,
|
||||
data.position_z,
|
||||
data.size_x,
|
||||
data.size_y,
|
||||
data.size_z,
|
||||
data.color,
|
||||
data.memo,
|
||||
placementId,
|
||||
]);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 배치 해제 (자재는 삭제되지 않음)
|
||||
async removePlacement(placementId: number) {
|
||||
const query = `DELETE FROM yard_material_placement WHERE id = $1 RETURNING *`;
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [placementId]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(layoutId: number, placements: any[]) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const results = [];
|
||||
for (const placement of placements) {
|
||||
const query = `
|
||||
UPDATE yard_material_placement
|
||||
SET
|
||||
position_x = $1,
|
||||
position_y = $2,
|
||||
position_z = $3,
|
||||
size_x = $4,
|
||||
size_y = $5,
|
||||
size_z = $6,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7 AND yard_layout_id = $8
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await client.query(query, [
|
||||
placement.position_x,
|
||||
placement.position_y,
|
||||
placement.position_z,
|
||||
placement.size_x,
|
||||
placement.size_y,
|
||||
placement.size_z,
|
||||
placement.id,
|
||||
layoutId,
|
||||
]);
|
||||
|
||||
if (result.rows[0]) {
|
||||
results.push(result.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return results;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(id: number, newName: string) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 원본 레이아웃 조회
|
||||
const layoutQuery = `SELECT * FROM yard_layout WHERE id = $1`;
|
||||
const layoutResult = await client.query(layoutQuery, [id]);
|
||||
const originalLayout = layoutResult.rows[0];
|
||||
|
||||
if (!originalLayout) {
|
||||
throw new Error("Layout not found");
|
||||
}
|
||||
|
||||
// 새 레이아웃 생성
|
||||
const newLayoutQuery = `
|
||||
INSERT INTO yard_layout (name, description, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
const newLayoutResult = await client.query(newLayoutQuery, [
|
||||
newName,
|
||||
originalLayout.description,
|
||||
originalLayout.created_by,
|
||||
]);
|
||||
const newLayout = newLayoutResult.rows[0];
|
||||
|
||||
// 배치 자재 복사
|
||||
const placementsQuery = `SELECT * FROM yard_material_placement WHERE yard_layout_id = $1`;
|
||||
const placementsResult = await client.query(placementsQuery, [id]);
|
||||
|
||||
for (const placement of placementsResult.rows) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO yard_material_placement (
|
||||
yard_layout_id, external_material_id, material_code, material_name,
|
||||
quantity, unit, position_x, position_y, position_z,
|
||||
size_x, size_y, size_z, color, memo
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
`,
|
||||
[
|
||||
newLayout.id,
|
||||
placement.external_material_id,
|
||||
placement.material_code,
|
||||
placement.material_name,
|
||||
placement.quantity,
|
||||
placement.unit,
|
||||
placement.position_x,
|
||||
placement.position_y,
|
||||
placement.position_z,
|
||||
placement.size_x,
|
||||
placement.size_y,
|
||||
placement.size_z,
|
||||
placement.color,
|
||||
placement.memo,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return newLayout;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new YardLayoutService();
|
||||
|
|
@ -35,6 +35,16 @@ export interface DashboardElement {
|
|||
title?: string;
|
||||
showLegend?: boolean;
|
||||
};
|
||||
listConfig?: {
|
||||
columns?: any[];
|
||||
pagination?: any;
|
||||
viewMode?: string;
|
||||
cardColumns?: number;
|
||||
};
|
||||
yardConfig?: {
|
||||
layoutId: number;
|
||||
layoutName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
// 달력 위젯 임포트
|
||||
|
|
@ -117,6 +106,12 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
|
|||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||
import { ListWidget } from "./widgets/ListWidget";
|
||||
|
||||
// 야드 관리 3D 위젯
|
||||
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
|
|
@ -231,20 +226,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 +284,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,
|
||||
],
|
||||
);
|
||||
|
||||
// 마우스 업 처리 (이미 스냅된 위치 사용)
|
||||
|
|
@ -356,7 +351,7 @@ export function CanvasElement({
|
|||
if (isResizing && tempPosition && tempSize) {
|
||||
// tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨
|
||||
// 다시 스냅하지 않고 그대로 사용!
|
||||
let finalX = tempPosition.x;
|
||||
const finalX = tempPosition.x;
|
||||
const finalY = tempPosition.y;
|
||||
let finalWidth = tempSize.width;
|
||||
const finalHeight = tempSize.height;
|
||||
|
|
@ -715,6 +710,17 @@ export function CanvasElement({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
|
||||
// 야드 관리 3D 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<YardManagement3DWidget
|
||||
isEditMode={true}
|
||||
config={element.yardConfig}
|
||||
onConfigChange={(newConfig) => {
|
||||
onUpdate(element.id, { yardConfig: newConfig });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||
// To-Do 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
|
|
@ -735,11 +741,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
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 충돌 방지 기능이 포함된 업데이트 핸들러
|
||||
const handleUpdateWithCollisionDetection = useCallback(
|
||||
(id: string, updates: Partial<DashboardElement>) => {
|
||||
// position이나 size가 아닌 다른 속성 업데이트는 충돌 감지 없이 바로 처리
|
||||
if (!updates.position && !updates.size) {
|
||||
onUpdateElement(id, updates);
|
||||
return;
|
||||
}
|
||||
|
||||
// 업데이트할 요소 찾기
|
||||
const elementIndex = elements.findIndex((el) => el.id === id);
|
||||
if (elementIndex === -1) {
|
||||
|
|
@ -58,9 +64,38 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
return;
|
||||
}
|
||||
|
||||
// position이나 size와 다른 속성이 함께 있으면 분리해서 처리
|
||||
const positionSizeUpdates: any = {};
|
||||
const otherUpdates: any = {};
|
||||
|
||||
Object.keys(updates).forEach((key) => {
|
||||
if (key === "position" || key === "size") {
|
||||
positionSizeUpdates[key] = (updates as any)[key];
|
||||
} else {
|
||||
otherUpdates[key] = (updates as any)[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 다른 속성들은 먼저 바로 업데이트
|
||||
if (Object.keys(otherUpdates).length > 0) {
|
||||
onUpdateElement(id, otherUpdates);
|
||||
}
|
||||
|
||||
// position/size가 없으면 여기서 종료
|
||||
if (Object.keys(positionSizeUpdates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 임시로 업데이트된 요소 배열 생성
|
||||
const updatedElements = elements.map((el) =>
|
||||
el.id === id ? { ...el, ...updates, position: updates.position || el.position, size: updates.size || el.size } : el
|
||||
el.id === id
|
||||
? {
|
||||
...el,
|
||||
...positionSizeUpdates,
|
||||
position: positionSizeUpdates.position || el.position,
|
||||
size: positionSizeUpdates.size || el.size,
|
||||
}
|
||||
: el,
|
||||
);
|
||||
|
||||
// 서브 그리드 크기 계산 (cellSize / 3)
|
||||
|
|
@ -85,7 +120,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
}
|
||||
});
|
||||
},
|
||||
[elements, onUpdateElement, cellSize, canvasWidth]
|
||||
[elements, onUpdateElement, cellSize, canvasWidth],
|
||||
);
|
||||
|
||||
// 드래그 오버 처리
|
||||
|
|
@ -124,20 +159,17 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const subGridSize = Math.floor(cellSize / 3);
|
||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15;
|
||||
|
||||
|
||||
// X 좌표 스냅
|
||||
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
|
||||
const distToGridX = Math.abs(rawX - nearestGridX);
|
||||
let snappedX = distToGridX <= magneticThreshold
|
||||
? nearestGridX
|
||||
: Math.round(rawX / subGridSize) * subGridSize;
|
||||
|
||||
let 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;
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
|
||||
const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
|
||||
|
|
|
|||
|
|
@ -345,6 +345,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig,
|
||||
listConfig: el.listConfig,
|
||||
yardConfig: el.yardConfig,
|
||||
}));
|
||||
|
||||
let savedDashboard;
|
||||
|
|
@ -449,7 +450,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}
|
||||
|
|
@ -459,111 +459,111 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
onBackgroundColorChange={setCanvasBackgroundColor}
|
||||
/>
|
||||
|
||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
||||
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
|
||||
<div
|
||||
className="relative shadow-2xl"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
||||
<div className="flex flex-1 items-start justify-center 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}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
|
|
@ -624,8 +624,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "정비 일정 위젯";
|
||||
case "document":
|
||||
return "문서 위젯";
|
||||
case "warehouse-3d":
|
||||
return "창고 현황 (3D)";
|
||||
case "yard-management-3d":
|
||||
return "야드 관리 3D";
|
||||
default:
|
||||
return "위젯";
|
||||
}
|
||||
|
|
@ -666,8 +666,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "driver-management";
|
||||
case "list":
|
||||
return "list-widget";
|
||||
case "warehouse-3d":
|
||||
return "warehouse-3d";
|
||||
case "yard-management-3d":
|
||||
return "yard-3d";
|
||||
default:
|
||||
return "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,11 +181,11 @@ export function DashboardTopMenu({
|
|||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
{/* <SelectItem value="map">지도</SelectItem> */}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export type ElementSubtype =
|
|||
| "maintenance"
|
||||
| "document"
|
||||
| "list"
|
||||
| "warehouse-3d"; // 위젯 타입
|
||||
| "yard-management-3d"; // 야드 관리 3D 위젯
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
|
|
@ -64,6 +64,7 @@ export interface DashboardElement {
|
|||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
||||
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
||||
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
|
||||
}
|
||||
|
||||
export interface DragData {
|
||||
|
|
@ -272,3 +273,9 @@ export interface ListColumn {
|
|||
align?: "left" | "center" | "right"; // 정렬
|
||||
visible?: boolean; // 표시 여부 (기본: true)
|
||||
}
|
||||
|
||||
// 야드 관리 3D 설정
|
||||
export interface YardManagementConfig {
|
||||
layoutId: number; // 선택된 야드 레이아웃 ID
|
||||
layoutName?: string; // 레이아웃 이름 (표시용)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">{element?.customTitle || "창고 현황 (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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<YardLayout[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingLayout, setEditingLayout] = useState<YardLayout | null>(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 (
|
||||
<div className="h-full w-full">
|
||||
<YardEditor layout={editingLayout} onBack={handleEditComplete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 모드: 레이아웃 선택 UI
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">야드 레이아웃 선택</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" />새 야드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-sm text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
) : layouts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm text-gray-600">생성된 야드 레이아웃이 없습니다</div>
|
||||
<div className="mt-1 text-xs text-gray-400">먼저 야드 레이아웃을 생성하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{layouts.map((layout) => (
|
||||
<div
|
||||
key={layout.id}
|
||||
className={`rounded-lg border p-3 transition-all ${
|
||||
config?.layoutId === layout.id ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{layout.name}</span>
|
||||
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-blue-600" />}
|
||||
</div>
|
||||
{layout.description && <p className="mt-1 text-xs text-gray-500">{layout.description}</p>}
|
||||
<div className="mt-2 text-xs text-gray-400">배치된 자재: {layout.placement_count}개</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingLayout(layout);
|
||||
}}
|
||||
>
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 생성 모달 */}
|
||||
<YardLayoutCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreateLayout}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시
|
||||
if (!config?.layoutId) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm font-medium text-gray-600">야드 레이아웃이 설정되지 않았습니다</div>
|
||||
<div className="mt-1 text-xs text-gray-400">대시보드 편집에서 레이아웃을 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 레이아웃의 3D 뷰어 표시
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Yard3DViewer layoutId={config.layoutId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
handleOpen(open);
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>자재 배치 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 자재 정보 */}
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-gray-600">선택한 자재</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded border" style={{ backgroundColor: material.default_color }} />
|
||||
<div>
|
||||
<div className="font-medium">{material.material_name}</div>
|
||||
<div className="text-sm text-gray-600">{material.material_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수량 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">수량</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
min="1"
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{material.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D 위치 */}
|
||||
<div className="space-y-2">
|
||||
<Label>3D 위치</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="posX" className="text-xs text-gray-600">
|
||||
X (좌우)
|
||||
</Label>
|
||||
<Input
|
||||
id="posX"
|
||||
type="number"
|
||||
value={positionX}
|
||||
onChange={(e) => setPositionX(e.target.value)}
|
||||
step="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="posY" className="text-xs text-gray-600">
|
||||
Y (높이)
|
||||
</Label>
|
||||
<Input
|
||||
id="posY"
|
||||
type="number"
|
||||
value={positionY}
|
||||
onChange={(e) => setPositionY(e.target.value)}
|
||||
step="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="posZ" className="text-xs text-gray-600">
|
||||
Z (앞뒤)
|
||||
</Label>
|
||||
<Input
|
||||
id="posZ"
|
||||
type="number"
|
||||
value={positionZ}
|
||||
onChange={(e) => setPositionZ(e.target.value)}
|
||||
step="0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D 크기 */}
|
||||
<div className="space-y-2">
|
||||
<Label>3D 크기</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="sizeX" className="text-xs text-gray-600">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
id="sizeX"
|
||||
type="number"
|
||||
value={sizeX}
|
||||
onChange={(e) => setSizeX(e.target.value)}
|
||||
min="1"
|
||||
step="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sizeY" className="text-xs text-gray-600">
|
||||
높이
|
||||
</Label>
|
||||
<Input
|
||||
id="sizeY"
|
||||
type="number"
|
||||
value={sizeY}
|
||||
onChange={(e) => setSizeY(e.target.value)}
|
||||
min="1"
|
||||
step="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sizeZ" className="text-xs text-gray-600">
|
||||
깊이
|
||||
</Label>
|
||||
<Input
|
||||
id="sizeZ"
|
||||
type="number"
|
||||
value={sizeZ}
|
||||
onChange={(e) => setSizeZ(e.target.value)}
|
||||
min="1"
|
||||
step="0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-10 w-20 cursor-pointer rounded border"
|
||||
/>
|
||||
<Input value={color} onChange={(e) => setColor(e.target.value)} className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isAdding}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleAdd} disabled={isAdding}>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
추가 중...
|
||||
</>
|
||||
) : (
|
||||
"배치"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<YardPlacement>) => void;
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) {
|
||||
const [editData, setEditData] = useState<Partial<YardPlacement>>({});
|
||||
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 (
|
||||
<div className="w-80 border-l bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">자재 정보</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 읽기 전용 정보 */}
|
||||
<div className="space-y-3 rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs font-medium text-gray-500">자재 정보 (읽기 전용)</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">자재 코드</div>
|
||||
<div className="mt-1 text-sm font-medium">{placement.material_code}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">자재 이름</div>
|
||||
<div className="mt-1 text-sm font-medium">{placement.material_name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">수량</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{placement.quantity} {placement.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 정보 (편집 가능) */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-gray-500">배치 정보 (편집 가능)</div>
|
||||
|
||||
{/* 3D 위치 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="edit-posX" className="text-xs text-gray-600">
|
||||
X
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-posX"
|
||||
type="number"
|
||||
value={editData.position_x ?? placement.position_x}
|
||||
onChange={(e) => setEditData({ ...editData, position_x: parseFloat(e.target.value) || 0 })}
|
||||
step="0.5"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-posY" className="text-xs text-gray-600">
|
||||
Y
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-posY"
|
||||
type="number"
|
||||
value={editData.position_y ?? placement.position_y}
|
||||
onChange={(e) => setEditData({ ...editData, position_y: parseFloat(e.target.value) || 0 })}
|
||||
step="0.5"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-posZ" className="text-xs text-gray-600">
|
||||
Z
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-posZ"
|
||||
type="number"
|
||||
value={editData.position_z ?? placement.position_z}
|
||||
onChange={(e) => setEditData({ ...editData, position_z: parseFloat(e.target.value) || 0 })}
|
||||
step="0.5"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">크기</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="edit-sizeX" className="text-xs text-gray-600">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-sizeX"
|
||||
type="number"
|
||||
value={editData.size_x ?? placement.size_x}
|
||||
onChange={(e) => setEditData({ ...editData, size_x: parseFloat(e.target.value) || 1 })}
|
||||
min="1"
|
||||
step="0.5"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-sizeY" className="text-xs text-gray-600">
|
||||
높이
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-sizeY"
|
||||
type="number"
|
||||
value={editData.size_y ?? placement.size_y}
|
||||
onChange={(e) => setEditData({ ...editData, size_y: parseFloat(e.target.value) || 1 })}
|
||||
min="1"
|
||||
step="0.5"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-sizeZ" className="text-xs text-gray-600">
|
||||
깊이
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-sizeZ"
|
||||
type="number"
|
||||
value={editData.size_z ?? placement.size_z}
|
||||
onChange={(e) => setEditData({ ...editData, size_z: parseFloat(e.target.value) || 1 })}
|
||||
min="1"
|
||||
step="0.5"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 */}
|
||||
<div>
|
||||
<Label htmlFor="edit-color" className="text-xs">
|
||||
색상
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="edit-color"
|
||||
type="color"
|
||||
value={editData.color ?? placement.color}
|
||||
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
|
||||
className="h-8 w-16 cursor-pointer rounded border"
|
||||
/>
|
||||
<Input
|
||||
value={editData.color ?? placement.color}
|
||||
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div>
|
||||
<Label htmlFor="edit-memo" className="text-xs">
|
||||
메모
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-memo"
|
||||
value={editData.memo ?? placement.memo ?? ""}
|
||||
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
|
||||
placeholder="메모를 입력하세요"
|
||||
rows={3}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<Button onClick={handleApply} className="w-full" size="sm">
|
||||
변경사항 적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 배치 해제 */}
|
||||
<div className="border-t pt-4">
|
||||
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)} className="w-full" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
배치 해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>배치 해제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말로 이 자재를 배치 해제하시겠습니까?
|
||||
<br />
|
||||
"{placement.material_name}" ({placement.quantity} {placement.unit})
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRemove} className="bg-red-600 hover:bg-red-700">
|
||||
해제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<TempMaterial[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(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 (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>자재 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="자재 코드 또는 이름 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">전체 카테고리</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
{searchText || selectedCategory ? "검색 결과가 없습니다" : "등록된 자재가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">색상</TableHead>
|
||||
<TableHead>자재 코드</TableHead>
|
||||
<TableHead>자재 이름</TableHead>
|
||||
<TableHead>카테고리</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material) => (
|
||||
<TableRow
|
||||
key={material.id}
|
||||
className={`cursor-pointer ${
|
||||
selectedMaterial?.id === material.id ? "bg-blue-50" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setSelectedMaterial(material)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="h-6 w-6 rounded border" style={{ backgroundColor: material.default_color }} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{material.material_code}</TableCell>
|
||||
<TableCell>{material.material_name}</TableCell>
|
||||
<TableCell>{material.category}</TableCell>
|
||||
<TableCell>{material.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 자재 정보 */}
|
||||
{selectedMaterial && (
|
||||
<div className="rounded-lg bg-blue-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-blue-900">선택된 자재</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded border" style={{ backgroundColor: selectedMaterial.default_color }} />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{selectedMaterial.material_name}</div>
|
||||
<div className="text-sm text-gray-600">{selectedMaterial.material_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedMaterial.description && (
|
||||
<div className="mt-2 text-sm text-gray-600">{selectedMaterial.description}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSelectMaterial} disabled={!selectedMaterial}>
|
||||
선택
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<THREE.Mesh>(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 (
|
||||
<Box
|
||||
ref={meshRef}
|
||||
position={[placement.position_x, placement.position_y, placement.position_z]}
|
||||
args={[placement.size_x, placement.size_y, placement.size_z]}
|
||||
onClick={(e) => {
|
||||
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";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
opacity={isSelected ? 1 : 0.8}
|
||||
transparent
|
||||
emissive={isSelected ? "#ffffff" : "#000000"}
|
||||
emissiveIntensity={isSelected ? 0.2 : 0}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 3D 씬 컴포넌트
|
||||
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||
const orbitControlsRef = useRef<any>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 조명 */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
||||
|
||||
{/* 바닥 그리드 */}
|
||||
<Grid
|
||||
args={[100, 100]}
|
||||
cellSize={5}
|
||||
cellThickness={0.5}
|
||||
cellColor="#6b7280"
|
||||
sectionSize={10}
|
||||
sectionThickness={1}
|
||||
sectionColor="#374151"
|
||||
fadeDistance={200}
|
||||
fadeStrength={1}
|
||||
followCamera={false}
|
||||
infiniteGrid={true}
|
||||
/>
|
||||
|
||||
{/* 자재 박스들 */}
|
||||
{placements.map((placement) => (
|
||||
<MaterialBox
|
||||
key={placement.id}
|
||||
placement={placement}
|
||||
isSelected={selectedPlacementId === placement.id}
|
||||
onClick={() => 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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 카메라 컨트롤 */}
|
||||
<OrbitControls
|
||||
ref={orbitControlsRef}
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
minDistance={10}
|
||||
maxDistance={200}
|
||||
maxPolarAngle={Math.PI / 2}
|
||||
enabled={!isDraggingAny}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-full w-full bg-gray-900" onClick={handleCanvasClick}>
|
||||
<Canvas
|
||||
camera={{
|
||||
position: [50, 30, 50],
|
||||
fov: 50,
|
||||
}}
|
||||
shadows
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Scene
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedPlacementId}
|
||||
onPlacementClick={onPlacementClick}
|
||||
onPlacementDrag={onPlacementDrag}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<YardPlacement[]>([]);
|
||||
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
||||
const [layoutName, setLayoutName] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
||||
<div className="mt-2 text-sm text-gray-600">3D 장면 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (placements.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📦</div>
|
||||
<div className="text-sm font-medium text-gray-600">배치된 자재가 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* 3D 캔버스 */}
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedPlacement?.id || null}
|
||||
onPlacementClick={handlePlacementClick}
|
||||
/>
|
||||
|
||||
{/* 야드 이름 (좌측 상단) */}
|
||||
{layoutName && (
|
||||
<div className="absolute top-4 left-4 z-50 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
|
||||
<h2 className="text-base font-bold text-gray-900">{layoutName}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 자재 정보 패널 (우측 상단) */}
|
||||
{selectedPlacement && (
|
||||
<div className="absolute top-4 right-4 z-50 w-64 rounded-lg border border-gray-300 bg-white p-4 shadow-xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">자재 정보</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPlacement(null);
|
||||
}}
|
||||
className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">자재명</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">{selectedPlacement.material_name}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">수량</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">
|
||||
{selectedPlacement.quantity} {selectedPlacement.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: () => (
|
||||
<div className="flex h-full items-center justify-center bg-gray-900">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
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<YardPlacement[]>([]);
|
||||
const [materials, setMaterials] = useState<TempMaterial[]>([]);
|
||||
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(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<YardPlacement>) => {
|
||||
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 (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 상단 툴바 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{layout.name}</h2>
|
||||
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측: 3D 캔버스 */}
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedPlacement?.id || null}
|
||||
onPlacementClick={setSelectedPlacement}
|
||||
onPlacementDrag={handlePlacementDrag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 자재 목록 또는 편집 패널 */}
|
||||
<div className="w-80 border-l bg-white">
|
||||
{selectedPlacement ? (
|
||||
// 선택된 자재 편집 패널
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h3 className="text-sm font-semibold">자재 정보</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPlacement(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{/* 읽기 전용 정보 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">자재 코드</Label>
|
||||
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_code}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">자재명</Label>
|
||||
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_name}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">수량 (변경 불가)</Label>
|
||||
<div className="mt-1 text-sm">
|
||||
{selectedPlacement.quantity} {selectedPlacement.unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 편집 가능 정보 */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<Label className="text-sm font-semibold">배치 정보</Label>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">X</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={selectedPlacement.position_x}
|
||||
onChange={(e) =>
|
||||
handlePlacementUpdate(selectedPlacement.id, {
|
||||
position_x: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Y</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={selectedPlacement.position_y}
|
||||
onChange={(e) =>
|
||||
handlePlacementUpdate(selectedPlacement.id, {
|
||||
position_y: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Z</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={selectedPlacement.position_z}
|
||||
onChange={(e) =>
|
||||
handlePlacementUpdate(selectedPlacement.id, {
|
||||
position_z: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">너비</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={selectedPlacement.size_x}
|
||||
onChange={(e) =>
|
||||
handlePlacementUpdate(selectedPlacement.id, {
|
||||
size_x: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={selectedPlacement.size_y}
|
||||
onChange={(e) =>
|
||||
handlePlacementUpdate(selectedPlacement.id, {
|
||||
size_y: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">깊이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={selectedPlacement.size_z}
|
||||
onChange={(e) =>
|
||||
handlePlacementUpdate(selectedPlacement.id, {
|
||||
size_z: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedPlacement.color}
|
||||
onChange={(e) => handlePlacementUpdate(selectedPlacement.id, { color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handlePlacementRemove(selectedPlacement.id)}
|
||||
>
|
||||
배치 해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 자재 목록
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">자재 목록</h3>
|
||||
<Input
|
||||
placeholder="자재 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredMaterials.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredMaterials.map((material) => {
|
||||
const isPlaced = placements.some((p) => p.material_code === material.material_code);
|
||||
return (
|
||||
<button
|
||||
key={material.id}
|
||||
onClick={() => !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"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 text-sm font-medium text-gray-900">{material.material_name}</div>
|
||||
<div className="text-xs text-gray-500">{material.material_code}</div>
|
||||
<div className="mt-1 text-xs text-gray-400">{material.category}</div>
|
||||
{isPlaced && <div className="mt-1 text-xs text-blue-600">배치됨</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 야드 생성</DialogTitle>
|
||||
<DialogDescription>야드의 이름과 설명을 입력하세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 야드 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yard-name">
|
||||
야드 이름 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="yard-name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="예: A구역, 1번 야드"
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yard-description">설명</Label>
|
||||
<Textarea
|
||||
id="yard-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="야드에 대한 설명을 입력하세요 (선택사항)"
|
||||
rows={3}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
onDuplicate: (id: number, newName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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<YardLayout | null>(null);
|
||||
const [duplicateTarget, setDuplicateTarget] = useState<YardLayout | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 및 정렬 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="야드 이름 또는 설명 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value as "recent" | "name")}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="recent">최근 수정순</option>
|
||||
<option value="name">이름순</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
{sortedLayouts.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
{searchText ? "검색 결과가 없습니다" : "등록된 야드가 없습니다"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>야드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="text-center">배치 자재</TableHead>
|
||||
<TableHead>최종 수정</TableHead>
|
||||
<TableHead className="w-[80px] text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedLayouts.map((layout) => (
|
||||
<TableRow key={layout.id} className="cursor-pointer hover:bg-gray-50" onClick={() => onSelect(layout)}>
|
||||
<TableCell className="font-medium">{layout.name}</TableCell>
|
||||
<TableCell className="text-gray-600">{layout.description || "-"}</TableCell>
|
||||
<TableCell className="text-center">{layout.placement_count}개</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(layout.updated_at)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSelect(layout)}>편집</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDuplicateClick(layout)}>복제</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteTarget(layout)} className="text-red-600">
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 총 개수 */}
|
||||
<div className="text-sm text-gray-500">총 {sortedLayouts.length}개</div>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>야드 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말로 "{deleteTarget?.name}" 야드를 삭제하시겠습니까?
|
||||
<br />
|
||||
배치된 자재 정보도 함께 삭제됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
"삭제"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 복제 모달 */}
|
||||
<Dialog open={!!duplicateTarget} onOpenChange={() => setDuplicateTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>야드 복제</DialogTitle>
|
||||
<DialogDescription>새로운 야드의 이름을 입력하세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duplicate-name">야드 이름</Label>
|
||||
<Input
|
||||
id="duplicate-name"
|
||||
value={duplicateName}
|
||||
onChange={(e) => setDuplicateName(e.target.value)}
|
||||
placeholder="야드 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDuplicateTarget(null)} disabled={isDuplicating}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleDuplicateConfirm} disabled={!duplicateName.trim() || isDuplicating}>
|
||||
{isDuplicating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
복제 중...
|
||||
</>
|
||||
) : (
|
||||
"복제"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,13 +39,9 @@ const ListWidget = dynamic(
|
|||
{ ssr: false },
|
||||
);
|
||||
|
||||
const Warehouse3DWidget = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({
|
||||
default: mod.Warehouse3DWidget,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
||||
|
|
@ -85,8 +81,8 @@ function renderWidget(element: DashboardElement) {
|
|||
case "list":
|
||||
return <ListWidget element={element} />;
|
||||
|
||||
case "warehouse-3d":
|
||||
return <Warehouse3DWidget element={element} />;
|
||||
case "yard-management-3d":
|
||||
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
|
||||
|
||||
// === 차량 관련 (추가 위젯) ===
|
||||
case "vehicle-status":
|
||||
|
|
@ -135,7 +131,13 @@ interface DashboardViewerProps {
|
|||
* - 실시간 데이터 업데이트
|
||||
* - 편집 화면과 동일한 레이아웃 (중앙 정렬, 고정 크기)
|
||||
*/
|
||||
export function DashboardViewer({ elements, dashboardId, refreshInterval, backgroundColor = "#f9fafb", resolution = "fhd" }: DashboardViewerProps) {
|
||||
export function DashboardViewer({
|
||||
elements,
|
||||
dashboardId,
|
||||
refreshInterval,
|
||||
backgroundColor = "#f9fafb",
|
||||
resolution = "fhd",
|
||||
}: DashboardViewerProps) {
|
||||
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
||||
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { apiCall } from "./client";
|
||||
|
||||
// 야드 레이아웃 관리 API
|
||||
export const yardLayoutApi = {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts() {
|
||||
return apiCall("GET", "/yard-layouts");
|
||||
},
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(id: number) {
|
||||
return apiCall("GET", `/yard-layouts/${id}`);
|
||||
},
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(data: { name: string; description?: string }) {
|
||||
return apiCall("POST", "/yard-layouts", data);
|
||||
},
|
||||
|
||||
// 야드 레이아웃 수정
|
||||
async updateLayout(id: number, data: { name?: string; description?: string }) {
|
||||
return apiCall("PUT", `/yard-layouts/${id}`, data);
|
||||
},
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(id: number) {
|
||||
return apiCall("DELETE", `/yard-layouts/${id}`);
|
||||
},
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(id: number, name: string) {
|
||||
return apiCall("POST", `/yard-layouts/${id}/duplicate`, { name });
|
||||
},
|
||||
|
||||
// 특정 야드의 배치 자재 목록 조회
|
||||
async getPlacementsByLayoutId(layoutId: number) {
|
||||
return apiCall("GET", `/yard-layouts/${layoutId}/placements`);
|
||||
},
|
||||
|
||||
// 야드에 자재 배치 추가
|
||||
async addMaterialPlacement(layoutId: number, data: any) {
|
||||
return apiCall("POST", `/yard-layouts/${layoutId}/placements`, data);
|
||||
},
|
||||
|
||||
// 배치 정보 수정
|
||||
async updatePlacement(placementId: number, data: any) {
|
||||
return apiCall("PUT", `/yard-layouts/placements/${placementId}`, data);
|
||||
},
|
||||
|
||||
// 배치 해제
|
||||
async removePlacement(placementId: number) {
|
||||
return apiCall("DELETE", `/yard-layouts/placements/${placementId}`);
|
||||
},
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(layoutId: number, placements: any[]) {
|
||||
return apiCall("PUT", `/yard-layouts/${layoutId}/placements/batch`, { placements });
|
||||
},
|
||||
};
|
||||
|
||||
// 자재 관리 API
|
||||
export const materialApi = {
|
||||
// 임시 자재 마스터 목록 조회
|
||||
async getTempMaterials(params?: { search?: string; category?: string; page?: number; limit?: number }) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.search) queryParams.append("search", params.search);
|
||||
if (params?.category) queryParams.append("category", params.category);
|
||||
if (params?.page) queryParams.append("page", params.page.toString());
|
||||
if (params?.limit) queryParams.append("limit", params.limit.toString());
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
return apiCall("GET", `/materials/temp${queryString ? `?${queryString}` : ""}`);
|
||||
},
|
||||
|
||||
// 특정 자재 상세 조회
|
||||
async getTempMaterialByCode(code: string) {
|
||||
return apiCall("GET", `/materials/temp/${code}`);
|
||||
},
|
||||
|
||||
// 카테고리 목록 조회
|
||||
async getCategories() {
|
||||
return apiCall("GET", "/materials/temp/categories");
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue