diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 0d7e0e15..6660bc13 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -55,6 +55,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 +import warehouseRoutes from "./routes/warehouseRoutes"; // 창고 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -204,6 +205,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 +app.use("/api/warehouse", warehouseRoutes); // 창고 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -249,7 +251,9 @@ app.listen(PORT, HOST, async () => { // 리스크/알림 자동 갱신 시작 try { - const { RiskAlertCacheService } = await import('./services/riskAlertCacheService'); + const { RiskAlertCacheService } = await import( + "./services/riskAlertCacheService" + ); const cacheService = RiskAlertCacheService.getInstance(); cacheService.startAutoRefresh(); logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`); diff --git a/backend-node/src/controllers/WarehouseController.ts b/backend-node/src/controllers/WarehouseController.ts new file mode 100644 index 00000000..1fe140e8 --- /dev/null +++ b/backend-node/src/controllers/WarehouseController.ts @@ -0,0 +1,97 @@ +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, + }); + } + }; +} diff --git a/backend-node/src/routes/warehouseRoutes.ts b/backend-node/src/routes/warehouseRoutes.ts new file mode 100644 index 00000000..15625a35 --- /dev/null +++ b/backend-node/src/routes/warehouseRoutes.ts @@ -0,0 +1,22 @@ +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; diff --git a/backend-node/src/services/WarehouseService.ts b/backend-node/src/services/WarehouseService.ts new file mode 100644 index 00000000..fe0433c7 --- /dev/null +++ b/backend-node/src/services/WarehouseService.ts @@ -0,0 +1,170 @@ +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, row: any) => { + acc[row.status] = parseInt(row.count); + return acc; + }, + {} as Record + ); + + return { + ...result[0], + materialsByStatus: statusCounts, + }; + } catch (error) { + throw error; + } + } +} diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index cde559ee..d1ca6125 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -15,7 +15,18 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Plus, Search, MoreVertical, Edit, Trash2, Copy, Eye } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react"; /** * 대시보드 관리 페이지 @@ -29,6 +40,12 @@ export default function DashboardListPage() { const [searchTerm, setSearchTerm] = useState(""); const [error, setError] = useState(null); + // 모달 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); + const [successDialogOpen, setSuccessDialogOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); + // 대시보드 목록 로드 const loadDashboards = async () => { try { @@ -48,38 +65,51 @@ export default function DashboardListPage() { loadDashboards(); }, [searchTerm]); - // 대시보드 삭제 - const handleDelete = async (id: string, title: string) => { - if (!confirm(`"${title}" 대시보드를 삭제하시겠습니까?`)) { - return; - } + // 대시보드 삭제 확인 모달 열기 + const handleDeleteClick = (id: string, title: string) => { + setDeleteTarget({ id, title }); + setDeleteDialogOpen(true); + }; + + // 대시보드 삭제 실행 + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; try { - await dashboardApi.deleteDashboard(id); - alert("대시보드가 삭제되었습니다."); + await dashboardApi.deleteDashboard(deleteTarget.id); + setDeleteDialogOpen(false); + setDeleteTarget(null); + setSuccessMessage("대시보드가 삭제되었습니다."); + setSuccessDialogOpen(true); loadDashboards(); } catch (err) { console.error("Failed to delete dashboard:", err); - alert("대시보드 삭제에 실패했습니다."); + setDeleteDialogOpen(false); + setError("대시보드 삭제에 실패했습니다."); } }; // 대시보드 복사 const handleCopy = async (dashboard: Dashboard) => { try { + // 전체 대시보드 정보(요소 포함)를 가져오기 + const fullDashboard = await dashboardApi.getDashboard(dashboard.id); + const newDashboard = await dashboardApi.createDashboard({ - title: `${dashboard.title} (복사본)`, - description: dashboard.description, - elements: dashboard.elements || [], + title: `${fullDashboard.title} (복사본)`, + description: fullDashboard.description, + elements: fullDashboard.elements || [], isPublic: false, - tags: dashboard.tags, - category: dashboard.category, + tags: fullDashboard.tags, + category: fullDashboard.category, + settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사 }); - alert("대시보드가 복사되었습니다."); + setSuccessMessage("대시보드가 복사되었습니다."); + setSuccessDialogOpen(true); loadDashboards(); } catch (err) { console.error("Failed to copy dashboard:", err); - alert("대시보드 복사에 실패했습니다."); + setError("대시보드 복사에 실패했습니다."); } }; @@ -178,10 +208,6 @@ export default function DashboardListPage() { - router.push(`/dashboard/${dashboard.id}`)} className="gap-2"> - - 보기 - router.push(`/admin/dashboard/edit/${dashboard.id}`)} className="gap-2" @@ -194,7 +220,7 @@ export default function DashboardListPage() { 복사 handleDelete(dashboard.id, dashboard.title)} + onClick={() => handleDeleteClick(dashboard.id, dashboard.title)} className="gap-2 text-red-600 focus:text-red-600" > @@ -210,6 +236,41 @@ export default function DashboardListPage() { )} + + {/* 삭제 확인 모달 */} + + + + 대시보드 삭제 + + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + {/* 성공 모달 */} + + + +
+ +
+ 완료 + {successMessage} +
+
+ +
+
+
); } diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 0705d77b..cb6defd6 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -27,6 +27,10 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { elements: DashboardElement[]; createdAt: string; updatedAt: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -101,63 +105,8 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { return (
- {/* 대시보드 헤더 */} -
-
-
-

{dashboard.title}

- {dashboard.description &&

{dashboard.description}

} -
- -
- {/* 새로고침 버튼 */} - - - {/* 전체화면 버튼 */} - - - {/* 편집 버튼 */} - -
-
- - {/* 메타 정보 */} -
- 생성: {new Date(dashboard.createdAt).toLocaleString()} - 수정: {new Date(dashboard.updatedAt).toLocaleString()} - 요소: {dashboard.elements.length}개 -
-
- - {/* 대시보드 뷰어 */} -
- -
+ {/* 대시보드 뷰어 - 전체 화면 */} +
); } diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index f8d80592..ad9edfc4 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -47,12 +47,12 @@ export const MenuFormModal: React.FC = ({ uiTexts, }) => { // console.log("🎯 MenuFormModal 렌더링 - Props:", { - // isOpen, - // menuId, - // parentId, - // menuType, - // level, - // parentCompanyCode, + // isOpen, + // menuId, + // parentId, + // menuType, + // level, + // parentCompanyCode, // }); // 다국어 텍스트 가져오기 함수 @@ -75,12 +75,18 @@ export const MenuFormModal: React.FC = ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen">("screen"); // URL 직접 입력 or 화면 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // 대시보드 할당 관련 상태 + const [selectedDashboard, setSelectedDashboard] = useState(null); + const [dashboards, setDashboards] = useState([]); + const [dashboardSearchText, setDashboardSearchText] = useState(""); + const [isDashboardDropdownOpen, setIsDashboardDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); const [isEdit, setIsEdit] = useState(false); const [companies, setCompanies] = useState([]); @@ -93,21 +99,6 @@ export const MenuFormModal: React.FC = ({ try { const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기 - // console.log("🔍 화면 목록 로드 디버깅:", { - // totalScreens: response.data.length, - // firstScreen: response.data[0], - // firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [], - // firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [], - // allScreenIds: response.data - // .map((s) => ({ - // screenId: s.screenId, - // legacyId: s.id, - // name: s.screenName, - // code: s.screenCode, - // })) - // .slice(0, 5), // 처음 5개만 출력 - // }); - setScreens(response.data); console.log("✅ 화면 목록 로드 완료:", response.data.length); } catch (error) { @@ -116,15 +107,28 @@ export const MenuFormModal: React.FC = ({ } }; + // 대시보드 목록 로드 + const loadDashboards = async () => { + try { + const { dashboardApi } = await import("@/lib/api/dashboard"); + const response = await dashboardApi.getMyDashboards(); + setDashboards(response.dashboards || []); + console.log("✅ 대시보드 목록 로드 완료:", response.dashboards?.length || 0); + } catch (error) { + console.error("❌ 대시보드 목록 로드 실패:", error); + toast.error("대시보드 목록을 불러오는데 실패했습니다."); + } + }; + // 화면 선택 시 URL 자동 설정 const handleScreenSelect = (screen: ScreenDefinition) => { // console.log("🖥️ 화면 선택 디버깅:", { - // screen, - // screenId: screen.screenId, - // screenIdType: typeof screen.screenId, - // legacyId: screen.id, - // allFields: Object.keys(screen), - // screenValues: Object.values(screen), + // screen, + // screenId: screen.screenId, + // screenIdType: typeof screen.screenId, + // legacyId: screen.id, + // allFields: Object.keys(screen), + // screenValues: Object.values(screen), // }); // ScreenDefinition에서는 screenId 필드를 사용 @@ -155,24 +159,42 @@ export const MenuFormModal: React.FC = ({ })); // console.log("🖥️ 화면 선택 완료:", { - // screenId: screen.screenId, - // legacyId: screen.id, - // actualScreenId, - // screenName: screen.screenName, - // menuType: menuType, - // formDataMenuType: formData.menuType, - // isAdminMenu, - // generatedUrl: screenUrl, + // screenId: screen.screenId, + // legacyId: screen.id, + // actualScreenId, + // screenName: screen.screenName, + // menuType: menuType, + // formDataMenuType: formData.menuType, + // isAdminMenu, + // generatedUrl: screenUrl, // }); }; + // 대시보드 선택 시 URL 자동 설정 + const handleDashboardSelect = (dashboard: any) => { + setSelectedDashboard(dashboard); + setIsDashboardDropdownOpen(false); + + // 대시보드 URL 생성 + let dashboardUrl = `/dashboard/${dashboard.id}`; + + // 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin") + const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; + if (isAdminMenu) { + dashboardUrl += "?mode=admin"; + } + + setFormData((prev) => ({ ...prev, menuUrl: dashboardUrl })); + toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`); + }; + // URL 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { // console.log("🔄 URL 타입 변경:", { - // from: urlType, - // to: type, - // currentSelectedScreen: selectedScreen?.screenName, - // currentUrl: formData.menuUrl, + // from: urlType, + // to: type, + // currentSelectedScreen: selectedScreen?.screenName, + // currentUrl: formData.menuUrl, // }); setUrlType(type); @@ -286,10 +308,10 @@ export const MenuFormModal: React.FC = ({ const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1]; if (screenId) { // console.log("🔍 기존 메뉴에서 화면 ID 추출:", { - // menuUrl, - // screenId, - // hasAdminParam: menuUrl.includes("mode=admin"), - // currentScreensCount: screens.length, + // menuUrl, + // screenId, + // hasAdminParam: menuUrl.includes("mode=admin"), + // currentScreensCount: screens.length, // }); // 화면 설정 함수 @@ -298,15 +320,15 @@ export const MenuFormModal: React.FC = ({ if (screen) { setSelectedScreen(screen); // console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", { - // screen, - // originalUrl: menuUrl, - // hasAdminParam: menuUrl.includes("mode=admin"), + // screen, + // originalUrl: menuUrl, + // hasAdminParam: menuUrl.includes("mode=admin"), // }); return true; } else { // console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", { - // screenId, - // availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })), + // screenId, + // availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })), // }); return false; } @@ -325,30 +347,34 @@ export const MenuFormModal: React.FC = ({ }, 500); } } + } else if (menuUrl.startsWith("/dashboard/")) { + setUrlType("dashboard"); + setSelectedScreen(null); + // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); } // console.log("설정된 폼 데이터:", { - // objid: menu.objid || menu.OBJID, - // parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", - // menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", - // menuUrl: menu.menu_url || menu.MENU_URL || "", - // menuDesc: menu.menu_desc || menu.MENU_DESC || "", - // seq: menu.seq || menu.SEQ || 1, - // menuType: convertedMenuType, - // status: convertedStatus, - // companyCode: companyCode, - // langKey: langKey, + // objid: menu.objid || menu.OBJID, + // parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", + // menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", + // menuUrl: menu.menu_url || menu.MENU_URL || "", + // menuDesc: menu.menu_desc || menu.MENU_DESC || "", + // seq: menu.seq || menu.SEQ || 1, + // menuType: convertedMenuType, + // status: convertedStatus, + // companyCode: companyCode, + // langKey: langKey, // }); } } catch (error: any) { console.error("메뉴 정보 로딩 오류:", error); // console.error("오류 상세 정보:", { - // message: error?.message, - // stack: error?.stack, - // response: error?.response, + // message: error?.message, + // stack: error?.stack, + // response: error?.response, // }); toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO)); } finally { @@ -391,11 +417,11 @@ export const MenuFormModal: React.FC = ({ }); // console.log("메뉴 등록 기본값 설정:", { - // parentObjId: parentId || "0", - // menuType: defaultMenuType, - // status: "ACTIVE", - // companyCode: "", - // langKey: "", + // parentObjId: parentId || "0", + // menuType: defaultMenuType, + // status: "ACTIVE", + // companyCode: "", + // langKey: "", // }); } }, [menuId, parentId, menuType]); @@ -430,10 +456,11 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); - // 화면 목록 로드 + // 화면 목록 및 대시보드 목록 로드 useEffect(() => { if (isOpen) { loadScreens(); + loadDashboards(); } }, [isOpen]); @@ -449,9 +476,9 @@ export const MenuFormModal: React.FC = ({ if (screen) { setSelectedScreen(screen); // console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", { - // screenId, - // screenName: screen.screenName, - // menuUrl, + // screenId, + // screenName: screen.screenName, + // menuUrl, // }); } } @@ -459,6 +486,23 @@ export const MenuFormModal: React.FC = ({ } }, [screens, isEdit, formData.menuUrl, urlType, selectedScreen]); + // 대시보드 목록 로드 완료 후 기존 메뉴의 할당된 대시보드 설정 + useEffect(() => { + if (dashboards.length > 0 && isEdit && formData.menuUrl && urlType === "dashboard") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/dashboard/")) { + const dashboardId = menuUrl.replace("/dashboard/", ""); + if (dashboardId && !selectedDashboard) { + console.log("🔄 대시보드 목록 로드 완료 - 기존 할당 대시보드 자동 설정"); + const dashboard = dashboards.find((d) => d.id === dashboardId); + if (dashboard) { + setSelectedDashboard(dashboard); + } + } + } + } + }, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -471,9 +515,13 @@ export const MenuFormModal: React.FC = ({ setIsScreenDropdownOpen(false); setScreenSearchText(""); } + if (!target.closest(".dashboard-dropdown")) { + setIsDashboardDropdownOpen(false); + setDashboardSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } @@ -751,6 +799,12 @@ export const MenuFormModal: React.FC = ({ 화면 할당 +
+ + +
); } @@ -497,6 +597,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "🚚 기사 관리 위젯"; case "list": return "📋 리스트 위젯"; + case "warehouse-3d": + return "🏭 창고 현황 (3D)"; default: return "🔧 위젯"; } @@ -537,6 +639,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "driver-management"; case "list": return "list-widget"; + case "warehouse-3d": + return "warehouse-3d"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardSaveModal.tsx b/frontend/components/admin/dashboard/DashboardSaveModal.tsx new file mode 100644 index 00000000..34837384 --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardSaveModal.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from "@/components/ui/select"; +import { Loader2, Save } from "lucide-react"; +import { menuApi } from "@/lib/api/menu"; + +interface MenuItem { + id: string; + name: string; + url?: string; + parent_id?: string; + children?: MenuItem[]; +} + +interface DashboardSaveModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: { + title: string; + description: string; + assignToMenu: boolean; + menuType?: "admin" | "user"; + menuId?: string; + }) => Promise; + initialTitle?: string; + initialDescription?: string; + isEditing?: boolean; +} + +export function DashboardSaveModal({ + isOpen, + onClose, + onSave, + initialTitle = "", + initialDescription = "", + isEditing = false, +}: DashboardSaveModalProps) { + const [title, setTitle] = useState(initialTitle); + const [description, setDescription] = useState(initialDescription); + const [assignToMenu, setAssignToMenu] = useState(false); + const [menuType, setMenuType] = useState<"admin" | "user">("admin"); + const [selectedMenuId, setSelectedMenuId] = useState(""); + const [adminMenus, setAdminMenus] = useState([]); + const [userMenus, setUserMenus] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMenus, setLoadingMenus] = useState(false); + + useEffect(() => { + if (isOpen) { + setTitle(initialTitle); + setDescription(initialDescription); + setAssignToMenu(false); + setMenuType("admin"); + setSelectedMenuId(""); + loadMenus(); + } + }, [isOpen, initialTitle, initialDescription]); + + const loadMenus = async () => { + setLoadingMenus(true); + try { + const [adminData, userData] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]); + + // API 응답이 배열인지 확인하고 처리 + const adminMenuList = Array.isArray(adminData) ? adminData : adminData?.data || []; + const userMenuList = Array.isArray(userData) ? userData : userData?.data || []; + + setAdminMenus(adminMenuList); + setUserMenus(userMenuList); + } catch (error) { + console.error("메뉴 목록 로드 실패:", error); + setAdminMenus([]); + setUserMenus([]); + } finally { + setLoadingMenus(false); + } + }; + + const flattenMenus = ( + menus: MenuItem[], + prefix = "", + parentPath = "", + ): { id: string; label: string; uniqueKey: string }[] => { + if (!Array.isArray(menus)) { + return []; + } + + const result: { id: string; label: string; uniqueKey: string }[] = []; + menus.forEach((menu, index) => { + // 메뉴 ID 추출 (objid 또는 id) + const menuId = (menu as any).objid || menu.id || ""; + const uniqueKey = `${parentPath}-${menuId}-${index}`; + + // 메뉴 이름 추출 + const menuName = + menu.name || + (menu as any).menu_name_kor || + (menu as any).MENU_NAME_KOR || + (menu as any).menuNameKor || + (menu as any).title || + "이름없음"; + + // lev 필드로 레벨 확인 (lev > 1인 메뉴만 추가) + const menuLevel = (menu as any).lev || 0; + + if (menuLevel > 1) { + result.push({ + id: menuId, + label: prefix + menuName, + uniqueKey, + }); + } + + // 하위 메뉴가 있으면 재귀 호출 + if (menu.children && Array.isArray(menu.children) && menu.children.length > 0) { + result.push(...flattenMenus(menu.children, prefix + menuName + " > ", uniqueKey)); + } + }); + + return result; + }; + + const handleSave = async () => { + if (!title.trim()) { + alert("대시보드 이름을 입력해주세요."); + return; + } + + if (assignToMenu && !selectedMenuId) { + alert("메뉴를 선택해주세요."); + return; + } + + setLoading(true); + try { + await onSave({ + title: title.trim(), + description: description.trim(), + assignToMenu, + menuType: assignToMenu ? menuType : undefined, + menuId: assignToMenu ? selectedMenuId : undefined, + }); + onClose(); + } catch (error) { + console.error("저장 실패:", error); + } finally { + setLoading(false); + } + }; + + const currentMenus = menuType === "admin" ? adminMenus : userMenus; + const flatMenus = flattenMenus(currentMenus); + + return ( + + + + {isEditing ? "대시보드 수정" : "대시보드 저장"} + + +
+ {/* 대시보드 이름 */} +
+ + setTitle(e.target.value)} + placeholder="예: 생산 현황 대시보드" + className="w-full" + /> +
+ + {/* 대시보드 설명 */} +
+ +