parent
2b7519519a
commit
5b503edfa8
|
|
@ -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 <DB_CONTAINER_NAME> 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` - 테이블 생성 스크립트
|
||||
|
||||
## 🎉 완료!
|
||||
|
||||
작업 이력 관리 시스템이 성공적으로 설치되었습니다!
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -53,6 +53,8 @@ const ALLOWED_TABLES = [
|
|||
"table_labels",
|
||||
"column_labels",
|
||||
"dynamic_form_data",
|
||||
"work_history", // 작업 이력 테이블
|
||||
"delivery_status", // 배송 현황 테이블
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<WorkHistory[]> {
|
||||
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<WorkHistory | null> {
|
||||
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<WorkHistory> {
|
||||
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<WorkHistory> {
|
||||
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<void> {
|
||||
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<WorkHistoryStats> {
|
||||
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<MonthlyTrend[]> {
|
||||
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<TopRoute[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +112,18 @@ const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DW
|
|||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 작업 이력 위젯
|
||||
const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 운송 통계 위젯
|
||||
const TransportStatsWidget = dynamic(() => import("@/components/dashboard/widgets/TransportStatsWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
|
|
@ -732,6 +744,16 @@ export function CanvasElement({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "work-history" ? (
|
||||
// 작업 이력 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<WorkHistoryWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "transport-stats" ? (
|
||||
// 운송 통계 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<TransportStatsWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||
// To-Do 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
|
|
|
|||
|
|
@ -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 "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,18 @@ export function DashboardSidebar() {
|
|||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
title="작업 이력"
|
||||
type="widget"
|
||||
subtype="work-history"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
title="운송 통계"
|
||||
type="widget"
|
||||
subtype="transport-stats"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export function DashboardTopMenu({
|
|||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
<SelectItem value="transport-stats">운송 통계</SelectItem>
|
||||
{/* <SelectItem value="map">지도</SelectItem> */}
|
||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -208,6 +208,10 @@ ORDER BY 하위부서수 DESC`,
|
|||
<Textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 모든 키보드 이벤트를 textarea 내부에서만 처리
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||
className="h-40 resize-none font-mono text-sm"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export type ElementSubtype =
|
|||
| "maintenance"
|
||||
| "document"
|
||||
| "list"
|
||||
| "yard-management-3d"; // 야드 관리 3D 위젯
|
||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||
| "work-history" // 작업 이력 위젯
|
||||
| "transport-stats"; // 운송 통계 위젯
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboar
|
|||
ssr: false,
|
||||
});
|
||||
|
||||
const WorkHistoryWidget = dynamic(() => import("./widgets/WorkHistoryWidget"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const TransportStatsWidget = dynamic(() => import("./widgets/TransportStatsWidget"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
||||
* ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의
|
||||
|
|
@ -92,6 +100,12 @@ function renderWidget(element: DashboardElement) {
|
|||
});
|
||||
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
|
||||
|
||||
case "work-history":
|
||||
return <WorkHistoryWidget element={element} />;
|
||||
|
||||
case "transport-stats":
|
||||
return <TransportStatsWidget element={element} />;
|
||||
|
||||
// === 차량 관련 (추가 위젯) ===
|
||||
case "vehicle-status":
|
||||
return <VehicleStatusWidget element={element} />;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* 운송 통계 위젯
|
||||
* - 총 운송량 (톤)
|
||||
* - 누적 거리 (km)
|
||||
* - 정시 도착률 (%)
|
||||
* - 쿼리 결과 기반 통계 계산
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface TransportStatsWidgetProps {
|
||||
element?: DashboardElement;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
interface StatsData {
|
||||
total_count: number;
|
||||
total_weight: number;
|
||||
total_distance: number;
|
||||
on_time_rate: number;
|
||||
}
|
||||
|
||||
export default function TransportStatsWidget({ element, refreshInterval = 60000 }: TransportStatsWidgetProps) {
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 쿼리가 설정되어 있지 않으면 안내 메시지만 표시
|
||||
if (!element?.dataSource?.query) {
|
||||
setError("쿼리를 설정해주세요");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 실행하여 통계 계산
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: element.dataSource.query,
|
||||
connectionType: element.dataSource.connectionType || "current",
|
||||
connectionId: element.dataSource.connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.data?.rows) {
|
||||
throw new Error(result.message || "데이터 로드 실패");
|
||||
}
|
||||
|
||||
const data = result.data.rows || [];
|
||||
|
||||
if (data.length === 0) {
|
||||
setStats({ total_count: 0, total_weight: 0, total_distance: 0, on_time_rate: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// 자동으로 숫자 컬럼 감지 및 합계 계산
|
||||
const firstRow = data[0];
|
||||
const numericColumns: { [key: string]: number } = {};
|
||||
|
||||
// 모든 컬럼을 순회하며 숫자 컬럼 찾기
|
||||
Object.keys(firstRow).forEach((key) => {
|
||||
const value = firstRow[key];
|
||||
// 숫자로 변환 가능한 컬럼만 선택
|
||||
if (value !== null && !isNaN(parseFloat(value))) {
|
||||
numericColumns[key] = data.reduce((sum: number, item: any) => {
|
||||
return sum + (parseFloat(item[key]) || 0);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 키워드를 포함한 컬럼 자동 매핑
|
||||
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
|
||||
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
|
||||
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
|
||||
|
||||
// 총 운송량 찾기
|
||||
let total_weight = 0;
|
||||
for (const key of Object.keys(numericColumns)) {
|
||||
if (weightKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
total_weight = numericColumns[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 누적 거리 찾기
|
||||
let total_distance = 0;
|
||||
for (const key of Object.keys(numericColumns)) {
|
||||
if (distanceKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
total_distance = numericColumns[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 정시 도착률 계산
|
||||
let on_time_rate = 0;
|
||||
for (const key of Object.keys(firstRow)) {
|
||||
if (onTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
const onTimeItems = data.filter((item: any) => {
|
||||
const onTime = item[key];
|
||||
return onTime !== null && onTime !== undefined;
|
||||
});
|
||||
|
||||
if (onTimeItems.length > 0) {
|
||||
const onTimeCount = onTimeItems.filter((item: any) => {
|
||||
const onTime = item[key];
|
||||
return onTime === true || onTime === "true" || onTime === 1 || onTime === "1";
|
||||
}).length;
|
||||
|
||||
on_time_rate = (onTimeCount / onTimeItems.length) * 100;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const calculatedStats: StatsData = {
|
||||
total_count: data.length, // 총 건수
|
||||
total_weight,
|
||||
total_distance,
|
||||
on_time_rate,
|
||||
};
|
||||
|
||||
setStats(calculatedStats);
|
||||
} catch (err) {
|
||||
console.error("통계 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval, element?.dataSource]);
|
||||
|
||||
if (isLoading && !stats) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error || "데이터 없음"}</div>
|
||||
{!element?.dataSource?.query && (
|
||||
<div className="mt-2 text-xs text-gray-500">톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요</div>
|
||||
)}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white p-6">
|
||||
<div className="grid w-full grid-cols-2 gap-4">
|
||||
{/* 총 건수 */}
|
||||
<div className="rounded-lg border bg-indigo-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 건수</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600">
|
||||
{stats.total_count.toLocaleString()}
|
||||
<span className="ml-1 text-lg">건</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 운송량 */}
|
||||
<div className="rounded-lg border bg-green-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 운송량</div>
|
||||
<div className="mt-2 text-3xl font-bold text-green-600">
|
||||
{stats.total_weight.toFixed(1)}
|
||||
<span className="ml-1 text-lg">톤</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 누적 거리 */}
|
||||
<div className="rounded-lg border bg-blue-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">누적 거리</div>
|
||||
<div className="mt-2 text-3xl font-bold text-blue-600">
|
||||
{stats.total_distance.toFixed(1)}
|
||||
<span className="ml-1 text-lg">km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정시 도착률 */}
|
||||
<div className="rounded-lg border bg-purple-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">정시 도착률</div>
|
||||
<div className="mt-2 text-3xl font-bold text-purple-600">
|
||||
{stats.on_time_rate.toFixed(1)}
|
||||
<span className="ml-1 text-lg">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* 작업 이력 위젯
|
||||
* - 작업 이력 목록 표시
|
||||
* - 필터링 기능
|
||||
* - 상태별 색상 구분
|
||||
* - 쿼리 결과 기반 데이터 표시
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import {
|
||||
WORK_TYPE_LABELS,
|
||||
WORK_STATUS_LABELS,
|
||||
WORK_STATUS_COLORS,
|
||||
WorkType,
|
||||
WorkStatus,
|
||||
} from "@/types/workHistory";
|
||||
|
||||
interface WorkHistoryWidgetProps {
|
||||
element: DashboardElement;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function WorkHistoryWidget({ element, refreshInterval = 60000 }: WorkHistoryWidgetProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<WorkType | "all">("all");
|
||||
const [selectedStatus, setSelectedStatus] = useState<WorkStatus | "all">("all");
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 쿼리가 설정되어 있으면 쿼리 실행
|
||||
if (element.dataSource?.query) {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: element.dataSource.query,
|
||||
connectionType: element.dataSource.connectionType || "current",
|
||||
connectionId: element.dataSource.connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
setData(result.data.rows);
|
||||
} else {
|
||||
throw new Error(result.message || "데이터 로드 실패");
|
||||
}
|
||||
} else {
|
||||
// 쿼리 미설정 시 안내 메시지
|
||||
setError("쿼리를 설정해주세요");
|
||||
setData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("작업 이력 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedType, selectedStatus, refreshInterval, element.dataSource]);
|
||||
|
||||
if (isLoading && data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||
{!element.dataSource?.query && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-2 border-b p-3">
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value as WorkType | "all")}
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">전체 유형</option>
|
||||
<option value="inbound">입고</option>
|
||||
<option value="outbound">출고</option>
|
||||
<option value="transfer">이송</option>
|
||||
<option value="maintenance">정비</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as WorkStatus | "all")}
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">전체 상태</option>
|
||||
<option value="pending">대기</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="cancelled">취소</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="ml-auto rounded bg-blue-500 px-3 py-1 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-gray-50 text-left">
|
||||
<tr>
|
||||
<th className="border-b px-3 py-2 font-medium">작업번호</th>
|
||||
<th className="border-b px-3 py-2 font-medium">일시</th>
|
||||
<th className="border-b px-3 py-2 font-medium">유형</th>
|
||||
<th className="border-b px-3 py-2 font-medium">차량</th>
|
||||
<th className="border-b px-3 py-2 font-medium">경로</th>
|
||||
<th className="border-b px-3 py-2 font-medium">화물</th>
|
||||
<th className="border-b px-3 py-2 font-medium">중량</th>
|
||||
<th className="border-b px-3 py-2 font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-gray-500">
|
||||
작업 이력이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data
|
||||
.filter((item) => selectedType === "all" || item.work_type === selectedType)
|
||||
.filter((item) => selectedStatus === "all" || item.status === selectedStatus)
|
||||
.map((item, index) => (
|
||||
<tr key={item.id || index} className="border-b hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.work_number}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.work_date
|
||||
? new Date(item.work_date).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
{WORK_TYPE_LABELS[item.work_type as WorkType] || item.work_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.vehicle_number || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{item.origin && item.destination ? `${item.origin} → ${item.destination}` : "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.cargo_name || "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.cargo_weight ? `${item.cargo_weight} ${item.cargo_unit || "ton"}` : "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${WORK_STATUS_COLORS[item.status as WorkStatus] || "bg-gray-100 text-gray-800"}`}
|
||||
>
|
||||
{WORK_STATUS_LABELS[item.status as WorkStatus] || item.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t bg-gray-50 px-3 py-2 text-xs text-gray-600">전체 {data.length}건</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 작업 이력 관리 타입 정의 (프론트엔드)
|
||||
*/
|
||||
|
||||
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: string;
|
||||
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?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
estimated_arrival?: string;
|
||||
actual_arrival?: string;
|
||||
is_on_time?: boolean;
|
||||
delay_reason?: string;
|
||||
notes?: string;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
updated_at: 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;
|
||||
}
|
||||
|
||||
// 한글 라벨
|
||||
export const WORK_TYPE_LABELS: Record<WorkType, string> = {
|
||||
inbound: '입고',
|
||||
outbound: '출고',
|
||||
transfer: '이송',
|
||||
maintenance: '정비',
|
||||
};
|
||||
|
||||
export const WORK_STATUS_LABELS: Record<WorkStatus, string> = {
|
||||
pending: '대기',
|
||||
in_progress: '진행중',
|
||||
completed: '완료',
|
||||
cancelled: '취소',
|
||||
};
|
||||
|
||||
export const WORK_STATUS_COLORS: Record<WorkStatus, string> = {
|
||||
pending: 'bg-gray-100 text-gray-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue