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/src/app.ts b/backend-node/src/app.ts index 0e41697f..caa010b4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -56,6 +56,7 @@ import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D +import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -206,6 +207,7 @@ app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D +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/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/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 070116f0..66b9d65f 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -112,6 +112,18 @@ const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DW loading: () =>
로딩 중...
, }); +// 작업 이력 위젯 +const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 운송 통계 위젯 +const TransportStatsWidget = dynamic(() => import("@/components/dashboard/widgets/TransportStatsWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; @@ -732,6 +744,16 @@ export function CanvasElement({ }} /> + ) : 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/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 691da506..f943375f 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -644,6 +644,10 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "문서 위젯"; case "yard-management-3d": return "야드 관리 3D"; + case "work-history": + return "작업 이력"; + case "transport-stats": + return "운송 통계"; default: return "위젯"; } @@ -686,6 +690,10 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "list-widget"; case "yard-management-3d": return "yard-3d"; + case "work-history": + return "work-history"; + case "transport-stats": + return "transport-stats"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardSaveModal.tsx b/frontend/components/admin/dashboard/DashboardSaveModal.tsx index 49d6ad7c..28e6e7d7 100644 --- a/frontend/components/admin/dashboard/DashboardSaveModal.tsx +++ b/frontend/components/admin/dashboard/DashboardSaveModal.tsx @@ -183,6 +183,10 @@ export function DashboardSaveModal({ id="title" value={title} onChange={(e) => 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..d11decc1 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -219,6 +219,18 @@ export function DashboardSidebar() { subtype="list" onDragStart={handleDragStart} /> + +
)} diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 35062400..f88933e3 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -182,6 +182,7 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 야드 관리 3D + 운송 통계 {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index fdfcc2c2..93796257 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -50,7 +50,9 @@ 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 = 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`,