diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ae10a6fe..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); @@ -231,6 +233,14 @@ app.listen(PORT, HOST, async () => { logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + // 대시보드 마이그레이션 실행 + try { + const { runDashboardMigration } = await import('./database/runMigration'); + await runDashboardMigration(); + } catch (error) { + logger.error(`❌ 대시보드 마이그레이션 실패:`, error); + } + // 배치 스케줄러 초기화 try { await BatchSchedulerService.initialize(); @@ -241,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/database/runMigration.ts b/backend-node/src/database/runMigration.ts new file mode 100644 index 00000000..61b98241 --- /dev/null +++ b/backend-node/src/database/runMigration.ts @@ -0,0 +1,42 @@ +import { PostgreSQLService } from './PostgreSQLService'; + +/** + * 데이터베이스 마이그레이션 실행 + * dashboard_elements 테이블에 custom_title, show_header 컬럼 추가 + */ +export async function runDashboardMigration() { + try { + console.log('🔄 대시보드 마이그레이션 시작...'); + + // custom_title 컬럼 추가 + await PostgreSQLService.query(` + ALTER TABLE dashboard_elements + ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255) + `); + console.log('✅ custom_title 컬럼 추가 완료'); + + // show_header 컬럼 추가 + await PostgreSQLService.query(` + ALTER TABLE dashboard_elements + ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true + `); + console.log('✅ show_header 컬럼 추가 완료'); + + // 기존 데이터 업데이트 + await PostgreSQLService.query(` + UPDATE dashboard_elements + SET show_header = true + WHERE show_header IS NULL + `); + console.log('✅ 기존 데이터 업데이트 완료'); + + console.log('✅ 대시보드 마이그레이션 완료!'); + } catch (error) { + console.error('❌ 대시보드 마이그레이션 실패:', error); + // 이미 컬럼이 있는 경우는 무시 + if (error instanceof Error && error.message.includes('already exists')) { + console.log('ℹ️ 컬럼이 이미 존재합니다.'); + } + } +} + 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/DashboardService.ts b/backend-node/src/services/DashboardService.ts index fa0ce775..f8816555 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -1,89 +1,100 @@ -import { v4 as uuidv4 } from 'uuid'; -import { PostgreSQLService } from '../database/PostgreSQLService'; -import { - Dashboard, - DashboardElement, - CreateDashboardRequest, +import { v4 as uuidv4 } from "uuid"; +import { PostgreSQLService } from "../database/PostgreSQLService"; +import { + Dashboard, + DashboardElement, + CreateDashboardRequest, UpdateDashboardRequest, - DashboardListQuery -} from '../types/dashboard'; + DashboardListQuery, +} from "../types/dashboard"; /** * 대시보드 서비스 - Raw Query 방식 * PostgreSQL 직접 연결을 통한 CRUD 작업 */ export class DashboardService { - /** * 대시보드 생성 */ - static async createDashboard(data: CreateDashboardRequest, userId: string): Promise { + static async createDashboard( + data: CreateDashboardRequest, + userId: string + ): Promise { const dashboardId = uuidv4(); const now = new Date(); - + try { // 트랜잭션으로 대시보드와 요소들을 함께 생성 const result = await PostgreSQLService.transaction(async (client) => { // 1. 대시보드 메인 정보 저장 - await client.query(` + await client.query( + ` INSERT INTO dashboards ( id, title, description, is_public, created_by, - created_at, updated_at, tags, category, view_count - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - `, [ - dashboardId, - data.title, - data.description || null, - data.isPublic || false, - userId, - now, - now, - JSON.stringify(data.tags || []), - data.category || null, - 0 - ]); - + created_at, updated_at, tags, category, view_count, settings + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, + [ + dashboardId, + data.title, + data.description || null, + data.isPublic || false, + userId, + now, + now, + JSON.stringify(data.tags || []), + data.category || null, + 0, + JSON.stringify(data.settings || {}), + ] + ); + // 2. 대시보드 요소들 저장 if (data.elements && data.elements.length > 0) { for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); // 항상 새로운 UUID 생성 - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, - title, content, data_source_config, chart_config, + title, custom_title, show_header, content, data_source_config, chart_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.customTitle || null, + element.showHeader !== false, // 기본값 true + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 생성된 대시보드 반환 try { const dashboard = await this.getDashboardById(dashboardId, userId); if (!dashboard) { - console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId); + console.error("대시보드 생성은 성공했으나 조회에 실패:", dashboardId); // 생성은 성공했으므로 기본 정보만이라도 반환 return { id: dashboardId, @@ -97,13 +108,13 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - + return dashboard; } catch (fetchError) { - console.error('생성된 대시보드 조회 중 오류:', fetchError); + console.error("생성된 대시보드 조회 중 오류:", fetchError); // 생성은 성공했으므로 기본 정보 반환 return { id: dashboardId, @@ -117,76 +128,79 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - } catch (error) { - console.error('Dashboard creation error:', error); + console.error("Dashboard creation error:", error); throw error; } } - + /** * 대시보드 목록 조회 */ static async getDashboards(query: DashboardListQuery, userId?: string) { - const { - page = 1, - limit = 20, - search, - category, - isPublic, - createdBy + const { + page = 1, + limit = 20, + search, + category, + isPublic, + createdBy, } = query; - + const offset = (page - 1) * limit; - + try { // 기본 WHERE 조건 - let whereConditions = ['d.deleted_at IS NULL']; + let whereConditions = ["d.deleted_at IS NULL"]; let params: any[] = []; let paramIndex = 1; - + // 권한 필터링 if (userId) { - whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`); + whereConditions.push( + `(d.created_by = $${paramIndex} OR d.is_public = true)` + ); params.push(userId); paramIndex++; } else { - whereConditions.push('d.is_public = true'); + whereConditions.push("d.is_public = true"); } - + // 검색 조건 if (search) { - whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`); + whereConditions.push( + `(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})` + ); params.push(`%${search}%`, `%${search}%`); paramIndex += 2; } - + // 카테고리 필터 if (category) { whereConditions.push(`d.category = $${paramIndex}`); params.push(category); paramIndex++; } - + // 공개/비공개 필터 - if (typeof isPublic === 'boolean') { + if (typeof isPublic === "boolean") { whereConditions.push(`d.is_public = $${paramIndex}`); params.push(isPublic); paramIndex++; } - + // 작성자 필터 if (createdBy) { whereConditions.push(`d.created_by = $${paramIndex}`); params.push(createdBy); paramIndex++; } - - const whereClause = whereConditions.join(' AND '); - + + const whereClause = whereConditions.join(" AND "); + // 대시보드 목록 조회 (users 테이블 조인 제거) const dashboardQuery = ` SELECT @@ -211,22 +225,23 @@ export class DashboardService { ORDER BY d.updated_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - - const dashboardResult = await PostgreSQLService.query( - dashboardQuery, - [...params, limit, offset] - ); - + + const dashboardResult = await PostgreSQLService.query(dashboardQuery, [ + ...params, + limit, + offset, + ]); + // 전체 개수 조회 const countQuery = ` SELECT COUNT(DISTINCT d.id) as total FROM dashboards d WHERE ${whereClause} `; - + const countResult = await PostgreSQLService.query(countQuery, params); - const total = parseInt(countResult.rows[0]?.total || '0'); - + const total = parseInt(countResult.rows[0]?.total || "0"); + return { dashboards: dashboardResult.rows.map((row: any) => ({ id: row.id, @@ -237,33 +252,36 @@ export class DashboardService { createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, - tags: JSON.parse(row.tags || '[]'), + tags: JSON.parse(row.tags || "[]"), category: row.category, - viewCount: parseInt(row.view_count || '0'), - elementsCount: parseInt(row.elements_count || '0') + viewCount: parseInt(row.view_count || "0"), + elementsCount: parseInt(row.elements_count || "0"), })), pagination: { page, limit, total, - totalPages: Math.ceil(total / limit) - } + totalPages: Math.ceil(total / limit), + }, }; } catch (error) { - console.error('Dashboard list error:', error); + console.error("Dashboard list error:", error); throw error; } } - + /** * 대시보드 상세 조회 */ - static async getDashboardById(dashboardId: string, userId?: string): Promise { + static async getDashboardById( + dashboardId: string, + userId?: string + ): Promise { try { // 1. 대시보드 기본 정보 조회 (권한 체크 포함) let dashboardQuery: string; let dashboardParams: any[]; - + if (userId) { dashboardQuery = ` SELECT d.* @@ -281,43 +299,52 @@ export class DashboardService { `; dashboardParams = [dashboardId]; } - - const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams); - + + const dashboardResult = await PostgreSQLService.query( + dashboardQuery, + dashboardParams + ); + if (dashboardResult.rows.length === 0) { return null; } - + const dashboard = dashboardResult.rows[0]; - + // 2. 대시보드 요소들 조회 const elementsQuery = ` SELECT * FROM dashboard_elements WHERE dashboard_id = $1 ORDER BY display_order ASC `; - - const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); - + + const elementsResult = await PostgreSQLService.query(elementsQuery, [ + dashboardId, + ]); + // 3. 요소 데이터 변환 - const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({ - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y - }, - size: { - width: row.width, - height: row.height - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || '{}'), - chartConfig: JSON.parse(row.chart_config || '{}') - })); - + const elements: DashboardElement[] = elementsResult.rows.map( + (row: any) => ({ + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y, + }, + size: { + width: row.width, + height: row.height, + }, + title: row.title, + customTitle: row.custom_title || undefined, + showHeader: row.show_header !== false, // 기본값 true + content: row.content, + dataSource: JSON.parse(row.data_source_config || "{}"), + chartConfig: JSON.parse(row.chart_config || "{}"), + }) + ); + return { id: dashboard.id, title: dashboard.title, @@ -327,44 +354,48 @@ export class DashboardService { createdBy: dashboard.created_by, createdAt: dashboard.created_at, updatedAt: dashboard.updated_at, - tags: JSON.parse(dashboard.tags || '[]'), + tags: JSON.parse(dashboard.tags || "[]"), category: dashboard.category, - viewCount: parseInt(dashboard.view_count || '0'), - elements + viewCount: parseInt(dashboard.view_count || "0"), + settings: dashboard.settings || undefined, + elements, }; } catch (error) { - console.error('Dashboard get error:', error); + console.error("Dashboard get error:", error); throw error; } } - + /** * 대시보드 업데이트 */ static async updateDashboard( - dashboardId: string, - data: UpdateDashboardRequest, + dashboardId: string, + data: UpdateDashboardRequest, userId: string ): Promise { try { const result = await PostgreSQLService.transaction(async (client) => { // 권한 체크 - const authCheckResult = await client.query(` + const authCheckResult = await client.query( + ` SELECT id FROM dashboards WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (authCheckResult.rows.length === 0) { - throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.'); + throw new Error("대시보드를 찾을 수 없거나 수정 권한이 없습니다."); } - + const now = new Date(); - + // 1. 대시보드 메인 정보 업데이트 const updateFields: string[] = []; const updateParams: any[] = []; let paramIndex = 1; - + if (data.title !== undefined) { updateFields.push(`title = $${paramIndex}`); updateParams.push(data.title); @@ -390,120 +421,143 @@ export class DashboardService { updateParams.push(data.category); paramIndex++; } - + if (data.settings !== undefined) { + updateFields.push(`settings = $${paramIndex}`); + updateParams.push(JSON.stringify(data.settings)); + paramIndex++; + } + updateFields.push(`updated_at = $${paramIndex}`); updateParams.push(now); paramIndex++; - + updateParams.push(dashboardId); - - if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우 + + if (updateFields.length > 1) { + // updated_at 외에 다른 필드가 있는 경우 const updateQuery = ` UPDATE dashboards - SET ${updateFields.join(', ')} + SET ${updateFields.join(", ")} WHERE id = $${paramIndex} `; - + await client.query(updateQuery, updateParams); } - + // 2. 요소 업데이트 (있는 경우) if (data.elements) { // 기존 요소들 삭제 - await client.query(` + await client.query( + ` DELETE FROM dashboard_elements WHERE dashboard_id = $1 - `, [dashboardId]); - + `, + [dashboardId] + ); + // 새 요소들 추가 for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, - title, content, data_source_config, chart_config, + title, custom_title, show_header, content, data_source_config, chart_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.customTitle || null, + element.showHeader !== false, // 기본값 true + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 업데이트된 대시보드 반환 return await this.getDashboardById(dashboardId, userId); - } catch (error) { - console.error('Dashboard update error:', error); + console.error("Dashboard update error:", error); throw error; } } - + /** * 대시보드 삭제 (소프트 삭제) */ - static async deleteDashboard(dashboardId: string, userId: string): Promise { + static async deleteDashboard( + dashboardId: string, + userId: string + ): Promise { try { const now = new Date(); - - const result = await PostgreSQLService.query(` + + const result = await PostgreSQLService.query( + ` UPDATE dashboards SET deleted_at = $1, updated_at = $2 WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL - `, [now, now, dashboardId, userId]); - + `, + [now, now, dashboardId, userId] + ); + return (result.rowCount || 0) > 0; } catch (error) { - console.error('Dashboard delete error:', error); + console.error("Dashboard delete error:", error); throw error; } } - + /** * 조회수 증가 */ static async incrementViewCount(dashboardId: string): Promise { try { - await PostgreSQLService.query(` + await PostgreSQLService.query( + ` UPDATE dashboards SET view_count = view_count + 1 WHERE id = $1 AND deleted_at IS NULL - `, [dashboardId]); + `, + [dashboardId] + ); } catch (error) { - console.error('View count increment error:', error); + console.error("View count increment error:", error); // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 } } - + /** * 사용자 권한 체크 */ static async checkUserPermission( - dashboardId: string, - userId: string, - requiredPermission: 'view' | 'edit' | 'admin' = 'view' + dashboardId: string, + userId: string, + requiredPermission: "view" | "edit" | "admin" = "view" ): Promise { try { - const result = await PostgreSQLService.query(` + const result = await PostgreSQLService.query( + ` SELECT CASE WHEN d.created_by = $2 THEN 'admin' @@ -512,23 +566,26 @@ export class DashboardService { END as permission FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (result.rows.length === 0) { return false; } - + const userPermission = result.rows[0].permission; - + // 권한 레벨 체크 - const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 }; - const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0; + const permissionLevels = { view: 1, edit: 2, admin: 3 }; + const userLevel = + permissionLevels[userPermission as keyof typeof permissionLevels] || 0; const requiredLevel = permissionLevels[requiredPermission]; - + return userLevel >= requiredLevel; } catch (error) { - console.error('Permission check error:', error); + console.error("Permission check error:", error); return false; } } -} \ No newline at end of file +} 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/backend-node/src/services/bookingService.ts b/backend-node/src/services/bookingService.ts index 79935414..b27544e1 100644 --- a/backend-node/src/services/bookingService.ts +++ b/backend-node/src/services/bookingService.ts @@ -53,13 +53,20 @@ export class BookingService { } private ensureDataDirectory(): void { - if (!fs.existsSync(BOOKING_DIR)) { - fs.mkdirSync(BOOKING_DIR, { recursive: true }); - logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); - } - if (!fs.existsSync(BOOKING_FILE)) { - fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2)); - logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + try { + if (!fs.existsSync(BOOKING_DIR)) { + fs.mkdirSync(BOOKING_DIR, { recursive: true, mode: 0o755 }); + logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); + } + if (!fs.existsSync(BOOKING_FILE)) { + fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2), { + mode: 0o644, + }); + logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + } + } catch (error) { + logger.error(`❌ 예약 디렉토리 생성 실패: ${BOOKING_DIR}`, error); + throw error; } } @@ -111,13 +118,16 @@ export class BookingService { priority?: string; }): Promise<{ bookings: BookingRequest[]; newCount: number }> { try { - const bookings = DATA_SOURCE === "database" - ? await this.loadBookingsFromDB(filter) - : this.loadBookingsFromFile(filter); + const bookings = + DATA_SOURCE === "database" + ? await this.loadBookingsFromDB(filter) + : this.loadBookingsFromFile(filter); bookings.sort((a, b) => { if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1; - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + return ( + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); }); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); @@ -145,7 +155,10 @@ export class BookingService { } } - public async rejectBooking(id: string, reason?: string): Promise { + public async rejectBooking( + id: string, + reason?: string + ): Promise { try { if (DATA_SOURCE === "database") { return await this.rejectBookingDB(id, reason); @@ -194,9 +207,15 @@ export class BookingService { scheduledTime: new Date(row.scheduledTime).toISOString(), createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - acceptedAt: row.acceptedAt ? new Date(row.acceptedAt).toISOString() : undefined, - rejectedAt: row.rejectedAt ? new Date(row.rejectedAt).toISOString() : undefined, - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + acceptedAt: row.acceptedAt + ? new Date(row.acceptedAt).toISOString() + : undefined, + rejectedAt: row.rejectedAt + ? new Date(row.rejectedAt).toISOString() + : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, })); } @@ -230,7 +249,10 @@ export class BookingService { }; } - private async rejectBookingDB(id: string, reason?: string): Promise { + private async rejectBookingDB( + id: string, + reason?: string + ): Promise { const rows = await query( `UPDATE booking_requests SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2 diff --git a/backend-node/src/services/mailAccountFileService.ts b/backend-node/src/services/mailAccountFileService.ts index 7b07b531..e547171a 100644 --- a/backend-node/src/services/mailAccountFileService.ts +++ b/backend-node/src/services/mailAccountFileService.ts @@ -33,11 +33,7 @@ class MailAccountFileService { try { await fs.access(this.accountsDir); } catch { - try { - await fs.mkdir(this.accountsDir, { recursive: true }); - } catch (error) { - console.error("메일 계정 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.accountsDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts index 741353fa..d5e3a78f 100644 --- a/backend-node/src/services/mailReceiveBasicService.ts +++ b/backend-node/src/services/mailReceiveBasicService.ts @@ -59,11 +59,7 @@ export class MailReceiveBasicService { try { await fs.access(this.attachmentsDir); } catch { - try { - await fs.mkdir(this.attachmentsDir, { recursive: true }); - } catch (error) { - console.error("메일 첨부파일 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.attachmentsDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts index 61fd6f89..c7828888 100644 --- a/backend-node/src/services/mailSentHistoryService.ts +++ b/backend-node/src/services/mailSentHistoryService.ts @@ -20,15 +20,13 @@ const SENT_MAIL_DIR = class MailSentHistoryService { constructor() { - // 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 try { if (!fs.existsSync(SENT_MAIL_DIR)) { - fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); + fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } } catch (error) { console.error("메일 발송 이력 디렉토리 생성 실패:", error); - // 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행 - // 실제 파일 쓰기 시점에 에러 처리 + throw error; } } @@ -45,13 +43,15 @@ class MailSentHistoryService { }; try { - // 디렉토리가 없으면 다시 시도 if (!fs.existsSync(SENT_MAIL_DIR)) { - fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); + fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`); - fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8"); + fs.writeFileSync(filePath, JSON.stringify(history, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); console.log("발송 이력 저장:", history.id); } catch (error) { diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index 7a8d4300..e1a878b9 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -54,17 +54,13 @@ class MailTemplateFileService { } /** - * 템플릿 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 + * 템플릿 디렉토리 생성 */ private async ensureDirectoryExists() { try { await fs.access(this.templatesDir); } catch { - try { - await fs.mkdir(this.templatesDir, { recursive: true }); - } catch (error) { - console.error("메일 템플릿 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.templatesDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/todoService.ts b/backend-node/src/services/todoService.ts index 1347c665..33becbb9 100644 --- a/backend-node/src/services/todoService.ts +++ b/backend-node/src/services/todoService.ts @@ -61,13 +61,20 @@ export class TodoService { * 데이터 디렉토리 생성 (파일 모드) */ private ensureDataDirectory(): void { - if (!fs.existsSync(TODO_DIR)) { - fs.mkdirSync(TODO_DIR, { recursive: true }); - logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); - } - if (!fs.existsSync(TODO_FILE)) { - fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2)); - logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + try { + if (!fs.existsSync(TODO_DIR)) { + fs.mkdirSync(TODO_DIR, { recursive: true, mode: 0o755 }); + logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); + } + if (!fs.existsSync(TODO_FILE)) { + fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2), { + mode: 0o644, + }); + logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + } + } catch (error) { + logger.error(`❌ To-Do 디렉토리 생성 실패: ${TODO_DIR}`, error); + throw error; } } @@ -80,15 +87,17 @@ export class TodoService { assignedTo?: string; }): Promise { try { - const todos = DATA_SOURCE === "database" - ? await this.loadTodosFromDB(filter) - : this.loadTodosFromFile(filter); + const todos = + DATA_SOURCE === "database" + ? await this.loadTodosFromDB(filter) + : this.loadTodosFromFile(filter); // 정렬: 긴급 > 우선순위 > 순서 todos.sort((a, b) => { if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1; const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; - if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority]; + if (a.priority !== b.priority) + return priorityOrder[a.priority] - priorityOrder[b.priority]; return a.order - b.order; }); @@ -124,7 +133,8 @@ export class TodoService { await this.createTodoDB(newTodo); } else { const todos = this.loadTodosFromFile(); - newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; + newTodo.order = + todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; todos.push(newTodo); this.saveTodosToFile(todos); } @@ -140,7 +150,10 @@ export class TodoService { /** * To-Do 항목 수정 */ - public async updateTodo(id: string, updates: Partial): Promise { + public async updateTodo( + id: string, + updates: Partial + ): Promise { try { if (DATA_SOURCE === "database") { return await this.updateTodoDB(id, updates); @@ -231,7 +244,9 @@ export class TodoService { dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, })); } @@ -263,7 +278,10 @@ export class TodoService { ); } - private async updateTodoDB(id: string, updates: Partial): Promise { + private async updateTodoDB( + id: string, + updates: Partial + ): Promise { const setClauses: string[] = ["updated_at = NOW()"]; const params: any[] = []; let paramIndex = 1; @@ -327,12 +345,17 @@ export class TodoService { dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, }; } private async deleteTodoDB(id: string): Promise { - const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]); + const rows = await query( + "DELETE FROM todo_items WHERE id = $1 RETURNING id", + [id] + ); if (rows.length === 0) { throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); } @@ -443,7 +466,10 @@ export class TodoService { inProgress: todos.filter((t) => t.status === "in_progress").length, completed: todos.filter((t) => t.status === "completed").length, urgent: todos.filter((t) => t.isUrgent).length, - overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length, + overdue: todos.filter( + (t) => + t.dueDate && new Date(t.dueDate) < now && t.status !== "completed" + ).length, }; } } diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index c37beae8..789adda3 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -4,8 +4,8 @@ export interface DashboardElement { id: string; - type: 'chart' | 'widget'; - subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather'; + type: "chart" | "widget"; + subtype: "bar" | "pie" | "line" | "exchange" | "weather"; position: { x: number; y: number; @@ -15,9 +15,11 @@ export interface DashboardElement { height: number; }; title: string; + customTitle?: string; // 사용자 정의 제목 (옵션) + showHeader?: boolean; // 헤더 표시 여부 (기본값: true) content?: string; dataSource?: { - type: 'api' | 'database' | 'static'; + type: "api" | "database" | "static"; endpoint?: string; query?: string; refreshInterval?: number; @@ -28,7 +30,7 @@ export interface DashboardElement { xAxis?: string; yAxis?: string; groupBy?: string; - aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min'; + aggregation?: "sum" | "avg" | "count" | "max" | "min"; colors?: string[]; title?: string; showLegend?: boolean; @@ -48,6 +50,10 @@ export interface Dashboard { tags?: string[]; category?: string; viewCount: number; + settings?: { + resolution?: string; + backgroundColor?: string; + }; elements: DashboardElement[]; } @@ -58,6 +64,10 @@ export interface CreateDashboardRequest { elements: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface UpdateDashboardRequest { @@ -67,6 +77,10 @@ export interface UpdateDashboardRequest { elements?: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface DashboardListQuery { @@ -83,7 +97,7 @@ export interface DashboardShare { dashboardId: string; sharedWithUser?: string; sharedWithRole?: string; - permissionLevel: 'view' | 'edit' | 'admin'; + permissionLevel: "view" | "edit" | "admin"; createdBy: string; createdAt: string; expiresAt?: string; diff --git a/docker/deploy/backend.Dockerfile b/docker/deploy/backend.Dockerfile index bbfd3438..a5dd1aeb 100644 --- a/docker/deploy/backend.Dockerfile +++ b/docker/deploy/backend.Dockerfile @@ -34,14 +34,11 @@ COPY --from=build /app/dist ./dist # Copy package files COPY package*.json ./ -# Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000) -RUN mkdir -p logs \ - uploads/mail-attachments \ - uploads/mail-templates \ - uploads/mail-accounts \ - data/mail-sent && \ - chown -R node:node logs uploads data && \ - chmod -R 755 logs uploads data +# 루트 디렉토리만 생성하고 node 유저에게 쓰기 권한 부여 +# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성 +RUN mkdir -p logs uploads data && \ + chown -R node:node /app && \ + chmod -R 755 /app EXPOSE 3001 USER node diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index d3934c00..e1c76ad9 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -20,8 +20,8 @@ services: LOG_LEVEL: info ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure volumes: - - /home/vexplor/backend_data/uploads:/app/uploads - - /home/vexplor/backend_data/data:/app/data + - backend_uploads:/app/uploads + - backend_data:/app/data labels: - traefik.enable=true - traefik.http.routers.backend.rule=Host(`api.vexplor.com`) @@ -46,7 +46,7 @@ services: PORT: "3000" HOSTNAME: 0.0.0.0 volumes: - - /home/vexplor/frontend_data:/app/data + - frontend_data:/app/data labels: - traefik.enable=true - traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`) @@ -55,6 +55,14 @@ services: - traefik.http.routers.frontend.tls.certresolver=le - traefik.http.services.frontend.loadbalancer.server.port=3000 +volumes: + backend_uploads: + driver: local + backend_data: + driver: local + frontend_data: + driver: local + networks: default: name: toktork_server_default diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index b9675147..4d862d9e 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -20,7 +20,7 @@ services: - LOG_LEVEL=debug - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA - - ITS_API_KEY=${ITS_API_KEY:-} + - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 diff --git a/docker/prod/backend.Dockerfile b/docker/prod/backend.Dockerfile index 8ef8a372..7944bc67 100644 --- a/docker/prod/backend.Dockerfile +++ b/docker/prod/backend.Dockerfile @@ -37,8 +37,11 @@ COPY --from=build /app/dist ./dist # Copy package files COPY package*.json ./ -# Create logs and uploads directories and set permissions -RUN mkdir -p logs uploads && chown -R appuser:appgroup logs uploads && chmod -R 755 logs uploads +# 루트 디렉토리만 생성하고 appuser에게 쓰기 권한 부여 +# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성 +RUN mkdir -p logs uploads data && \ + chown -R appuser:appgroup /app && \ + chmod -R 755 /app EXPOSE 8080 USER appuser diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index dcf81963..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("대시보드 복사에 실패했습니다."); } }; @@ -156,8 +186,6 @@ export default function DashboardListPage() { 제목 설명 - 요소 수 - 상태 생성일 수정일 작업 @@ -166,29 +194,10 @@ export default function DashboardListPage() { {dashboards.map((dashboard) => ( - -
- {dashboard.title} - {dashboard.isPublic && ( - - 공개 - - )} -
-
+ {dashboard.title} {dashboard.description || "-"} - - {dashboard.elementsCount || 0}개 - - - {dashboard.isPublic ? ( - 공개 - ) : ( - 비공개 - )} - {formatDate(dashboard.createdAt)} {formatDate(dashboard.updatedAt)} @@ -199,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" @@ -215,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" > @@ -231,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 = ({ 화면 할당 +
+ + +