573 lines
12 KiB
Vue
573 lines
12 KiB
Vue
<template>
|
|
<div class="routes-page">
|
|
<div class="routes-header">
|
|
<h2>Service Routes</h2>
|
|
<p class="routes-subtitle">Manage and track daily service routes for technicians</p>
|
|
</div>
|
|
|
|
<!-- Routes Data Table -->
|
|
<div class="routes-table-container">
|
|
<DataTable :data="tableData" :columns="columns" @row-click="viewRouteDetails" />
|
|
</div>
|
|
|
|
<!-- Route Details Modal -->
|
|
<v-dialog v-model="routeDialog" max-width="1200px" persistent>
|
|
<v-card v-if="selectedRoute">
|
|
<v-card-title class="d-flex justify-space-between align-center pa-4">
|
|
<div>
|
|
<h3>{{ selectedRoute.routeName }}</h3>
|
|
<span class="text-subtitle-1 text-medium-emphasis"
|
|
>{{ selectedRoute.routeId }} - {{ selectedRoute.date }}</span
|
|
>
|
|
</div>
|
|
<div class="d-flex align-center gap-2">
|
|
<v-chip :color="getStatusColor(selectedRoute.status)" size="small">
|
|
{{ selectedRoute.status.toUpperCase() }}
|
|
</v-chip>
|
|
<v-btn
|
|
icon="mdi-close"
|
|
variant="text"
|
|
@click="routeDialog = false"
|
|
></v-btn>
|
|
</div>
|
|
</v-card-title>
|
|
|
|
<v-divider></v-divider>
|
|
|
|
<v-card-text class="pa-0">
|
|
<div class="route-details-container">
|
|
<!-- Route Info Panel -->
|
|
<div class="route-info-panel">
|
|
<div class="route-summary pa-4">
|
|
<h4 class="mb-3">Route Summary</h4>
|
|
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<v-icon class="mr-2" size="small"
|
|
>mdi-account-hard-hat</v-icon
|
|
>
|
|
<span class="label">Technician:</span>
|
|
<span class="value">{{ selectedRoute.technician }}</span>
|
|
</div>
|
|
|
|
<div class="info-item">
|
|
<v-icon class="mr-2" size="small">mdi-clock-start</v-icon>
|
|
<span class="label">Start Time:</span>
|
|
<span class="value">{{ selectedRoute.startTime }}</span>
|
|
</div>
|
|
|
|
<div class="info-item">
|
|
<v-icon class="mr-2" size="small">mdi-car</v-icon>
|
|
<span class="label">Vehicle:</span>
|
|
<span class="value">{{ selectedRoute.vehicleId }}</span>
|
|
</div>
|
|
|
|
<div class="info-item">
|
|
<v-icon class="mr-2" size="small"
|
|
>mdi-map-marker-distance</v-icon
|
|
>
|
|
<span class="label">Total Miles:</span>
|
|
<span class="value"
|
|
>{{ selectedRoute.totalMileage }} mi</span
|
|
>
|
|
</div>
|
|
|
|
<div class="info-item">
|
|
<v-icon class="mr-2" size="small">mdi-timer</v-icon>
|
|
<span class="label">Est. Duration:</span>
|
|
<span class="value">{{
|
|
selectedRoute.estimatedDuration
|
|
}}</span>
|
|
</div>
|
|
|
|
<div class="info-item">
|
|
<v-icon class="mr-2" size="small"
|
|
>mdi-map-marker-multiple</v-icon
|
|
>
|
|
<span class="label">Stops:</span>
|
|
<span class="value"
|
|
>{{ selectedRoute.completedStops }}/{{
|
|
selectedRoute.totalStops
|
|
}}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stops List -->
|
|
<div class="stops-section pa-4">
|
|
<h4 class="mb-3">Route Stops</h4>
|
|
|
|
<div class="stops-list">
|
|
<div
|
|
v-for="stop in selectedRoute.stops"
|
|
:key="stop.stopId"
|
|
class="stop-item"
|
|
:class="getStopStatusClass(stop.status)"
|
|
>
|
|
<div class="stop-number">{{ stop.stopId }}</div>
|
|
|
|
<div class="stop-content">
|
|
<div class="stop-header">
|
|
<span class="customer-name">{{
|
|
stop.customer
|
|
}}</span>
|
|
<v-chip
|
|
:color="getStopStatusColor(stop.status)"
|
|
size="x-small"
|
|
>
|
|
{{ stop.status }}
|
|
</v-chip>
|
|
</div>
|
|
|
|
<div class="stop-details">
|
|
<div class="stop-address">
|
|
<v-icon size="x-small" class="mr-1"
|
|
>mdi-map-marker</v-icon
|
|
>
|
|
{{ stop.address }}
|
|
</div>
|
|
|
|
<div class="stop-service">
|
|
<v-icon size="x-small" class="mr-1"
|
|
>mdi-wrench</v-icon
|
|
>
|
|
{{ stop.serviceType }}
|
|
</div>
|
|
|
|
<div class="stop-time">
|
|
<v-icon size="x-small" class="mr-1"
|
|
>mdi-clock</v-icon
|
|
>
|
|
{{ stop.estimatedTime }} ({{
|
|
stop.duration
|
|
}}
|
|
min)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map Panel -->
|
|
<div class="map-panel">
|
|
<div class="map-container">
|
|
<div class="map-placeholder">
|
|
<div class="map-content">
|
|
<v-icon size="64" color="primary">mdi-map</v-icon>
|
|
<h4 class="mt-3 mb-2">Route Map</h4>
|
|
<p class="text-body-2 text-center mb-4">
|
|
Interactive map showing route path and stops
|
|
</p>
|
|
|
|
<!-- Mock Map Legend -->
|
|
<div class="map-legend">
|
|
<div class="legend-item">
|
|
<div class="legend-dot completed"></div>
|
|
<span>Completed</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-dot in-progress"></div>
|
|
<span>In Progress</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-dot not-started"></div>
|
|
<span>Not Started</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mock Route Stats -->
|
|
<div class="route-stats mt-4">
|
|
<div class="stat-item">
|
|
<div class="stat-value">
|
|
{{ selectedRoute.totalMileage }}
|
|
</div>
|
|
<div class="stat-label">Total Miles</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value">
|
|
{{ selectedRoute.totalStops }}
|
|
</div>
|
|
<div class="stat-label">Stops</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value">
|
|
{{
|
|
Math.round(
|
|
(selectedRoute.totalMileage /
|
|
selectedRoute.totalStops) *
|
|
10,
|
|
) / 10
|
|
}}
|
|
</div>
|
|
<div class="stat-label">Avg Miles/Stop</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-divider></v-divider>
|
|
|
|
<v-card-actions class="pa-4">
|
|
<v-spacer></v-spacer>
|
|
<v-btn color="primary" variant="outlined" @click="routeDialog = false">
|
|
Close
|
|
</v-btn>
|
|
<v-btn color="primary" @click="optimizeRoute"> Optimize Route </v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from "vue";
|
|
import DataTable from "../DataTable.vue";
|
|
import Api from "../../api";
|
|
|
|
// Reactive data
|
|
const tableData = ref([]);
|
|
const routeDialog = ref(false);
|
|
const selectedRoute = ref(null);
|
|
|
|
// Table columns configuration
|
|
const columns = [
|
|
{ label: "Route ID", fieldName: "routeId", type: "text", sortable: true },
|
|
{ label: "Route Name", fieldName: "routeName", type: "text", sortable: true },
|
|
{ label: "Technician", fieldName: "technician", type: "text", sortable: true },
|
|
{ label: "Date", fieldName: "date", type: "text", sortable: true },
|
|
{ label: "Status", fieldName: "status", type: "status", sortable: true },
|
|
{ label: "Progress", fieldName: "progress", type: "text", sortable: true },
|
|
{ label: "Total Stops", fieldName: "totalStops", type: "text", sortable: true },
|
|
{ label: "Est. Duration", fieldName: "estimatedDuration", type: "text", sortable: true },
|
|
{ label: "Actions", fieldName: "actions", type: "button", sortable: false },
|
|
];
|
|
|
|
// Methods
|
|
const viewRouteDetails = (event) => {
|
|
const routeId = event.data.routeId;
|
|
const route = tableData.value.find((r) => r.routeId === routeId);
|
|
if (route && route.fullData) {
|
|
selectedRoute.value = route.fullData;
|
|
routeDialog.value = true;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
switch (status?.toLowerCase()) {
|
|
case "completed":
|
|
return "success";
|
|
case "in progress":
|
|
return "warning";
|
|
case "not started":
|
|
return "info";
|
|
default:
|
|
return "grey";
|
|
}
|
|
};
|
|
|
|
const getStopStatusColor = (status) => {
|
|
switch (status?.toLowerCase()) {
|
|
case "completed":
|
|
return "success";
|
|
case "in progress":
|
|
return "warning";
|
|
case "not started":
|
|
return "grey";
|
|
default:
|
|
return "grey";
|
|
}
|
|
};
|
|
|
|
const getStopStatusClass = (status) => {
|
|
return `stop-status-${status.replace(" ", "-")}`;
|
|
};
|
|
|
|
const optimizeRoute = () => {
|
|
alert("Route optimization feature coming soon!");
|
|
};
|
|
|
|
// Load data on component mount
|
|
onMounted(async () => {
|
|
try {
|
|
const data = await Api.getRouteData();
|
|
|
|
// Transform data for table display and keep full data reference
|
|
tableData.value = data.map((route) => ({
|
|
routeId: route.routeId,
|
|
routeName: route.routeName,
|
|
technician: route.technician,
|
|
date: route.date,
|
|
status: route.status,
|
|
progress: `${route.completedStops}/${route.totalStops}`,
|
|
totalStops: route.totalStops,
|
|
estimatedDuration: route.estimatedDuration,
|
|
actions: "View Details",
|
|
fullData: route, // Keep reference to full route data
|
|
}));
|
|
|
|
console.log("Loaded routes:", tableData.value);
|
|
} catch (error) {
|
|
console.error("Error loading routes:", error);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.routes-page {
|
|
padding: 20px;
|
|
}
|
|
|
|
.routes-header {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.routes-header h2 {
|
|
margin-bottom: 8px;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.routes-subtitle {
|
|
color: #666;
|
|
margin: 0;
|
|
}
|
|
|
|
.routes-table-container {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.route-details-container {
|
|
display: flex;
|
|
height: 600px;
|
|
}
|
|
|
|
.route-info-panel {
|
|
flex: 1;
|
|
border-right: 1px solid #e0e0e0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.route-summary {
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.info-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
.info-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.info-item .label {
|
|
font-weight: 500;
|
|
min-width: 80px;
|
|
}
|
|
|
|
.info-item .value {
|
|
color: #666;
|
|
}
|
|
|
|
.stops-section {
|
|
flex: 1;
|
|
}
|
|
|
|
.stops-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.stop-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid #e0e0e0;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.stop-item:hover {
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.stop-item.stop-status-completed {
|
|
background-color: #f1f8e9;
|
|
border-color: #4caf50;
|
|
}
|
|
|
|
.stop-item.stop-status-in-progress {
|
|
background-color: #fff8e1;
|
|
border-color: #ff9800;
|
|
}
|
|
|
|
.stop-item.stop-status-not-started {
|
|
background-color: #fafafa;
|
|
border-color: #e0e0e0;
|
|
}
|
|
|
|
.stop-number {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
background-color: #1976d2;
|
|
color: white;
|
|
border-radius: 50%;
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stop-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.stop-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.customer-name {
|
|
font-weight: 600;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.stop-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.stop-address,
|
|
.stop-service,
|
|
.stop-time {
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}
|
|
|
|
.map-panel {
|
|
width: 400px;
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.map-container {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.map-placeholder {
|
|
text-align: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.map-content {
|
|
background: white;
|
|
padding: 32px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.map-legend {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.legend-dot.completed {
|
|
background-color: #4caf50;
|
|
}
|
|
|
|
.legend-dot.in-progress {
|
|
background-color: #ff9800;
|
|
}
|
|
|
|
.legend-dot.not-started {
|
|
background-color: #9e9e9e;
|
|
}
|
|
|
|
.route-stats {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
padding-top: 16px;
|
|
border-top: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.stat-item {
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.5em;
|
|
font-weight: 600;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.75em;
|
|
color: #666;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* Responsive design */
|
|
@media (max-width: 900px) {
|
|
.route-details-container {
|
|
flex-direction: column;
|
|
height: auto;
|
|
}
|
|
|
|
.route-info-panel {
|
|
border-right: none;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.map-panel {
|
|
width: 100%;
|
|
min-height: 300px;
|
|
}
|
|
|
|
.info-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|