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:
hjlee 2025-12-02 09:58:05 +09:00
commit 650c5ef722
16 changed files with 3469 additions and 200 deletions

View File

@ -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);

View File

@ -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 };
}

View File

@ -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 || "구간별 통계 조회에 실패했습니다.",
});
}
};

View File

@ -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 || "운행 취소에 실패했습니다.",
});
}
};

View File

@ -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;

View File

@ -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');

View File

@ -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();

View File

@ -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();

View File

@ -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,
};
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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" && (

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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) {