diff --git a/WORK_HISTORY_SETUP.md b/WORK_HISTORY_SETUP.md new file mode 100644 index 00000000..223b3975 --- /dev/null +++ b/WORK_HISTORY_SETUP.md @@ -0,0 +1,150 @@ +# 작업 이력 관리 시스템 설치 가이드 + +## 📋 개요 + +작업 이력 관리 시스템이 추가되었습니다. 입고/출고/이송/정비 작업을 관리하고 통계를 확인할 수 있습니다. + +## 🚀 설치 방법 + +### 1. 데이터베이스 마이그레이션 실행 + +PostgreSQL 데이터베이스에 작업 이력 테이블을 생성해야 합니다. + +```bash +# 방법 1: psql 명령어 사용 (로컬 PostgreSQL) +psql -U postgres -d plm -f db/migrations/20241020_create_work_history.sql + +# 방법 2: Docker 컨테이너 사용 +docker exec -i psql -U postgres -d plm < db/migrations/20241020_create_work_history.sql + +# 방법 3: pgAdmin 또는 DBeaver 사용 +# db/migrations/20241020_create_work_history.sql 파일을 열어서 실행 +``` + +### 2. 백엔드 재시작 + +```bash +cd backend-node +npm run dev +``` + +### 3. 프론트엔드 확인 + +대시보드 편집 화면에서 다음 위젯들을 추가할 수 있습니다: + +- **작업 이력**: 작업 목록을 테이블 형식으로 표시 +- **운송 통계**: 오늘 작업, 총 운송량, 정시 도착률 등 통계 표시 + +## 📊 주요 기능 + +### 작업 이력 위젯 + +- 작업 번호, 일시, 유형, 차량, 경로, 화물, 중량, 상태 표시 +- 유형별 필터링 (입고/출고/이송/정비) +- 상태별 필터링 (대기/진행중/완료/취소) +- 실시간 자동 새로고침 + +### 운송 통계 위젯 + +- 오늘 작업 건수 및 완료율 +- 총 운송량 (톤) +- 누적 거리 (km) +- 정시 도착률 (%) +- 작업 유형별 분포 차트 + +## 🔧 API 엔드포인트 + +### 작업 이력 관리 + +- `GET /api/work-history` - 작업 이력 목록 조회 +- `GET /api/work-history/:id` - 작업 이력 단건 조회 +- `POST /api/work-history` - 작업 이력 생성 +- `PUT /api/work-history/:id` - 작업 이력 수정 +- `DELETE /api/work-history/:id` - 작업 이력 삭제 + +### 통계 및 분석 + +- `GET /api/work-history/stats` - 작업 이력 통계 +- `GET /api/work-history/trend?months=6` - 월별 추이 +- `GET /api/work-history/routes?limit=5` - 주요 운송 경로 + +## 📝 샘플 데이터 + +마이그레이션 실행 시 자동으로 4건의 샘플 데이터가 생성됩니다: + +1. 입고 작업 (완료) +2. 출고 작업 (진행중) +3. 이송 작업 (대기) +4. 정비 작업 (완료) + +## 🎯 사용 방법 + +### 1. 대시보드에 위젯 추가 + +1. 대시보드 편집 모드로 이동 +2. 상단 메뉴에서 "위젯 추가" 선택 +3. "작업 이력" 또는 "운송 통계" 선택 +4. 원하는 위치에 배치 +5. 저장 + +### 2. 작업 이력 필터링 + +- 유형 선택: 전체/입고/출고/이송/정비 +- 상태 선택: 전체/대기/진행중/완료/취소 +- 새로고침 버튼으로 수동 갱신 + +### 3. 통계 확인 + +운송 통계 위젯에서 다음 정보를 확인할 수 있습니다: + +- 오늘 작업 건수 +- 완료율 +- 총 운송량 +- 정시 도착률 +- 작업 유형별 분포 + +## 🔍 문제 해결 + +### 데이터가 표시되지 않는 경우 + +1. 데이터베이스 마이그레이션이 실행되었는지 확인 +2. 백엔드 서버가 실행 중인지 확인 +3. 브라우저 콘솔에서 API 에러 확인 + +### API 에러가 발생하는 경우 + +```bash +# 백엔드 로그 확인 +cd backend-node +npm run dev +``` + +### 위젯이 표시되지 않는 경우 + +1. 프론트엔드 재시작 +2. 브라우저 캐시 삭제 +3. 페이지 새로고침 + +## 📚 관련 파일 + +### 백엔드 + +- `backend-node/src/types/workHistory.ts` - 타입 정의 +- `backend-node/src/services/workHistoryService.ts` - 비즈니스 로직 +- `backend-node/src/controllers/workHistoryController.ts` - API 컨트롤러 +- `backend-node/src/routes/workHistoryRoutes.ts` - 라우트 정의 + +### 프론트엔드 + +- `frontend/types/workHistory.ts` - 타입 정의 +- `frontend/components/dashboard/widgets/WorkHistoryWidget.tsx` - 작업 이력 위젯 +- `frontend/components/dashboard/widgets/TransportStatsWidget.tsx` - 운송 통계 위젯 + +### 데이터베이스 + +- `db/migrations/20241020_create_work_history.sql` - 테이블 생성 스크립트 + +## 🎉 완료! + +작업 이력 관리 시스템이 성공적으로 설치되었습니다! + diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json index 0637a088..e10d42af 100644 --- a/backend-node/data/todos/todos.json +++ b/backend-node/data/todos/todos.json @@ -1 +1,55 @@ -[] \ No newline at end of file +[ + { + "id": "e5bb334c-d58a-4068-ad77-2607a41f4675", + "title": "ㅁㄴㅇㄹ", + "description": "ㅁㄴㅇㄹ", + "priority": "normal", + "status": "completed", + "assignedTo": "", + "dueDate": "2025-10-20T18:17", + "createdAt": "2025-10-20T06:15:49.610Z", + "updatedAt": "2025-10-20T07:36:06.370Z", + "isUrgent": false, + "order": 0, + "completedAt": "2025-10-20T07:36:06.370Z" + }, + { + "id": "334be17c-7776-47e8-89ec-4b57c4a34bcd", + "title": "연동되어주겠니?", + "description": "", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-20T06:20:06.343Z", + "updatedAt": "2025-10-20T06:20:06.343Z", + "isUrgent": false, + "order": 1 + }, + { + "id": "f85b81de-fcbd-4858-8973-247d9d6e70ed", + "title": "연동되어주겠니?11", + "description": "ㄴㅇㄹ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-20T17:22", + "createdAt": "2025-10-20T06:20:53.818Z", + "updatedAt": "2025-10-20T06:20:53.818Z", + "isUrgent": false, + "order": 2 + }, + { + "id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05", + "title": "연동되어주려무니", + "description": "ㅁㄴㅇㄹ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-21T15:21", + "createdAt": "2025-10-20T06:21:19.817Z", + "updatedAt": "2025-10-20T06:21:19.817Z", + "isUrgent": false, + "order": 3 + } +] \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f8c096e4..c503f548 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -59,6 +59,7 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 +import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -212,6 +213,7 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D // app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) +app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/workHistoryController.ts b/backend-node/src/controllers/workHistoryController.ts new file mode 100644 index 00000000..8648a385 --- /dev/null +++ b/backend-node/src/controllers/workHistoryController.ts @@ -0,0 +1,199 @@ +/** + * 작업 이력 관리 컨트롤러 + */ + +import { Request, Response } from 'express'; +import * as workHistoryService from '../services/workHistoryService'; +import { CreateWorkHistoryDto, UpdateWorkHistoryDto, WorkHistoryFilters } from '../types/workHistory'; + +/** + * 작업 이력 목록 조회 + */ +export async function getWorkHistories(req: Request, res: Response): Promise { + try { + const filters: WorkHistoryFilters = { + work_type: req.query.work_type as any, + status: req.query.status as any, + vehicle_number: req.query.vehicle_number as string, + driver_name: req.query.driver_name as string, + start_date: req.query.start_date ? new Date(req.query.start_date as string) : undefined, + end_date: req.query.end_date ? new Date(req.query.end_date as string) : undefined, + search: req.query.search as string, + }; + + const histories = await workHistoryService.getWorkHistories(filters); + res.json({ + success: true, + data: histories, + }); + } catch (error) { + console.error('작업 이력 목록 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 목록 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 단건 조회 + */ +export async function getWorkHistoryById(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const history = await workHistoryService.getWorkHistoryById(id); + + if (!history) { + res.status(404).json({ + success: false, + message: '작업 이력을 찾을 수 없습니다', + }); + return; + } + + res.json({ + success: true, + data: history, + }); + } catch (error) { + console.error('작업 이력 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 생성 + */ +export async function createWorkHistory(req: Request, res: Response): Promise { + try { + const data: CreateWorkHistoryDto = req.body; + const history = await workHistoryService.createWorkHistory(data); + + res.status(201).json({ + success: true, + data: history, + message: '작업 이력이 생성되었습니다', + }); + } catch (error) { + console.error('작업 이력 생성 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 생성에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 수정 + */ +export async function updateWorkHistory(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const data: UpdateWorkHistoryDto = req.body; + const history = await workHistoryService.updateWorkHistory(id, data); + + res.json({ + success: true, + data: history, + message: '작업 이력이 수정되었습니다', + }); + } catch (error) { + console.error('작업 이력 수정 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 수정에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 삭제 + */ +export async function deleteWorkHistory(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + await workHistoryService.deleteWorkHistory(id); + + res.json({ + success: true, + message: '작업 이력이 삭제되었습니다', + }); + } catch (error) { + console.error('작업 이력 삭제 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 삭제에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 통계 조회 + */ +export async function getWorkHistoryStats(req: Request, res: Response): Promise { + try { + const stats = await workHistoryService.getWorkHistoryStats(); + res.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error('작업 이력 통계 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 통계 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 월별 추이 조회 + */ +export async function getMonthlyTrend(req: Request, res: Response): Promise { + try { + const months = parseInt(req.query.months as string) || 6; + const trend = await workHistoryService.getMonthlyTrend(months); + res.json({ + success: true, + data: trend, + }); + } catch (error) { + console.error('월별 추이 조회 실패:', error); + res.status(500).json({ + success: false, + message: '월별 추이 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 주요 운송 경로 조회 + */ +export async function getTopRoutes(req: Request, res: Response): Promise { + try { + const limit = parseInt(req.query.limit as string) || 5; + const routes = await workHistoryService.getTopRoutes(limit); + res.json({ + success: true, + data: routes, + }); + } catch (error) { + console.error('주요 운송 경로 조회 실패:', error); + res.status(500).json({ + success: false, + message: '주요 운송 경로 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + diff --git a/backend-node/src/routes/workHistoryRoutes.ts b/backend-node/src/routes/workHistoryRoutes.ts new file mode 100644 index 00000000..330d08db --- /dev/null +++ b/backend-node/src/routes/workHistoryRoutes.ts @@ -0,0 +1,35 @@ +/** + * 작업 이력 관리 라우트 + */ + +import express from 'express'; +import * as workHistoryController from '../controllers/workHistoryController'; + +const router = express.Router(); + +// 작업 이력 목록 조회 +router.get('/', workHistoryController.getWorkHistories); + +// 작업 이력 통계 조회 +router.get('/stats', workHistoryController.getWorkHistoryStats); + +// 월별 추이 조회 +router.get('/trend', workHistoryController.getMonthlyTrend); + +// 주요 운송 경로 조회 +router.get('/routes', workHistoryController.getTopRoutes); + +// 작업 이력 단건 조회 +router.get('/:id', workHistoryController.getWorkHistoryById); + +// 작업 이력 생성 +router.post('/', workHistoryController.createWorkHistory); + +// 작업 이력 수정 +router.put('/:id', workHistoryController.updateWorkHistory); + +// 작업 이력 삭제 +router.delete('/:id', workHistoryController.deleteWorkHistory); + +export default router; + diff --git a/backend-node/src/services/YardLayoutService.ts b/backend-node/src/services/YardLayoutService.ts index 64572739..609e3d1c 100644 --- a/backend-node/src/services/YardLayoutService.ts +++ b/backend-node/src/services/YardLayoutService.ts @@ -153,18 +153,35 @@ export class YardLayoutService { `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = ( + value: any, + defaultValue: number | null = null + ): number | null => { + if (!value && value !== 0) return defaultValue; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? defaultValue : parsed; + }; + + const safeParseFloat = (value: any, defaultValue: number): number => { + if (!value && value !== 0) return defaultValue; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? defaultValue : parsed; + }; + const result = await pool.query(query, [ layoutId, data.material_code || null, data.material_name || null, - data.quantity || null, + safeParseInt(data.quantity, null), data.unit || null, - data.position_x || 0, - data.position_y || 0, - data.position_z || 0, - data.size_x || 5, - data.size_y || 5, - data.size_z || 5, + safeParseFloat(data.position_x, 0), + safeParseFloat(data.position_y, 0), + safeParseFloat(data.position_z, 0), + safeParseFloat(data.size_x, 5), + safeParseFloat(data.size_y, 5), + safeParseFloat(data.size_z, 5), data.color || "#9ca3af", // 미설정 시 회색 data.data_source_type || null, data.data_source_config ? JSON.stringify(data.data_source_config) : null, @@ -201,17 +218,31 @@ export class YardLayoutService { `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? null : parsed; + }; + + const safeParseFloat = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? null : parsed; + }; + const result = await pool.query(query, [ data.material_code !== undefined ? data.material_code : null, data.material_name !== undefined ? data.material_name : null, - data.quantity !== undefined ? data.quantity : null, + data.quantity !== undefined ? safeParseInt(data.quantity) : null, data.unit !== undefined ? data.unit : null, - data.position_x !== undefined ? data.position_x : null, - data.position_y !== undefined ? data.position_y : null, - data.position_z !== undefined ? data.position_z : null, - data.size_x !== undefined ? data.size_x : null, - data.size_y !== undefined ? data.size_y : null, - data.size_z !== undefined ? data.size_z : null, + data.position_x !== undefined ? safeParseFloat(data.position_x) : null, + data.position_y !== undefined ? safeParseFloat(data.position_y) : null, + data.position_z !== undefined ? safeParseFloat(data.position_z) : null, + data.size_x !== undefined ? safeParseFloat(data.size_x) : null, + data.size_y !== undefined ? safeParseFloat(data.size_y) : null, + data.size_z !== undefined ? safeParseFloat(data.size_z) : null, data.color !== undefined ? data.color : null, data.data_source_type !== undefined ? data.data_source_type : null, data.data_source_config !== undefined diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 661ffae1..3de082d7 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -53,6 +53,8 @@ const ALLOWED_TABLES = [ "table_labels", "column_labels", "dynamic_form_data", + "work_history", // 작업 이력 테이블 + "delivery_status", // 배송 현황 테이블 ]; /** diff --git a/backend-node/src/services/workHistoryService.ts b/backend-node/src/services/workHistoryService.ts new file mode 100644 index 00000000..5ccceba9 --- /dev/null +++ b/backend-node/src/services/workHistoryService.ts @@ -0,0 +1,335 @@ +/** + * 작업 이력 관리 서비스 + */ + +import pool from '../database/db'; +import { + WorkHistory, + CreateWorkHistoryDto, + UpdateWorkHistoryDto, + WorkHistoryFilters, + WorkHistoryStats, + MonthlyTrend, + TopRoute, +} from '../types/workHistory'; + +/** + * 작업 이력 목록 조회 + */ +export async function getWorkHistories(filters?: WorkHistoryFilters): Promise { + try { + let query = ` + SELECT * FROM work_history + WHERE deleted_at IS NULL + `; + const params: (string | Date)[] = []; + let paramIndex = 1; + + // 필터 적용 + if (filters?.work_type) { + query += ` AND work_type = $${paramIndex}`; + params.push(filters.work_type); + paramIndex++; + } + + if (filters?.status) { + query += ` AND status = $${paramIndex}`; + params.push(filters.status); + paramIndex++; + } + + if (filters?.vehicle_number) { + query += ` AND vehicle_number LIKE $${paramIndex}`; + params.push(`%${filters.vehicle_number}%`); + paramIndex++; + } + + if (filters?.driver_name) { + query += ` AND driver_name LIKE $${paramIndex}`; + params.push(`%${filters.driver_name}%`); + paramIndex++; + } + + if (filters?.start_date) { + query += ` AND work_date >= $${paramIndex}`; + params.push(filters.start_date); + paramIndex++; + } + + if (filters?.end_date) { + query += ` AND work_date <= $${paramIndex}`; + params.push(filters.end_date); + paramIndex++; + } + + if (filters?.search) { + query += ` AND ( + work_number LIKE $${paramIndex} OR + vehicle_number LIKE $${paramIndex} OR + driver_name LIKE $${paramIndex} OR + cargo_name LIKE $${paramIndex} + )`; + params.push(`%${filters.search}%`); + paramIndex++; + } + + query += ` ORDER BY work_date DESC`; + + const result: any = await pool.query(query, params); + return result.rows; + } catch (error) { + console.error('작업 이력 조회 실패:', error); + throw error; + } +} + +/** + * 작업 이력 단건 조회 + */ +export async function getWorkHistoryById(id: number): Promise { + try { + const result: any = await pool.query( + 'SELECT * FROM work_history WHERE id = $1 AND deleted_at IS NULL', + [id] + ); + return result.rows[0] || null; + } catch (error) { + console.error('작업 이력 조회 실패:', error); + throw error; + } +} + +/** + * 작업 이력 생성 + */ +export async function createWorkHistory(data: CreateWorkHistoryDto): Promise { + try { + const result: any = await pool.query( + `INSERT INTO work_history ( + work_type, vehicle_number, driver_name, origin, destination, + cargo_name, cargo_weight, cargo_unit, distance, distance_unit, + status, scheduled_time, estimated_arrival, notes, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING *`, + [ + data.work_type, + data.vehicle_number, + data.driver_name, + data.origin, + data.destination, + data.cargo_name, + data.cargo_weight, + data.cargo_unit || 'ton', + data.distance, + data.distance_unit || 'km', + data.status || 'pending', + data.scheduled_time, + data.estimated_arrival, + data.notes, + data.created_by, + ] + ); + return result.rows[0]; + } catch (error) { + console.error('작업 이력 생성 실패:', error); + throw error; + } +} + +/** + * 작업 이력 수정 + */ +export async function updateWorkHistory(id: number, data: UpdateWorkHistoryDto): Promise { + try { + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined) { + fields.push(`${key} = $${paramIndex}`); + values.push(value); + paramIndex++; + } + }); + + if (fields.length === 0) { + throw new Error('수정할 데이터가 없습니다'); + } + + values.push(id); + const query = ` + UPDATE work_history + SET ${fields.join(', ')} + WHERE id = $${paramIndex} AND deleted_at IS NULL + RETURNING * + `; + + const result: any = await pool.query(query, values); + if (result.rows.length === 0) { + throw new Error('작업 이력을 찾을 수 없습니다'); + } + return result.rows[0]; + } catch (error) { + console.error('작업 이력 수정 실패:', error); + throw error; + } +} + +/** + * 작업 이력 삭제 (소프트 삭제) + */ +export async function deleteWorkHistory(id: number): Promise { + try { + const result: any = await pool.query( + 'UPDATE work_history SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL', + [id] + ); + if (result.rowCount === 0) { + throw new Error('작업 이력을 찾을 수 없습니다'); + } + } catch (error) { + console.error('작업 이력 삭제 실패:', error); + throw error; + } +} + +/** + * 작업 이력 통계 조회 + */ +export async function getWorkHistoryStats(): Promise { + try { + // 오늘 작업 통계 + const todayResult: any = await pool.query(` + SELECT + COUNT(*) as today_total, + COUNT(*) FILTER (WHERE status = 'completed') as today_completed + FROM work_history + WHERE DATE(work_date) = CURRENT_DATE AND deleted_at IS NULL + `); + + // 총 운송량 및 거리 + const totalResult: any = await pool.query(` + SELECT + COALESCE(SUM(cargo_weight), 0) as total_weight, + COALESCE(SUM(distance), 0) as total_distance + FROM work_history + WHERE deleted_at IS NULL AND status = 'completed' + `); + + // 정시 도착률 + const onTimeResult: any = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE is_on_time = true) * 100.0 / NULLIF(COUNT(*), 0) as on_time_rate + FROM work_history + WHERE deleted_at IS NULL + AND status = 'completed' + AND is_on_time IS NOT NULL + `); + + // 작업 유형별 분포 + const typeResult: any = await pool.query(` + SELECT + work_type, + COUNT(*) as count + FROM work_history + WHERE deleted_at IS NULL + GROUP BY work_type + `); + + const typeDistribution = { + inbound: 0, + outbound: 0, + transfer: 0, + maintenance: 0, + }; + + typeResult.rows.forEach((row: any) => { + typeDistribution[row.work_type as keyof typeof typeDistribution] = parseInt(row.count); + }); + + return { + today_total: parseInt(todayResult.rows[0].today_total), + today_completed: parseInt(todayResult.rows[0].today_completed), + total_weight: parseFloat(totalResult.rows[0].total_weight), + total_distance: parseFloat(totalResult.rows[0].total_distance), + on_time_rate: parseFloat(onTimeResult.rows[0]?.on_time_rate || '0'), + type_distribution: typeDistribution, + }; + } catch (error) { + console.error('작업 이력 통계 조회 실패:', error); + throw error; + } +} + +/** + * 월별 추이 조회 + */ +export async function getMonthlyTrend(months: number = 6): Promise { + try { + const result: any = await pool.query( + ` + SELECT + TO_CHAR(work_date, 'YYYY-MM') as month, + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'completed') as completed, + COALESCE(SUM(cargo_weight), 0) as weight, + COALESCE(SUM(distance), 0) as distance + FROM work_history + WHERE deleted_at IS NULL + AND work_date >= CURRENT_DATE - INTERVAL '${months} months' + GROUP BY TO_CHAR(work_date, 'YYYY-MM') + ORDER BY month DESC + `, + [] + ); + + return result.rows.map((row: any) => ({ + month: row.month, + total: parseInt(row.total), + completed: parseInt(row.completed), + weight: parseFloat(row.weight), + distance: parseFloat(row.distance), + })); + } catch (error) { + console.error('월별 추이 조회 실패:', error); + throw error; + } +} + +/** + * 주요 운송 경로 조회 + */ +export async function getTopRoutes(limit: number = 5): Promise { + try { + const result: any = await pool.query( + ` + SELECT + origin, + destination, + COUNT(*) as count, + COALESCE(SUM(cargo_weight), 0) as total_weight + FROM work_history + WHERE deleted_at IS NULL + AND origin IS NOT NULL + AND destination IS NOT NULL + AND work_type IN ('inbound', 'outbound', 'transfer') + GROUP BY origin, destination + ORDER BY count DESC + LIMIT $1 + `, + [limit] + ); + + return result.rows.map((row: any) => ({ + origin: row.origin, + destination: row.destination, + count: parseInt(row.count), + total_weight: parseFloat(row.total_weight), + })); + } catch (error) { + console.error('주요 운송 경로 조회 실패:', error); + throw error; + } +} + diff --git a/backend-node/src/types/workHistory.ts b/backend-node/src/types/workHistory.ts new file mode 100644 index 00000000..83c13fe2 --- /dev/null +++ b/backend-node/src/types/workHistory.ts @@ -0,0 +1,114 @@ +/** + * 작업 이력 관리 타입 정의 + */ + +export type WorkType = 'inbound' | 'outbound' | 'transfer' | 'maintenance'; +export type WorkStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface WorkHistory { + id: number; + work_number: string; + work_date: Date; + work_type: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status: WorkStatus; + scheduled_time?: Date; + start_time?: Date; + end_time?: Date; + estimated_arrival?: Date; + actual_arrival?: Date; + is_on_time?: boolean; + delay_reason?: string; + notes?: string; + created_by?: string; + created_at: Date; + updated_at: Date; + deleted_at?: Date; +} + +export interface CreateWorkHistoryDto { + work_type: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status?: WorkStatus; + scheduled_time?: Date; + estimated_arrival?: Date; + notes?: string; + created_by?: string; +} + +export interface UpdateWorkHistoryDto { + work_type?: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status?: WorkStatus; + scheduled_time?: Date; + start_time?: Date; + end_time?: Date; + estimated_arrival?: Date; + actual_arrival?: Date; + delay_reason?: string; + notes?: string; +} + +export interface WorkHistoryFilters { + work_type?: WorkType; + status?: WorkStatus; + vehicle_number?: string; + driver_name?: string; + start_date?: Date; + end_date?: Date; + search?: string; +} + +export interface WorkHistoryStats { + today_total: number; + today_completed: number; + total_weight: number; + total_distance: number; + on_time_rate: number; + type_distribution: { + inbound: number; + outbound: number; + transfer: number; + maintenance: number; + }; +} + +export interface MonthlyTrend { + month: string; + total: number; + completed: number; + weight: number; + distance: number; +} + +export interface TopRoute { + origin: string; + destination: string; + count: number; + total_weight: number; +} + diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index f8af0d0f..7639abc6 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useState, useEffect, use } from "react"; -import { useRouter } from "next/navigation"; import { DashboardViewer } from "@/components/dashboard/DashboardViewer"; import { DashboardElement } from "@/components/admin/dashboard/types"; @@ -18,7 +17,6 @@ interface DashboardViewPageProps { * - 전체화면 모드 지원 */ export default function DashboardViewPage({ params }: DashboardViewPageProps) { - const router = useRouter(); const resolvedParams = use(params); const [dashboard, setDashboard] = useState<{ id: string; @@ -35,12 +33,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // 대시보드 데이터 로딩 - useEffect(() => { - loadDashboard(); - }, [resolvedParams.dashboardId]); - - const loadDashboard = async () => { + const loadDashboard = React.useCallback(async () => { setIsLoading(true); setError(null); @@ -50,13 +43,16 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { try { const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId); - setDashboard(dashboardData); + setDashboard({ + ...dashboardData, + elements: dashboardData.elements || [], + }); } catch (apiError) { console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError); // API 실패 시 로컬 스토리지에서 찾기 const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); - const savedDashboard = savedDashboards.find((d: any) => d.id === resolvedParams.dashboardId); + const savedDashboard = savedDashboards.find((d: { id: string }) => d.id === resolvedParams.dashboardId); if (savedDashboard) { setDashboard(savedDashboard); @@ -72,7 +68,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { } finally { setIsLoading(false); } - }; + }, [resolvedParams.dashboardId]); + + // 대시보드 데이터 로딩 + useEffect(() => { + loadDashboard(); + }, [loadDashboard]); // 로딩 상태 if (isLoading) { @@ -159,10 +160,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { */} {/* 대시보드 뷰어 */} - ); @@ -171,8 +173,33 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { /** * 샘플 대시보드 생성 함수 */ -function generateSampleDashboard(dashboardId: string) { - const dashboards: Record = { +function generateSampleDashboard(dashboardId: string): { + id: string; + title: string; + description?: string; + elements: DashboardElement[]; + settings?: { + backgroundColor?: string; + resolution?: string; + }; + createdAt: string; + updatedAt: string; +} { + const dashboards: Record< + string, + { + id: string; + title: string; + description?: string; + elements: DashboardElement[]; + settings?: { + backgroundColor?: string; + resolution?: string; + }; + createdAt: string; + updatedAt: string; + } + > = { "sales-overview": { id: "sales-overview", title: "📊 매출 현황 대시보드", diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index f725497c..7b5453f9 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -112,10 +112,23 @@ const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DW loading: () =>
로딩 중...
, }); +// 작업 이력 위젯 +const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 커스텀 통계 카드 위젯 +const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; cellSize: number; + subGridSize: number; canvasWidth?: number; onUpdate: (id: string, updates: Partial) => void; onRemove: (id: string) => void; @@ -133,6 +146,7 @@ export function CanvasElement({ element, isSelected, cellSize, + subGridSize, canvasWidth = 1560, onUpdate, onRemove, @@ -233,7 +247,6 @@ export function CanvasElement({ rawX = Math.min(rawX, maxX); // 드래그 중 실시간 스냅 (마그네틱 스냅) - const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) @@ -291,7 +304,6 @@ export function CanvasElement({ newWidth = Math.min(newWidth, maxWidth); // 리사이즈 중 실시간 스냅 (마그네틱 스냅) - const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; @@ -336,6 +348,7 @@ export function CanvasElement({ element.subtype, canvasWidth, cellSize, + subGridSize, ], ); @@ -726,10 +739,21 @@ export function CanvasElement({ isEditMode={true} config={element.yardConfig} onConfigChange={(newConfig) => { + // console.log("🏗️ 야드 설정 업데이트:", { elementId: element.id, newConfig }); onUpdate(element.id, { yardConfig: newConfig }); }} /> + ) : element.type === "widget" && element.subtype === "work-history" ? ( + // 작업 이력 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "transport-stats" ? ( + // 커스텀 통계 카드 위젯 렌더링 +
+ +
) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 45d2cf3c..3170880a 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -156,8 +156,7 @@ export const DashboardCanvas = forwardRef( const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) - const subGridSize = Math.floor(cellSize / 3); - const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // X 좌표 스냅 @@ -196,6 +195,9 @@ export const DashboardCanvas = forwardRef( // 동적 그리드 크기 계산 const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; + + // 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용) + const subGridSize = gridConfig.SUB_GRID_SIZE; // 12개 컬럼 구분선 위치 계산 const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap); @@ -208,12 +210,12 @@ export const DashboardCanvas = forwardRef( backgroundColor, height: `${canvasHeight}px`, minHeight: `${canvasHeight}px`, - // 세밀한 그리드 배경 + // 서브그리드 배경 (세밀한 점선) backgroundImage: ` - linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px) + linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px) `, - backgroundSize: gridSize, + backgroundSize: `${subGridSize}px ${subGridSize}px`, backgroundPosition: "0 0", backgroundRepeat: "repeat", }} @@ -229,8 +231,9 @@ export const DashboardCanvas = forwardRef( className="pointer-events-none absolute top-0 h-full" style={{ left: `${x}px`, - width: "2px", - zIndex: 1, + width: "1px", + backgroundColor: i === 0 || i === GRID_CONFIG.COLUMNS ? "rgba(59, 130, 246, 0.3)" : "rgba(59, 130, 246, 0.15)", + zIndex: 0, }} /> ))} @@ -248,6 +251,7 @@ export const DashboardCanvas = forwardRef( element={element} isSelected={selectedElement === element.id} cellSize={cellSize} + subGridSize={subGridSize} canvasWidth={canvasWidth} onUpdate={handleUpdateWithCollisionDetection} onRemove={onRemoveElement} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 27115ee1..c0d08083 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -6,6 +6,7 @@ import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; +import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; @@ -140,18 +141,38 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const { dashboardApi } = await import("@/lib/api/dashboard"); const dashboard = await dashboardApi.getDashboard(id); + console.log("📊 대시보드 로드:", { + id: dashboard.id, + title: dashboard.title, + settings: dashboard.settings, + settingsType: typeof dashboard.settings, + }); + // 대시보드 정보 설정 setDashboardId(dashboard.id); setDashboardTitle(dashboard.title); // 저장된 설정 복원 const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings; + console.log("🎨 설정 복원:", { + settings, + resolution: settings?.resolution, + backgroundColor: settings?.backgroundColor, + currentResolution: resolution, + }); + if (settings?.resolution) { setResolution(settings.resolution); + console.log("✅ Resolution 설정됨:", settings.resolution); + } else { + console.log("⚠️ Resolution 없음, 기본값 유지:", resolution); } if (settings?.backgroundColor) { setCanvasBackgroundColor(settings.backgroundColor); + console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor); + } else { + console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor); } // 요소들 설정 @@ -332,21 +353,31 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D try { const { dashboardApi } = await import("@/lib/api/dashboard"); - const elementsData = elements.map((el) => ({ - id: el.id, - type: el.type, - subtype: el.subtype, - position: el.position, - size: el.size, - title: el.title, - customTitle: el.customTitle, - showHeader: el.showHeader, - content: el.content, - dataSource: el.dataSource, - chartConfig: el.chartConfig, - listConfig: el.listConfig, - yardConfig: el.yardConfig, - })); + const elementsData = elements.map((el) => { + // 야드 위젯인 경우 설정 로그 출력 + // if (el.subtype === "yard-management-3d") { + // console.log("💾 야드 위젯 저장:", { + // id: el.id, + // yardConfig: el.yardConfig, + // hasLayoutId: !!el.yardConfig?.layoutId, + // }); + // } + return { + id: el.id, + type: el.type, + subtype: el.subtype, + position: el.position, + size: el.size, + title: el.title, + customTitle: el.customTitle, + showHeader: el.showHeader, + content: el.content, + dataSource: el.dataSource, + chartConfig: el.chartConfig, + listConfig: el.listConfig, + yardConfig: el.yardConfig, + }; + }); let savedDashboard; @@ -495,6 +526,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onClose={closeConfigModal} onSave={saveListWidgetConfig} /> + ) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? ( + ) : ( setTitle(e.target.value)} + onKeyDown={(e) => { + // 모든 키보드 이벤트를 input 필드 내부에서만 처리 + e.stopPropagation(); + }} placeholder="예: 생산 현황 대시보드" className="w-full" /> @@ -195,6 +199,10 @@ export function DashboardSaveModal({ id="description" value={description} onChange={(e) => setDescription(e.target.value)} + onKeyDown={(e) => { + // 모든 키보드 이벤트를 textarea 내부에서만 처리 + e.stopPropagation(); + }} placeholder="대시보드에 대한 간단한 설명을 입력하세요" rows={3} className="w-full resize-none" diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 2f50a874..62c50fdc 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -200,12 +200,13 @@ export function DashboardSidebar() { subtype="todo" onDragStart={handleDragStart} /> - + /> */} {/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */} + +
)} diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 35062400..f2a11b29 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -182,6 +182,7 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 야드 관리 3D + 커스텀 통계 카드 {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} @@ -195,7 +196,7 @@ export function DashboardTopMenu({ 달력 시계 할 일 - 예약 알림 + {/* 예약 알림 */} 정비 일정 문서 리스크 알림 diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index ad4de687..6aba88db 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -36,6 +36,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = + element.subtype === "todo" || // To-Do 위젯 + element.subtype === "booking-alert" || // 예약 알림 위젯 + element.subtype === "maintenance" || // 정비 일정 위젯 + element.subtype === "document" || // 문서 위젯 + element.subtype === "risk-alert" || // 리스크 알림 위젯 element.subtype === "vehicle-status" || element.subtype === "vehicle-list" || element.subtype === "status-summary" || // 커스텀 상태 카드 @@ -45,7 +50,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "delivery-today-stats" || element.subtype === "cargo-list" || element.subtype === "customer-issues" || - element.subtype === "driver-management"; + element.subtype === "driver-management" || + element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요) + element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요) + + // 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능) + const isSelfContainedWidget = + element.subtype === "weather" || // 날씨 위젯 (외부 API) + element.subtype === "exchange" || // 환율 위젯 (외부 API) + element.subtype === "calculator"; // 계산기 위젯 (자체 기능) // 지도 위젯 (위도/경도 매핑 필요) const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; @@ -59,6 +72,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element setQueryResult(null); setCurrentStep(1); setCustomTitle(element.customTitle || ""); + setShowHeader(element.showHeader !== false); // showHeader 초기화 } }, [isOpen, element]); @@ -135,8 +149,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 모달이 열려있지 않으면 렌더링하지 않음 if (!isOpen) return null; - // 시계, 달력, To-Do 위젯은 헤더 설정만 가능 - const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "todo"); + // 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능 + const isHeaderOnlyWidget = + element.type === "widget" && + (element.subtype === "clock" || + element.subtype === "calendar" || + isSelfContainedWidget); // 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음 if (element.type === "widget" && element.subtype === "driver-management") { @@ -154,11 +172,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // customTitle이 변경되었는지 확인 const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); + + // showHeader가 변경되었는지 확인 + const isHeaderChanged = showHeader !== (element.showHeader !== false); const canSave = isTitleChanged || // 제목만 변경해도 저장 가능 + isHeaderChanged || // 헤더 표시 여부만 변경해도 저장 가능 (isSimpleWidget - ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요) currentStep === 2 && queryResult && queryResult.rows.length > 0 : isMapWidget ? // 지도 위젯: 위도/경도 매핑 필요 @@ -184,7 +206,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
{/* 모달 헤더 */} @@ -336,7 +358,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element 저장 ) : currentStep === 1 ? ( - // 1단계: 다음 버튼 + // 1단계: 다음 버튼 (차트 위젯, 간단한 위젯 모두)
); } + diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index e83c9c9b..1335b243 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -208,6 +208,10 @@ ORDER BY 하위부서수 DESC`,