Merge pull request '공차관련수정사항들' (#234) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/234
This commit is contained in:
commit
650c5ef722
|
|
@ -72,6 +72,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
|
|||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -238,6 +239,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
|||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -708,6 +708,12 @@ export class DashboardController {
|
|||
});
|
||||
}
|
||||
|
||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||
const isKmaApi = urlObj.hostname.includes('kma.go.kr');
|
||||
if (isKmaApi) {
|
||||
requestConfig.responseType = 'arraybuffer';
|
||||
}
|
||||
|
||||
const response = await axios(requestConfig);
|
||||
|
||||
if (response.status >= 400) {
|
||||
|
|
@ -719,8 +725,24 @@ export class DashboardController {
|
|||
let data = response.data;
|
||||
const contentType = response.headers["content-type"];
|
||||
|
||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||
const iconv = require('iconv-lite');
|
||||
const buffer = Buffer.from(data);
|
||||
const utf8Text = buffer.toString('utf-8');
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인
|
||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||
data = { text: utf8Text, contentType, encoding: 'utf-8' };
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
const eucKrText = iconv.decode(buffer, 'EUC-KR');
|
||||
data = { text: eucKrText, contentType, encoding: 'euc-kr' };
|
||||
}
|
||||
}
|
||||
// 텍스트 응답인 경우 포맷팅
|
||||
if (typeof data === "string") {
|
||||
else if (typeof data === "string") {
|
||||
data = { text: data, contentType };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* 차량 운행 리포트 컨트롤러
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { vehicleReportService } from "../services/vehicleReportService";
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
* GET /api/vehicle/reports/daily
|
||||
*/
|
||||
export const getDailyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getDailyReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getDailyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "일별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
* GET /api/vehicle/reports/weekly
|
||||
*/
|
||||
export const getWeeklyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { year, month, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month });
|
||||
|
||||
const result = await vehicleReportService.getWeeklyReport(companyCode, {
|
||||
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||
month: month ? parseInt(month as string) : new Date().getMonth() + 1,
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getWeeklyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "주별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
* GET /api/vehicle/reports/monthly
|
||||
*/
|
||||
export const getMonthlyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { year, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getMonthlyReport] 요청:", { companyCode, year });
|
||||
|
||||
const result = await vehicleReportService.getMonthlyReport(companyCode, {
|
||||
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getMonthlyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "월별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
* GET /api/vehicle/reports/summary
|
||||
*/
|
||||
export const getSummaryReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { period } = req.query; // today, week, month, year
|
||||
|
||||
console.log("📊 [getSummaryReport] 요청:", { companyCode, period });
|
||||
|
||||
const result = await vehicleReportService.getSummaryReport(
|
||||
companyCode,
|
||||
(period as string) || "today"
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getSummaryReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "요약 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
* GET /api/vehicle/reports/by-driver
|
||||
*/
|
||||
export const getDriverReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getDriverReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getDriverReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운전자별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
* GET /api/vehicle/reports/by-route
|
||||
*/
|
||||
export const getRouteReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getRouteReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getRouteReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "구간별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
/**
|
||||
* 차량 운행 이력 컨트롤러
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { vehicleTripService } from "../services/vehicleTripService";
|
||||
|
||||
/**
|
||||
* 운행 시작
|
||||
* POST /api/vehicle/trip/start
|
||||
*/
|
||||
export const startTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body;
|
||||
|
||||
console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival });
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.startTrip({
|
||||
userId,
|
||||
companyCode,
|
||||
vehicleId,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
console.log("✅ [startTrip] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "운행이 시작되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [startTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 시작에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 종료
|
||||
* POST /api/vehicle/trip/end
|
||||
*/
|
||||
export const endTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tripId, latitude, longitude } = req.body;
|
||||
|
||||
console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId });
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.endTrip({
|
||||
tripId,
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
console.log("✅ [endTrip] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "운행이 종료되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [endTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 종료에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 기록 추가 (연속 추적)
|
||||
* POST /api/vehicle/trip/location
|
||||
*/
|
||||
export const addTripLocation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tripId, latitude, longitude, accuracy, speed } = req.body;
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.addLocation({
|
||||
tripId,
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
speed,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [addTripLocation] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 기록에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 이력 목록 조회
|
||||
* GET /api/vehicle/trips
|
||||
*/
|
||||
export const getTripList = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query;
|
||||
|
||||
console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate });
|
||||
|
||||
const result = await vehicleTripService.getTripList(companyCode, {
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
status: status as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
departure: departure as string,
|
||||
arrival: arrival as string,
|
||||
limit: limit ? parseInt(limit as string) : 50,
|
||||
offset: offset ? parseInt(offset as string) : 0,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getTripList] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 이력 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 상세 조회 (경로 포함)
|
||||
* GET /api/vehicle/trips/:tripId
|
||||
*/
|
||||
export const getTripDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.params;
|
||||
|
||||
console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId });
|
||||
|
||||
const result = await vehicleTripService.getTripDetail(tripId, companyCode);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "운행 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getTripDetail] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 상세 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 활성 운행 조회 (현재 진행 중)
|
||||
* GET /api/vehicle/trip/active
|
||||
*/
|
||||
export const getActiveTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
|
||||
const result = await vehicleTripService.getActiveTrip(userId, companyCode);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
hasActiveTrip: !!result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getActiveTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "활성 운행 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 취소
|
||||
* POST /api/vehicle/trip/cancel
|
||||
*/
|
||||
export const cancelTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.body;
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.cancelTrip(tripId, companyCode);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "취소할 운행을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "운행이 취소되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [cancelTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 취소에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* 차량 운행 이력 및 리포트 라우트
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import {
|
||||
startTrip,
|
||||
endTrip,
|
||||
addTripLocation,
|
||||
getTripList,
|
||||
getTripDetail,
|
||||
getActiveTrip,
|
||||
cancelTrip,
|
||||
} from "../controllers/vehicleTripController";
|
||||
import {
|
||||
getDailyReport,
|
||||
getWeeklyReport,
|
||||
getMonthlyReport,
|
||||
getSummaryReport,
|
||||
getDriverReport,
|
||||
getRouteReport,
|
||||
} from "../controllers/vehicleReportController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// === 운행 관리 ===
|
||||
// 운행 시작
|
||||
router.post("/trip/start", startTrip);
|
||||
|
||||
// 운행 종료
|
||||
router.post("/trip/end", endTrip);
|
||||
|
||||
// 위치 기록 추가 (연속 추적)
|
||||
router.post("/trip/location", addTripLocation);
|
||||
|
||||
// 활성 운행 조회 (현재 진행 중)
|
||||
router.get("/trip/active", getActiveTrip);
|
||||
|
||||
// 운행 취소
|
||||
router.post("/trip/cancel", cancelTrip);
|
||||
|
||||
// 운행 이력 목록 조회
|
||||
router.get("/trips", getTripList);
|
||||
|
||||
// 운행 상세 조회 (경로 포함)
|
||||
router.get("/trips/:tripId", getTripDetail);
|
||||
|
||||
// === 리포트 ===
|
||||
// 요약 통계 (대시보드용)
|
||||
router.get("/reports/summary", getSummaryReport);
|
||||
|
||||
// 일별 통계
|
||||
router.get("/reports/daily", getDailyReport);
|
||||
|
||||
// 주별 통계
|
||||
router.get("/reports/weekly", getWeeklyReport);
|
||||
|
||||
// 월별 통계
|
||||
router.get("/reports/monthly", getMonthlyReport);
|
||||
|
||||
// 운전자별 통계
|
||||
router.get("/reports/by-driver", getDriverReport);
|
||||
|
||||
// 구간별 통계
|
||||
router.get("/reports/by-route", getRouteReport);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -47,9 +47,24 @@ export class RiskAlertService {
|
|||
|
||||
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
||||
|
||||
// 텍스트 응답 파싱 (EUC-KR 인코딩)
|
||||
// 텍스트 응답 파싱 (인코딩 자동 감지)
|
||||
const iconv = require('iconv-lite');
|
||||
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR');
|
||||
const buffer = Buffer.from(warningResponse.data);
|
||||
|
||||
// UTF-8 먼저 시도, 실패하면 EUC-KR 시도
|
||||
let responseText: string;
|
||||
const utf8Text = buffer.toString('utf-8');
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지)
|
||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||
responseText = utf8Text;
|
||||
console.log('📝 UTF-8 인코딩으로 디코딩');
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
responseText = iconv.decode(buffer, 'EUC-KR');
|
||||
console.log('📝 EUC-KR 인코딩으로 디코딩');
|
||||
}
|
||||
|
||||
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
||||
const lines = responseText.split('\n');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
/**
|
||||
* 차량 운행 리포트 서비스
|
||||
*/
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
interface DailyReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface WeeklyReportFilters {
|
||||
year: number;
|
||||
month: number;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface MonthlyReportFilters {
|
||||
year: number;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface DriverReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface RouteReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class VehicleReportService {
|
||||
private get pool() {
|
||||
return getPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
*/
|
||||
async getDailyReport(companyCode: string, filters: DailyReportFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 기본값: 최근 30일
|
||||
const endDate = filters.endDate || new Date().toISOString().split("T")[0];
|
||||
const startDate =
|
||||
filters.startDate ||
|
||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
|
||||
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||
params.push(startDate);
|
||||
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||
params.push(endDate);
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
|
||||
if (filters.vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(filters.vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
DATE(start_time) as date,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY DATE(start_time)
|
||||
ORDER BY DATE(start_time) DESC
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
data: result.rows.map((row) => ({
|
||||
date: row.date,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
cancelledCount: parseInt(row.cancelled_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
*/
|
||||
async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) {
|
||||
const { year, month, userId, vehicleId } = filters;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||
params.push(year);
|
||||
conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`);
|
||||
params.push(month);
|
||||
|
||||
if (userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
EXTRACT(WEEK FROM start_time) as week_number,
|
||||
MIN(DATE(start_time)) as week_start,
|
||||
MAX(DATE(start_time)) as week_end,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY EXTRACT(WEEK FROM start_time)
|
||||
ORDER BY week_number
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
data: result.rows.map((row) => ({
|
||||
weekNumber: parseInt(row.week_number),
|
||||
weekStart: row.week_start,
|
||||
weekEnd: row.week_end,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) {
|
||||
const { year, userId, vehicleId } = filters;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||
params.push(year);
|
||||
|
||||
if (userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM start_time) as month,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||
COUNT(DISTINCT user_id) as driver_count
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY EXTRACT(MONTH FROM start_time)
|
||||
ORDER BY month
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
year,
|
||||
data: result.rows.map((row) => ({
|
||||
month: parseInt(row.month),
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
cancelledCount: parseInt(row.cancelled_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
driverCount: parseInt(row.driver_count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
*/
|
||||
async getSummaryReport(companyCode: string, period: string) {
|
||||
let dateCondition = "";
|
||||
|
||||
switch (period) {
|
||||
case "today":
|
||||
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||
break;
|
||||
case "week":
|
||||
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'";
|
||||
break;
|
||||
case "month":
|
||||
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'";
|
||||
break;
|
||||
case "year":
|
||||
dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)";
|
||||
break;
|
||||
default:
|
||||
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_trips,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips,
|
||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||
COUNT(DISTINCT user_id) as active_drivers
|
||||
FROM vehicle_trip_summary
|
||||
WHERE company_code = $1 AND ${dateCondition}
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [companyCode]);
|
||||
const row = result.rows[0];
|
||||
|
||||
// 완료율 계산
|
||||
const totalTrips = parseInt(row.total_trips) || 0;
|
||||
const completedTrips = parseInt(row.completed_trips) || 0;
|
||||
const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0;
|
||||
|
||||
return {
|
||||
period,
|
||||
totalTrips,
|
||||
completedTrips,
|
||||
activeTrips: parseInt(row.active_trips) || 0,
|
||||
cancelledTrips: parseInt(row.cancelled_trips) || 0,
|
||||
completionRate: parseFloat(completionRate.toFixed(1)),
|
||||
totalDistance: parseFloat(row.total_distance) || 0,
|
||||
totalDuration: parseInt(row.total_duration) || 0,
|
||||
avgDistance: parseFloat(row.avg_distance) || 0,
|
||||
avgDuration: parseFloat(row.avg_duration) || 0,
|
||||
activeDrivers: parseInt(row.active_drivers) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
*/
|
||||
async getDriverReport(companyCode: string, filters: DriverReportFilters) {
|
||||
const conditions: string[] = ["vts.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`);
|
||||
params.push(filters.endDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
vts.user_id,
|
||||
ui.user_name,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance
|
||||
FROM vehicle_trip_summary vts
|
||||
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY vts.user_id, ui.user_name
|
||||
ORDER BY total_distance DESC
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit);
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
userId: row.user_id,
|
||||
userName: row.user_name || row.user_id,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
*/
|
||||
async getRouteReport(companyCode: string, filters: RouteReportFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||
params.push(filters.endDate);
|
||||
}
|
||||
|
||||
// 출발지/도착지가 있는 것만
|
||||
conditions.push("departure IS NOT NULL");
|
||||
conditions.push("arrival IS NOT NULL");
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
departure,
|
||||
arrival,
|
||||
departure_name,
|
||||
destination_name,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY departure, arrival, departure_name, destination_name
|
||||
ORDER BY trip_count DESC
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit);
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
departure: row.departure,
|
||||
arrival: row.arrival,
|
||||
departureName: row.departure_name || row.departure,
|
||||
destinationName: row.destination_name || row.arrival,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const vehicleReportService = new VehicleReportService();
|
||||
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* 차량 운행 이력 서비스
|
||||
*/
|
||||
import { getPool } from "../database/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { calculateDistance } from "../utils/geoUtils";
|
||||
|
||||
interface StartTripParams {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
vehicleId?: number;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
departureName?: string;
|
||||
destinationName?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface EndTripParams {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface AddLocationParams {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
interface TripListFilters {
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
class VehicleTripService {
|
||||
private get pool() {
|
||||
return getPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 시작
|
||||
*/
|
||||
async startTrip(params: StartTripParams) {
|
||||
const {
|
||||
userId,
|
||||
companyCode,
|
||||
vehicleId,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
latitude,
|
||||
longitude,
|
||||
} = params;
|
||||
|
||||
const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`;
|
||||
|
||||
// 1. vehicle_trip_summary에 운행 기록 생성
|
||||
const summaryQuery = `
|
||||
INSERT INTO vehicle_trip_summary (
|
||||
trip_id, user_id, vehicle_id, departure, arrival,
|
||||
departure_name, destination_name, start_time, status, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const summaryResult = await this.pool.query(summaryQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
vehicleId || null,
|
||||
departure || null,
|
||||
arrival || null,
|
||||
departureName || null,
|
||||
destinationName || null,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 2. 시작 위치 기록
|
||||
const locationQuery = `
|
||||
INSERT INTO vehicle_location_history (
|
||||
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||
trip_status, departure, arrival, departure_name, destination_name,
|
||||
recorded_at, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
await this.pool.query(locationQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
vehicleId || null,
|
||||
latitude,
|
||||
longitude,
|
||||
departure || null,
|
||||
arrival || null,
|
||||
departureName || null,
|
||||
destinationName || null,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
return {
|
||||
tripId,
|
||||
summary: summaryResult.rows[0],
|
||||
startLocation: { latitude, longitude },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 종료
|
||||
*/
|
||||
async endTrip(params: EndTripParams) {
|
||||
const { tripId, userId, companyCode, latitude, longitude } = params;
|
||||
|
||||
// 1. 운행 정보 조회
|
||||
const tripQuery = `
|
||||
SELECT * FROM vehicle_trip_summary
|
||||
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||
`;
|
||||
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
||||
|
||||
if (tripResult.rows.length === 0) {
|
||||
throw new Error("활성 운행을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const trip = tripResult.rows[0];
|
||||
|
||||
// 2. 마지막 위치 기록
|
||||
const locationQuery = `
|
||||
INSERT INTO vehicle_location_history (
|
||||
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||
trip_status, departure, arrival, departure_name, destination_name,
|
||||
recorded_at, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
await this.pool.query(locationQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
trip.vehicle_id,
|
||||
latitude,
|
||||
longitude,
|
||||
trip.departure,
|
||||
trip.arrival,
|
||||
trip.departure_name,
|
||||
trip.destination_name,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 3. 총 거리 및 위치 수 계산
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as location_count,
|
||||
MIN(recorded_at) as start_time,
|
||||
MAX(recorded_at) as end_time
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
`;
|
||||
const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]);
|
||||
const stats = statsResult.rows[0];
|
||||
|
||||
// 4. 모든 위치 데이터로 총 거리 계산
|
||||
const locationsQuery = `
|
||||
SELECT latitude, longitude
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
ORDER BY recorded_at ASC
|
||||
`;
|
||||
const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]);
|
||||
|
||||
let totalDistance = 0;
|
||||
const locations = locationsResult.rows;
|
||||
for (let i = 1; i < locations.length; i++) {
|
||||
const prev = locations[i - 1];
|
||||
const curr = locations[i];
|
||||
totalDistance += calculateDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
curr.latitude,
|
||||
curr.longitude
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 운행 시간 계산 (분)
|
||||
const startTime = new Date(stats.start_time);
|
||||
const endTime = new Date(stats.end_time);
|
||||
const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
|
||||
|
||||
// 6. 운행 요약 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE vehicle_trip_summary
|
||||
SET
|
||||
end_time = NOW(),
|
||||
total_distance = $1,
|
||||
duration_minutes = $2,
|
||||
location_count = $3,
|
||||
status = 'completed'
|
||||
WHERE trip_id = $4 AND company_code = $5
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const updateResult = await this.pool.query(updateQuery, [
|
||||
totalDistance.toFixed(3),
|
||||
durationMinutes,
|
||||
stats.location_count,
|
||||
tripId,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
return {
|
||||
tripId,
|
||||
summary: updateResult.rows[0],
|
||||
totalDistance: parseFloat(totalDistance.toFixed(3)),
|
||||
durationMinutes,
|
||||
locationCount: parseInt(stats.location_count),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 기록 추가 (연속 추적)
|
||||
*/
|
||||
async addLocation(params: AddLocationParams) {
|
||||
const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params;
|
||||
|
||||
// 1. 운행 정보 조회
|
||||
const tripQuery = `
|
||||
SELECT * FROM vehicle_trip_summary
|
||||
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||
`;
|
||||
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
||||
|
||||
if (tripResult.rows.length === 0) {
|
||||
throw new Error("활성 운행을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const trip = tripResult.rows[0];
|
||||
|
||||
// 2. 이전 위치 조회 (거리 계산용)
|
||||
const prevLocationQuery = `
|
||||
SELECT latitude, longitude
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]);
|
||||
|
||||
let distanceFromPrev = 0;
|
||||
if (prevResult.rows.length > 0) {
|
||||
const prev = prevResult.rows[0];
|
||||
distanceFromPrev = calculateDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
latitude,
|
||||
longitude
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 위치 기록 추가
|
||||
const locationQuery = `
|
||||
INSERT INTO vehicle_location_history (
|
||||
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||
accuracy, speed, distance_from_prev,
|
||||
trip_status, departure, arrival, departure_name, destination_name,
|
||||
recorded_at, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(locationQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
trip.vehicle_id,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy || null,
|
||||
speed || null,
|
||||
distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null,
|
||||
trip.departure,
|
||||
trip.arrival,
|
||||
trip.departure_name,
|
||||
trip.destination_name,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 4. 운행 요약의 위치 수 업데이트
|
||||
await this.pool.query(
|
||||
`UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`,
|
||||
[tripId]
|
||||
);
|
||||
|
||||
return {
|
||||
locationId: result.rows[0].id,
|
||||
distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 이력 목록 조회
|
||||
*/
|
||||
async getTripList(companyCode: string, filters: TripListFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
|
||||
if (filters.vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(filters.vehicleId);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`start_time >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`start_time <= $${paramIndex++}`);
|
||||
params.push(filters.endDate + " 23:59:59");
|
||||
}
|
||||
|
||||
if (filters.departure) {
|
||||
conditions.push(`departure = $${paramIndex++}`);
|
||||
params.push(filters.departure);
|
||||
}
|
||||
|
||||
if (filters.arrival) {
|
||||
conditions.push(`arrival = $${paramIndex++}`);
|
||||
params.push(filters.arrival);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// 총 개수 조회
|
||||
const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`;
|
||||
const countResult = await this.pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
// 목록 조회
|
||||
const limit = filters.limit || 50;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
const listQuery = `
|
||||
SELECT
|
||||
vts.*,
|
||||
ui.user_name,
|
||||
v.vehicle_number
|
||||
FROM vehicle_trip_summary vts
|
||||
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY vts.start_time DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
const listResult = await this.pool.query(listQuery, params);
|
||||
|
||||
return {
|
||||
data: listResult.rows,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 상세 조회 (경로 포함)
|
||||
*/
|
||||
async getTripDetail(tripId: string, companyCode: string) {
|
||||
// 1. 운행 요약 조회
|
||||
const summaryQuery = `
|
||||
SELECT
|
||||
vts.*,
|
||||
ui.user_name,
|
||||
v.vehicle_number
|
||||
FROM vehicle_trip_summary vts
|
||||
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
||||
WHERE vts.trip_id = $1 AND vts.company_code = $2
|
||||
`;
|
||||
const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]);
|
||||
|
||||
if (summaryResult.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 경로 데이터 조회
|
||||
const routeQuery = `
|
||||
SELECT
|
||||
id, latitude, longitude, accuracy, speed,
|
||||
distance_from_prev, trip_status, recorded_at
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
ORDER BY recorded_at ASC
|
||||
`;
|
||||
const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]);
|
||||
|
||||
return {
|
||||
summary: summaryResult.rows[0],
|
||||
route: routeResult.rows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 운행 조회
|
||||
*/
|
||||
async getActiveTrip(userId: string, companyCode: string) {
|
||||
const query = `
|
||||
SELECT * FROM vehicle_trip_summary
|
||||
WHERE user_id = $1 AND company_code = $2 AND status = 'active'
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await this.pool.query(query, [userId, companyCode]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 취소
|
||||
*/
|
||||
async cancelTrip(tripId: string, companyCode: string) {
|
||||
const query = `
|
||||
UPDATE vehicle_trip_summary
|
||||
SET status = 'cancelled', end_time = NOW()
|
||||
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await this.pool.query(query, [tripId, companyCode]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
export const vehicleTripService = new VehicleTripService();
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* 지리 좌표 관련 유틸리티 함수
|
||||
*/
|
||||
|
||||
/**
|
||||
* Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km)
|
||||
*
|
||||
* @param lat1 - 첫 번째 지점의 위도
|
||||
* @param lon1 - 첫 번째 지점의 경도
|
||||
* @param lat2 - 두 번째 지점의 위도
|
||||
* @param lon2 - 두 번째 지점의 경도
|
||||
* @returns 두 지점 간의 거리 (km)
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // 지구 반경 (km)
|
||||
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRadians(lat1)) *
|
||||
Math.cos(toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 각도를 라디안으로 변환
|
||||
*/
|
||||
function toRadians(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* 라디안을 각도로 변환
|
||||
*/
|
||||
export function toDegrees(radians: number): number {
|
||||
return radians * (180 / Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열에서 총 거리 계산
|
||||
*
|
||||
* @param coordinates - { latitude, longitude }[] 형태의 좌표 배열
|
||||
* @returns 총 거리 (km)
|
||||
*/
|
||||
export function calculateTotalDistance(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): number {
|
||||
let totalDistance = 0;
|
||||
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const prev = coordinates[i - 1];
|
||||
const curr = coordinates[i];
|
||||
totalDistance += calculateDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
curr.latitude,
|
||||
curr.longitude
|
||||
);
|
||||
}
|
||||
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표가 특정 반경 내에 있는지 확인
|
||||
*
|
||||
* @param centerLat - 중심점 위도
|
||||
* @param centerLon - 중심점 경도
|
||||
* @param pointLat - 확인할 지점의 위도
|
||||
* @param pointLon - 확인할 지점의 경도
|
||||
* @param radiusKm - 반경 (km)
|
||||
* @returns 반경 내에 있으면 true
|
||||
*/
|
||||
export function isWithinRadius(
|
||||
centerLat: number,
|
||||
centerLon: number,
|
||||
pointLat: number,
|
||||
pointLon: number,
|
||||
radiusKm: number
|
||||
): boolean {
|
||||
const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon);
|
||||
return distance <= radiusKm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 좌표 사이의 방위각(bearing) 계산
|
||||
*
|
||||
* @param lat1 - 시작점 위도
|
||||
* @param lon1 - 시작점 경도
|
||||
* @param lat2 - 도착점 위도
|
||||
* @param lon2 - 도착점 경도
|
||||
* @returns 방위각 (0-360도)
|
||||
*/
|
||||
export function calculateBearing(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const lat1Rad = toRadians(lat1);
|
||||
const lat2Rad = toRadians(lat2);
|
||||
|
||||
const x = Math.sin(dLon) * Math.cos(lat2Rad);
|
||||
const y =
|
||||
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
|
||||
|
||||
let bearing = toDegrees(Math.atan2(x, y));
|
||||
bearing = (bearing + 360) % 360; // 0-360 범위로 정규화
|
||||
|
||||
return bearing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열의 경계 상자(bounding box) 계산
|
||||
*
|
||||
* @param coordinates - 좌표 배열
|
||||
* @returns { minLat, maxLat, minLon, maxLon }
|
||||
*/
|
||||
export function getBoundingBox(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null {
|
||||
if (coordinates.length === 0) return null;
|
||||
|
||||
let minLat = coordinates[0].latitude;
|
||||
let maxLat = coordinates[0].latitude;
|
||||
let minLon = coordinates[0].longitude;
|
||||
let maxLon = coordinates[0].longitude;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
minLat = Math.min(minLat, coord.latitude);
|
||||
maxLat = Math.max(maxLat, coord.latitude);
|
||||
minLon = Math.min(minLon, coord.longitude);
|
||||
maxLon = Math.max(maxLon, coord.longitude);
|
||||
}
|
||||
|
||||
return { minLat, maxLat, minLon, maxLon };
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열의 중심점 계산
|
||||
*
|
||||
* @param coordinates - 좌표 배열
|
||||
* @returns { latitude, longitude } 중심점
|
||||
*/
|
||||
export function getCenterPoint(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): { latitude: number; longitude: number } | null {
|
||||
if (coordinates.length === 0) return null;
|
||||
|
||||
let sumLat = 0;
|
||||
let sumLon = 0;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
sumLat += coord.latitude;
|
||||
sumLon += coord.longitude;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: sumLat / coordinates.length,
|
||||
longitude: sumLon / coordinates.length,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const VehicleReport = dynamic(
|
||||
() => import("@/components/vehicle/VehicleReport"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
export default function VehicleReportsPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">운행 리포트</h1>
|
||||
<p className="text-muted-foreground">
|
||||
차량 운행 통계 및 분석 리포트를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<VehicleReport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const VehicleTripHistory = dynamic(
|
||||
() => import("@/components/vehicle/VehicleTripHistory"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
export default function VehicleTripsPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">운행 이력 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
차량 운행 이력을 조회하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<VehicleTripHistory />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -503,7 +503,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||
<SelectItem value="empty_vehicle">공차등록</SelectItem>
|
||||
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
|
||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -1664,190 +1664,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 위치정보 가져오기 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
|
||||
{/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */}
|
||||
{/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">🚛 공차등록 설정</h4>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div>
|
||||
<Label htmlFor="geolocation-table">저장할 테이블</Label>
|
||||
<Select
|
||||
value={config.action?.geolocationTableName || currentTableName || ""}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationTableName", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geolocation-lat-field">
|
||||
위도 필드 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="geolocation-lat-field"
|
||||
placeholder="latitude"
|
||||
value={config.action?.geolocationLatField || "latitude"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geolocation-lng-field">
|
||||
경도 필드 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="geolocation-lng-field"
|
||||
placeholder="longitude"
|
||||
value={config.action?.geolocationLngField || "longitude"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geolocation-accuracy-field">정확도 필드 (선택)</Label>
|
||||
<Input
|
||||
id="geolocation-accuracy-field"
|
||||
placeholder="accuracy"
|
||||
value={config.action?.geolocationAccuracyField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geolocation-timestamp-field">타임스탬프 필드 (선택)</Label>
|
||||
<Input
|
||||
id="geolocation-timestamp-field"
|
||||
placeholder="location_time"
|
||||
value={config.action?.geolocationTimestampField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="geolocation-high-accuracy">고정밀 모드</Label>
|
||||
<p className="text-xs text-muted-foreground">GPS 사용 (배터리 소모 증가)</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="geolocation-high-accuracy"
|
||||
checked={config.action?.geolocationHighAccuracy !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 자동 저장 옵션 */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="geolocation-auto-save">DB 자동 저장</Label>
|
||||
<p className="text-xs text-muted-foreground">위치 정보를 바로 DB에 저장</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="geolocation-auto-save"
|
||||
checked={config.action?.geolocationAutoSave === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.action?.geolocationAutoSave && (
|
||||
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>키 필드 (WHERE 조건)</Label>
|
||||
<Input
|
||||
placeholder="user_id"
|
||||
value={config.action?.geolocationKeyField || "user_id"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationKeyField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>키 값 소스</Label>
|
||||
<Select
|
||||
value={config.action?.geolocationKeySourceField || "__userId__"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationKeySourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
|
||||
🔑 로그인 사용자 ID
|
||||
</SelectItem>
|
||||
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
|
||||
🏢 회사 코드
|
||||
</SelectItem>
|
||||
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
|
||||
👤 사용자 이름
|
||||
</SelectItem>
|
||||
{tableColumns.length > 0 && (
|
||||
<>
|
||||
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
|
||||
── 폼 필드 ──
|
||||
</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가 필드 변경 (status 등) */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>추가 변경 필드 (선택)</Label>
|
||||
<Input
|
||||
placeholder="status"
|
||||
value={config.action?.geolocationExtraField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>변경할 값</Label>
|
||||
<Input
|
||||
placeholder="inactive"
|
||||
value={config.action?.geolocationExtraValue || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-amber-700 dark:text-amber-300">
|
||||
위치 정보와 함께 status 같은 필드도 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>참고:</strong> HTTPS 환경에서만 작동합니다.
|
||||
</p>
|
||||
</div>
|
||||
... 공차등록 설정 UI 생략 ...
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* 운행알림 및 종료 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "operation_control" && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getSummaryReport,
|
||||
getDailyReport,
|
||||
getMonthlyReport,
|
||||
getDriverReport,
|
||||
getRouteReport,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
SummaryReport,
|
||||
DailyStat,
|
||||
MonthlyStat,
|
||||
DriverStat,
|
||||
RouteStat,
|
||||
} from "@/lib/api/vehicleTrip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
RefreshCw,
|
||||
Car,
|
||||
Route,
|
||||
Clock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
||||
export default function VehicleReport() {
|
||||
// 요약 통계
|
||||
const [summary, setSummary] = useState<SummaryReport | null>(null);
|
||||
const [summaryPeriod, setSummaryPeriod] = useState("month");
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
|
||||
// 일별 통계
|
||||
const [dailyData, setDailyData] = useState<DailyStat[]>([]);
|
||||
const [dailyStartDate, setDailyStartDate] = useState(
|
||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]
|
||||
);
|
||||
const [dailyEndDate, setDailyEndDate] = useState(
|
||||
new Date().toISOString().split("T")[0]
|
||||
);
|
||||
const [dailyLoading, setDailyLoading] = useState(false);
|
||||
|
||||
// 월별 통계
|
||||
const [monthlyData, setMonthlyData] = useState<MonthlyStat[]>([]);
|
||||
const [monthlyYear, setMonthlyYear] = useState(new Date().getFullYear());
|
||||
const [monthlyLoading, setMonthlyLoading] = useState(false);
|
||||
|
||||
// 운전자별 통계
|
||||
const [driverData, setDriverData] = useState<DriverStat[]>([]);
|
||||
const [driverLoading, setDriverLoading] = useState(false);
|
||||
|
||||
// 구간별 통계
|
||||
const [routeData, setRouteData] = useState<RouteStat[]>([]);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
|
||||
// 요약 로드
|
||||
const loadSummary = useCallback(async () => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const response = await getSummaryReport(summaryPeriod);
|
||||
if (response.success) {
|
||||
setSummary(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("요약 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, [summaryPeriod]);
|
||||
|
||||
// 일별 로드
|
||||
const loadDaily = useCallback(async () => {
|
||||
setDailyLoading(true);
|
||||
try {
|
||||
const response = await getDailyReport({
|
||||
startDate: dailyStartDate,
|
||||
endDate: dailyEndDate,
|
||||
});
|
||||
if (response.success) {
|
||||
setDailyData(response.data?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("일별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setDailyLoading(false);
|
||||
}
|
||||
}, [dailyStartDate, dailyEndDate]);
|
||||
|
||||
// 월별 로드
|
||||
const loadMonthly = useCallback(async () => {
|
||||
setMonthlyLoading(true);
|
||||
try {
|
||||
const response = await getMonthlyReport({ year: monthlyYear });
|
||||
if (response.success) {
|
||||
setMonthlyData(response.data?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("월별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setMonthlyLoading(false);
|
||||
}
|
||||
}, [monthlyYear]);
|
||||
|
||||
// 운전자별 로드
|
||||
const loadDrivers = useCallback(async () => {
|
||||
setDriverLoading(true);
|
||||
try {
|
||||
const response = await getDriverReport({ limit: 20 });
|
||||
if (response.success) {
|
||||
setDriverData(response.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("운전자별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setDriverLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 구간별 로드
|
||||
const loadRoutes = useCallback(async () => {
|
||||
setRouteLoading(true);
|
||||
try {
|
||||
const response = await getRouteReport({ limit: 20 });
|
||||
if (response.success) {
|
||||
setRouteData(response.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("구간별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setRouteLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
// 기간 레이블
|
||||
const getPeriodLabel = (period: string) => {
|
||||
switch (period) {
|
||||
case "today":
|
||||
return "오늘";
|
||||
case "week":
|
||||
return "최근 7일";
|
||||
case "month":
|
||||
return "최근 30일";
|
||||
case "year":
|
||||
return "올해";
|
||||
default:
|
||||
return period;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 요약 통계 카드 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">요약 통계</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={summaryPeriod} onValueChange={setSummaryPeriod}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">오늘</SelectItem>
|
||||
<SelectItem value="week">최근 7일</SelectItem>
|
||||
<SelectItem value="month">최근 30일</SelectItem>
|
||||
<SelectItem value="year">올해</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadSummary}
|
||||
disabled={summaryLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${summaryLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Car className="h-3 w-3" />
|
||||
총 운행
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{summary.totalTrips.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getPeriodLabel(summaryPeriod)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
완료율
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{summary.completionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary.completedTrips} / {summary.totalTrips}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Route className="h-3 w-3" />
|
||||
총 거리
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{formatDistance(summary.totalDistance)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
평균 {formatDistance(summary.avgDistance)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
총 시간
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{formatDuration(summary.totalDuration)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
평균 {formatDuration(Math.round(summary.avgDuration))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
운전자
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{summary.activeDrivers}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">활동 중</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Car className="h-3 w-3" />
|
||||
진행 중
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-green-600">
|
||||
{summary.activeTrips}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">현재 운행</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 통계 탭 */}
|
||||
<Tabs defaultValue="daily" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="daily" onClick={loadDaily}>
|
||||
일별 통계
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monthly" onClick={loadMonthly}>
|
||||
월별 통계
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drivers" onClick={loadDrivers}>
|
||||
운전자별
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routes" onClick={loadRoutes}>
|
||||
구간별
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 일별 통계 */}
|
||||
<TabsContent value="daily">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">일별 운행 통계</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs">시작</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dailyStartDate}
|
||||
onChange={(e) => setDailyStartDate(e.target.value)}
|
||||
className="h-8 w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs">종료</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dailyEndDate}
|
||||
onChange={(e) => setDailyEndDate(e.target.value)}
|
||||
className="h-8 w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={loadDaily} disabled={dailyLoading}>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>날짜</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">완료</TableHead>
|
||||
<TableHead className="text-right">취소</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">총 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dailyLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dailyData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
dailyData.map((row) => (
|
||||
<TableRow key={row.date}>
|
||||
<TableCell>
|
||||
{format(new Date(row.date), "MM/dd (E)", {
|
||||
locale: ko,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.completedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.cancelledCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDuration(row.totalDuration)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 월별 통계 */}
|
||||
<TabsContent value="monthly">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">월별 운행 통계</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(monthlyYear)}
|
||||
onValueChange={(v) => setMonthlyYear(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[0, 1, 2].map((offset) => {
|
||||
const year = new Date().getFullYear() - offset;
|
||||
return (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}년
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={loadMonthly}
|
||||
disabled={monthlyLoading}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>월</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">완료</TableHead>
|
||||
<TableHead className="text-right">취소</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">운전자 수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthlyLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : monthlyData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
monthlyData.map((row) => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell>{row.month}월</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.completedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.cancelledCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.driverCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 운전자별 통계 */}
|
||||
<TabsContent value="drivers">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">운전자별 통계</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={loadDrivers}
|
||||
disabled={driverLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-4 w-4 ${driverLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>운전자</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">완료</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">총 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{driverLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : driverData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
driverData.map((row) => (
|
||||
<TableRow key={row.userId}>
|
||||
<TableCell className="font-medium">
|
||||
{row.userName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.completedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDuration(row.totalDuration)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 구간별 통계 */}
|
||||
<TabsContent value="routes">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">구간별 통계</CardTitle>
|
||||
<Button size="sm" onClick={loadRoutes} disabled={routeLoading}>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-4 w-4 ${routeLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
출발지
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
도착지
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">평균 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{routeLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : routeData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
routeData.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{row.departureName}</TableCell>
|
||||
<TableCell>{row.destinationName}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDuration(Math.round(row.avgDuration))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getTripList,
|
||||
getTripDetail,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
getStatusLabel,
|
||||
getStatusColor,
|
||||
TripSummary,
|
||||
TripDetail,
|
||||
TripListFilters,
|
||||
} from "@/lib/api/vehicleTrip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
MapPin,
|
||||
Clock,
|
||||
Route,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function VehicleTripHistory() {
|
||||
// 상태
|
||||
const [trips, setTrips] = useState<TripSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 필터
|
||||
const [filters, setFilters] = useState<TripListFilters>({
|
||||
status: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
departure: "",
|
||||
arrival: "",
|
||||
});
|
||||
|
||||
// 상세 모달
|
||||
const [selectedTrip, setSelectedTrip] = useState<TripDetail | null>(null);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadTrips = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTripList({
|
||||
...filters,
|
||||
status: filters.status || undefined,
|
||||
startDate: filters.startDate || undefined,
|
||||
endDate: filters.endDate || undefined,
|
||||
departure: filters.departure || undefined,
|
||||
arrival: filters.arrival || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setTrips(response.data || []);
|
||||
setTotal(response.total || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("운행 이력 조회 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTrips();
|
||||
}, [loadTrips]);
|
||||
|
||||
// 상세 조회
|
||||
const handleViewDetail = async (tripId: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailModalOpen(true);
|
||||
try {
|
||||
const response = await getTripDetail(tripId);
|
||||
if (response.success && response.data) {
|
||||
setSelectedTrip(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("운행 상세 조회 실패:", error);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 변경
|
||||
const handleFilterChange = (key: keyof TripListFilters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadTrips();
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setFilters({
|
||||
status: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
departure: "",
|
||||
arrival: "",
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필터 영역 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">검색 조건</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{/* 상태 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select
|
||||
value={filters.status || "all"}
|
||||
onValueChange={(v) =>
|
||||
handleFilterChange("status", v === "all" ? "" : v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="active">운행 중</SelectItem>
|
||||
<SelectItem value="completed">완료</SelectItem>
|
||||
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 시작일 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">시작일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.startDate || ""}
|
||||
onChange={(e) => handleFilterChange("startDate", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종료일 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.endDate || ""}
|
||||
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 출발지 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">출발지</Label>
|
||||
<Input
|
||||
placeholder="출발지"
|
||||
value={filters.departure || ""}
|
||||
onChange={(e) => handleFilterChange("departure", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 도착지 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">도착지</Label>
|
||||
<Input
|
||||
placeholder="도착지"
|
||||
value={filters.arrival || ""}
|
||||
onChange={(e) => handleFilterChange("arrival", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button onClick={handleSearch} size="sm">
|
||||
<Search className="mr-1 h-4 w-4" />
|
||||
검색
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline" size="sm">
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
운행 이력 ({total.toLocaleString()}건)
|
||||
</CardTitle>
|
||||
<Button onClick={loadTrips} variant="ghost" size="sm">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[120px]">운행ID</TableHead>
|
||||
<TableHead>운전자</TableHead>
|
||||
<TableHead>출발지</TableHead>
|
||||
<TableHead>도착지</TableHead>
|
||||
<TableHead>시작 시간</TableHead>
|
||||
<TableHead>종료 시간</TableHead>
|
||||
<TableHead className="text-right">거리</TableHead>
|
||||
<TableHead className="text-right">시간</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : trips.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-24 text-center">
|
||||
운행 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
trips.map((trip) => (
|
||||
<TableRow key={trip.trip_id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{trip.trip_id.substring(0, 15)}...
|
||||
</TableCell>
|
||||
<TableCell>{trip.user_name || trip.user_id}</TableCell>
|
||||
<TableCell>{trip.departure_name || trip.departure || "-"}</TableCell>
|
||||
<TableCell>{trip.destination_name || trip.arrival || "-"}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{format(new Date(trip.start_time), "MM/dd HH:mm", {
|
||||
locale: ko,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{trip.end_time
|
||||
? format(new Date(trip.end_time), "MM/dd HH:mm", {
|
||||
locale: ko,
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{trip.total_distance
|
||||
? formatDistance(Number(trip.total_distance))
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{trip.duration_minutes
|
||||
? formatDuration(trip.duration_minutes)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={getStatusColor(trip.status)}>
|
||||
{getStatusLabel(trip.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(trip.trip_id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상세 모달 */}
|
||||
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>운행 상세 정보</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : selectedTrip ? (
|
||||
<div className="space-y-4">
|
||||
{/* 요약 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
출발지
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.departure_name ||
|
||||
selectedTrip.summary.departure ||
|
||||
"-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
도착지
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.destination_name ||
|
||||
selectedTrip.summary.arrival ||
|
||||
"-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Route className="h-3 w-3" />
|
||||
총 거리
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.total_distance
|
||||
? formatDistance(Number(selectedTrip.summary.total_distance))
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
운행 시간
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.duration_minutes
|
||||
? formatDuration(selectedTrip.summary.duration_minutes)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 운행 정보 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 font-medium">운행 정보</h4>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">운행 ID</span>
|
||||
<span className="font-mono text-xs">
|
||||
{selectedTrip.summary.trip_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">운전자</span>
|
||||
<span>
|
||||
{selectedTrip.summary.user_name ||
|
||||
selectedTrip.summary.user_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">시작 시간</span>
|
||||
<span>
|
||||
{format(
|
||||
new Date(selectedTrip.summary.start_time),
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
{ locale: ko }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">종료 시간</span>
|
||||
<span>
|
||||
{selectedTrip.summary.end_time
|
||||
? format(
|
||||
new Date(selectedTrip.summary.end_time),
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
{ locale: ko }
|
||||
)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">상태</span>
|
||||
<Badge className={getStatusColor(selectedTrip.summary.status)}>
|
||||
{getStatusLabel(selectedTrip.summary.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">위치 기록 수</span>
|
||||
<span>{selectedTrip.summary.location_count}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 경로 데이터 */}
|
||||
{selectedTrip.route && selectedTrip.route.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 font-medium">
|
||||
경로 데이터 ({selectedTrip.route.length}개 지점)
|
||||
</h4>
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px]">#</TableHead>
|
||||
<TableHead>위도</TableHead>
|
||||
<TableHead>경도</TableHead>
|
||||
<TableHead>정확도</TableHead>
|
||||
<TableHead>이전 거리</TableHead>
|
||||
<TableHead>기록 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTrip.route.map((loc, idx) => (
|
||||
<TableRow key={loc.id}>
|
||||
<TableCell className="text-xs">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{loc.latitude.toFixed(6)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{loc.longitude.toFixed(6)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{loc.accuracy ? `${loc.accuracy.toFixed(0)}m` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{loc.distance_from_prev
|
||||
? formatDistance(Number(loc.distance_from_prev))
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{format(new Date(loc.recorded_at), "HH:mm:ss", {
|
||||
locale: ko,
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* 차량 운행 이력 API 클라이언트
|
||||
*/
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// 타입 정의
|
||||
export interface TripSummary {
|
||||
id: number;
|
||||
trip_id: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
vehicle_id?: number;
|
||||
vehicle_number?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
departure_name?: string;
|
||||
destination_name?: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
total_distance: number;
|
||||
duration_minutes?: number;
|
||||
status: "active" | "completed" | "cancelled";
|
||||
location_count: number;
|
||||
company_code: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TripLocation {
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
speed?: number;
|
||||
distance_from_prev?: number;
|
||||
trip_status: "start" | "tracking" | "end";
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface TripDetail {
|
||||
summary: TripSummary;
|
||||
route: TripLocation[];
|
||||
}
|
||||
|
||||
export interface TripListFilters {
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface StartTripParams {
|
||||
vehicleId?: number;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
departureName?: string;
|
||||
destinationName?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface EndTripParams {
|
||||
tripId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface AddLocationParams {
|
||||
tripId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
// API 함수들
|
||||
|
||||
/**
|
||||
* 운행 시작
|
||||
*/
|
||||
export async function startTrip(params: StartTripParams) {
|
||||
const response = await apiClient.post("/vehicle/trip/start", params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 종료
|
||||
*/
|
||||
export async function endTrip(params: EndTripParams) {
|
||||
const response = await apiClient.post("/vehicle/trip/end", params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 기록 추가 (연속 추적)
|
||||
*/
|
||||
export async function addTripLocation(params: AddLocationParams) {
|
||||
const response = await apiClient.post("/vehicle/trip/location", params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 운행 조회
|
||||
*/
|
||||
export async function getActiveTrip() {
|
||||
const response = await apiClient.get("/vehicle/trip/active");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 취소
|
||||
*/
|
||||
export async function cancelTrip(tripId: string) {
|
||||
const response = await apiClient.post("/vehicle/trip/cancel", { tripId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 이력 목록 조회
|
||||
*/
|
||||
export async function getTripList(filters?: TripListFilters) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
if (filters.userId) params.append("userId", filters.userId);
|
||||
if (filters.vehicleId) params.append("vehicleId", String(filters.vehicleId));
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters.departure) params.append("departure", filters.departure);
|
||||
if (filters.arrival) params.append("arrival", filters.arrival);
|
||||
if (filters.limit) params.append("limit", String(filters.limit));
|
||||
if (filters.offset) params.append("offset", String(filters.offset));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/trips?${queryString}` : "/vehicle/trips";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 상세 조회 (경로 포함)
|
||||
*/
|
||||
export async function getTripDetail(tripId: string): Promise<{ success: boolean; data?: TripDetail; message?: string }> {
|
||||
const response = await apiClient.get(`/vehicle/trips/${tripId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거리 포맷팅 (km)
|
||||
*/
|
||||
export function formatDistance(distanceKm: number): string {
|
||||
if (distanceKm < 1) {
|
||||
return `${Math.round(distanceKm * 1000)}m`;
|
||||
}
|
||||
return `${distanceKm.toFixed(2)}km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 시간 포맷팅
|
||||
*/
|
||||
export function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) {
|
||||
return `${minutes}분`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 한글 변환
|
||||
*/
|
||||
export function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "운행 중";
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "cancelled":
|
||||
return "취소됨";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 색상
|
||||
*/
|
||||
export function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "completed":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "cancelled":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 리포트 API ==============
|
||||
|
||||
export interface DailyStat {
|
||||
date: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
cancelledCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
}
|
||||
|
||||
export interface WeeklyStat {
|
||||
weekNumber: number;
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
}
|
||||
|
||||
export interface MonthlyStat {
|
||||
month: number;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
cancelledCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
driverCount: number;
|
||||
}
|
||||
|
||||
export interface SummaryReport {
|
||||
period: string;
|
||||
totalTrips: number;
|
||||
completedTrips: number;
|
||||
activeTrips: number;
|
||||
cancelledTrips: number;
|
||||
completionRate: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
activeDrivers: number;
|
||||
}
|
||||
|
||||
export interface DriverStat {
|
||||
userId: string;
|
||||
userName: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
}
|
||||
|
||||
export interface RouteStat {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
departureName: string;
|
||||
destinationName: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
totalDistance: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
*/
|
||||
export async function getSummaryReport(period?: string) {
|
||||
const url = period ? `/vehicle/reports/summary?period=${period}` : "/vehicle/reports/summary";
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
*/
|
||||
export async function getDailyReport(filters?: { startDate?: string; endDate?: string; userId?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters?.userId) params.append("userId", filters.userId);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/daily?${queryString}` : "/vehicle/reports/daily";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
*/
|
||||
export async function getWeeklyReport(filters?: { year?: number; month?: number; userId?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.year) params.append("year", String(filters.year));
|
||||
if (filters?.month) params.append("month", String(filters.month));
|
||||
if (filters?.userId) params.append("userId", filters.userId);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/weekly?${queryString}` : "/vehicle/reports/weekly";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
export async function getMonthlyReport(filters?: { year?: number; userId?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.year) params.append("year", String(filters.year));
|
||||
if (filters?.userId) params.append("userId", filters.userId);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/monthly?${queryString}` : "/vehicle/reports/monthly";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
*/
|
||||
export async function getDriverReport(filters?: { startDate?: string; endDate?: string; limit?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters?.limit) params.append("limit", String(filters.limit));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/by-driver?${queryString}` : "/vehicle/reports/by-driver";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
*/
|
||||
export async function getRouteReport(filters?: { startDate?: string; endDate?: string; limit?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters?.limit) params.append("limit", String(filters.limit));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/by-route?${queryString}` : "/vehicle/reports/by-route";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ export type ButtonActionType =
|
|||
| "excel_upload" // 엑셀 업로드
|
||||
| "barcode_scan" // 바코드 스캔
|
||||
| "code_merge" // 코드 병합
|
||||
| "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경)
|
||||
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
|
||||
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||
|
|
@ -163,6 +163,10 @@ export interface ButtonActionConfig {
|
|||
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
|
||||
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
|
||||
|
||||
// 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용)
|
||||
emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true)
|
||||
emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초)
|
||||
|
||||
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
|
||||
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
|
||||
editModalTitle?: string; // 편집 모달 제목
|
||||
|
|
@ -350,8 +354,8 @@ export class ButtonActionExecutor {
|
|||
case "transferData":
|
||||
return await this.handleTransferData(config, context);
|
||||
|
||||
case "empty_vehicle":
|
||||
return await this.handleEmptyVehicle(config, context);
|
||||
// case "empty_vehicle":
|
||||
// return await this.handleEmptyVehicle(config, context);
|
||||
|
||||
case "operation_control":
|
||||
return await this.handleOperationControl(config, context);
|
||||
|
|
@ -3501,6 +3505,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
/**
|
||||
* 위치 이력 테이블에 저장 (내부 헬퍼)
|
||||
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
|
||||
*/
|
||||
private static async saveLocationToHistory(
|
||||
tripId: string | null,
|
||||
|
|
@ -3517,13 +3522,15 @@ export class ButtonActionExecutor {
|
|||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords;
|
||||
|
||||
const locationData = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
altitude: position.coords.altitude,
|
||||
speed: position.coords.speed,
|
||||
heading: position.coords.heading,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
tripId,
|
||||
tripStatus,
|
||||
departure,
|
||||
|
|
@ -3536,6 +3543,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
console.log("📍 [saveLocationToHistory] 위치 저장:", locationData);
|
||||
|
||||
// 1. vehicle_location_history에 저장
|
||||
const response = await apiClient.post(`/dynamic-form/location-history`, locationData);
|
||||
|
||||
if (response.data?.success) {
|
||||
|
|
@ -3544,6 +3552,41 @@ export class ButtonActionExecutor {
|
|||
console.warn("⚠️ 위치 이력 저장 실패:", response.data);
|
||||
}
|
||||
|
||||
// 2. vehicles 테이블의 latitude/longitude도 업데이트 (실시간 위치 반영)
|
||||
if (this.trackingContext && this.trackingConfig) {
|
||||
const keyField = this.trackingConfig.trackingStatusKeyField || "user_id";
|
||||
const keySourceField = this.trackingConfig.trackingStatusKeySourceField || "__userId__";
|
||||
const keyValue = resolveSpecialKeyword(keySourceField, this.trackingContext);
|
||||
const vehiclesTableName = this.trackingConfig.trackingStatusTableName || "vehicles";
|
||||
|
||||
if (keyValue) {
|
||||
try {
|
||||
// latitude 업데이트
|
||||
await apiClient.put(`/dynamic-form/update-field`, {
|
||||
tableName: vehiclesTableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField: "latitude",
|
||||
updateValue: latitude,
|
||||
});
|
||||
|
||||
// longitude 업데이트
|
||||
await apiClient.put(`/dynamic-form/update-field`, {
|
||||
tableName: vehiclesTableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField: "longitude",
|
||||
updateValue: longitude,
|
||||
});
|
||||
|
||||
console.log(`✅ vehicles 테이블 위치 업데이트: (${latitude.toFixed(6)}, ${longitude.toFixed(6)})`);
|
||||
} catch (vehicleUpdateError) {
|
||||
// 컬럼이 없으면 조용히 무시
|
||||
console.warn("⚠️ vehicles 테이블 위치 업데이트 실패 (무시):", vehicleUpdateError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error("❌ 위치 이력 저장 오류:", error);
|
||||
|
|
@ -3673,13 +3716,18 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 공차 추적용 watchId 저장
|
||||
private static emptyVehicleWatchId: number | null = null;
|
||||
private static emptyVehicleTripId: string | null = null;
|
||||
|
||||
/**
|
||||
* 공차등록 액션 처리
|
||||
* - 위치 수집 + 상태 변경 (예: status → inactive)
|
||||
* - 연속 위치 추적 시작 (vehicle_location_history에 저장)
|
||||
*/
|
||||
private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
|
||||
console.log("📍 공차등록 액션 실행:", { config, context });
|
||||
|
||||
// 브라우저 Geolocation API 지원 확인
|
||||
if (!navigator.geolocation) {
|
||||
|
|
@ -3708,7 +3756,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
const { latitude, longitude, accuracy } = position.coords;
|
||||
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
|
||||
const timestamp = new Date(position.timestamp);
|
||||
|
||||
console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy });
|
||||
|
|
@ -3777,8 +3825,15 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`);
|
||||
|
||||
// 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록)
|
||||
if (config.emptyVehicleTracking !== false) {
|
||||
await this.startEmptyVehicleTracking(config, context, {
|
||||
latitude, longitude, accuracy, speed, heading, altitude
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(config.successMessage || "위치 정보가 저장되었습니다.");
|
||||
toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다.");
|
||||
} catch (saveError) {
|
||||
console.error("❌ 위치정보 자동 저장 실패:", saveError);
|
||||
toast.error("위치 정보 저장에 실패했습니다.");
|
||||
|
|
@ -3795,7 +3850,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 위치정보 가져오기 실패:", error);
|
||||
console.error("❌ 공차등록 실패:", error);
|
||||
toast.dismiss();
|
||||
|
||||
// GeolocationPositionError 처리
|
||||
|
|
@ -3821,6 +3876,122 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공차 상태에서 연속 위치 추적 시작
|
||||
*/
|
||||
private static async startEmptyVehicleTracking(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null }
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 기존 추적이 있으면 중지
|
||||
if (this.emptyVehicleWatchId !== null) {
|
||||
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
|
||||
this.emptyVehicleWatchId = null;
|
||||
}
|
||||
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
// Trip ID 생성 (공차용)
|
||||
const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
||||
this.emptyVehicleTripId = tripId;
|
||||
|
||||
const userId = context.userId || "";
|
||||
const companyCode = context.companyCode || "";
|
||||
const departure = context.formData?.departure || "";
|
||||
const arrival = context.formData?.arrival || "";
|
||||
const departureName = context.formData?.departure_name || "";
|
||||
const destinationName = context.formData?.destination_name || "";
|
||||
|
||||
// 시작 위치 기록
|
||||
try {
|
||||
await apiClient.post("/dynamic-form/location-history", {
|
||||
tripId,
|
||||
userId,
|
||||
latitude: initialPosition.latitude,
|
||||
longitude: initialPosition.longitude,
|
||||
accuracy: initialPosition.accuracy,
|
||||
speed: initialPosition.speed,
|
||||
heading: initialPosition.heading,
|
||||
altitude: initialPosition.altitude,
|
||||
tripStatus: "empty_start", // 공차 시작
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
companyCode,
|
||||
});
|
||||
console.log("📍 공차 시작 위치 기록 완료:", tripId);
|
||||
} catch (err) {
|
||||
console.warn("⚠️ 공차 시작 위치 기록 실패 (테이블 없을 수 있음):", err);
|
||||
}
|
||||
|
||||
// 추적 간격 (기본 10초)
|
||||
const trackingInterval = config.emptyVehicleTrackingInterval || 10000;
|
||||
|
||||
// watchPosition으로 연속 추적
|
||||
this.emptyVehicleWatchId = navigator.geolocation.watchPosition(
|
||||
async (position) => {
|
||||
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
|
||||
|
||||
try {
|
||||
await apiClient.post("/dynamic-form/location-history", {
|
||||
tripId: this.emptyVehicleTripId,
|
||||
userId,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
speed,
|
||||
heading,
|
||||
altitude,
|
||||
tripStatus: "empty_tracking", // 공차 추적 중
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
companyCode,
|
||||
});
|
||||
console.log("📍 공차 위치 기록:", { latitude: latitude.toFixed(6), longitude: longitude.toFixed(6) });
|
||||
} catch (err) {
|
||||
console.warn("⚠️ 공차 위치 기록 실패:", err);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error("❌ 공차 위치 추적 오류:", error.message);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: trackingInterval,
|
||||
maximumAge: 0,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId });
|
||||
} catch (error) {
|
||||
console.error("❌ 공차 위치 추적 시작 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공차 위치 추적 중지 (운행 전환 시 호출)
|
||||
*/
|
||||
public static stopEmptyVehicleTracking(): void {
|
||||
if (this.emptyVehicleWatchId !== null) {
|
||||
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
|
||||
console.log("🛑 공차 위치 추적 중지:", { tripId: this.emptyVehicleTripId, watchId: this.emptyVehicleWatchId });
|
||||
this.emptyVehicleWatchId = null;
|
||||
this.emptyVehicleTripId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 공차 추적 Trip ID 반환
|
||||
*/
|
||||
public static getEmptyVehicleTripId(): string | null {
|
||||
return this.emptyVehicleTripId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지)
|
||||
*/
|
||||
|
|
@ -3885,7 +4056,13 @@ export class ButtonActionExecutor {
|
|||
*/
|
||||
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("🔄 필드 값 변경 액션 실행:", { config, context });
|
||||
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
||||
|
||||
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
|
||||
if (this.emptyVehicleWatchId !== null) {
|
||||
this.stopEmptyVehicleTracking();
|
||||
console.log("🛑 공차 추적 종료 후 운행 시작");
|
||||
}
|
||||
|
||||
// 🆕 연속 위치 추적 모드 처리
|
||||
if (config.updateWithTracking) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue